├── LICENSE.md ├── README.md ├── composer.json └── src ├── ClientException.php ├── CurlClient.php ├── CurlHandle.php ├── CurlMultiClient.php ├── HTTPClientAbstract.php ├── HTTPClientInterface.php ├── HTTPOptions.php ├── HTTPOptionsTrait.php ├── MultiResponseHandlerInterface.php ├── NetworkException.php ├── RequestException.php └── StreamClient.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 smiley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chillerlan/php-httpinterface 2 | 3 | A [PSR-7](https://www.php-fig.org/psr/psr-7/)/[PSR-17](https://www.php-fig.org/psr/psr-17/)/[PSR-18](https://www.php-fig.org/psr/psr-18/) HTTP message/client implementation. 4 | 5 | [![PHP Version Support][php-badge]][php] 6 | [![version][packagist-badge]][packagist] 7 | [![license][license-badge]][license] 8 | [![Continuous Integration][gh-action-badge]][gh-action] 9 | [![Coverage][coverage-badge]][coverage] 10 | [![Codacy][codacy-badge]][codacy] 11 | [![Packagist downloads][downloads-badge]][downloads] 12 | 13 | [php-badge]: https://img.shields.io/packagist/php-v/chillerlan/php-httpinterface?logo=php&color=8892BF 14 | [php]: https://www.php.net/supported-versions.php 15 | [packagist-badge]: https://img.shields.io/packagist/v/chillerlan/php-httpinterface.svg?logo=packagist 16 | [packagist]: https://packagist.org/packages/chillerlan/php-httpinterface 17 | [license-badge]: https://img.shields.io/github/license/chillerlan/php-httpinterface.svg 18 | [license]: https://github.com/chillerlan/php-httpinterface/blob/main/LICENSE 19 | [gh-action-badge]: https://img.shields.io/github/actions/workflow/status/chillerlan/php-httpinterface/ci.yml?branch=main&logo=github 20 | [gh-action]: https://github.com/chillerlan/php-httpinterface/actions/workflows/ci.yml?query=branch%3Amain 21 | [coverage-badge]: https://img.shields.io/codecov/c/github/chillerlan/php-httpinterface.svg?logo=codecov 22 | [coverage]: https://codecov.io/github/chillerlan/php-httpinterface 23 | [codacy-badge]: https://img.shields.io/codacy/grade/0ad3a5f9abe547cca5d5b3dff0ba3383?logo=codacy 24 | [codacy]: https://app.codacy.com/gh/chillerlan/php-httpinterface/dashboard 25 | [downloads-badge]: https://img.shields.io/packagist/dt/chillerlan/php-httpinterface.svg?logo=packagist 26 | [downloads]: https://packagist.org/packages/chillerlan/php-httpinterface/stats 27 | 28 | 29 | # Documentation 30 | 31 | An API documentation created with [phpDocumentor](https://www.phpdoc.org/) can be found at https://chillerlan.github.io/php-httpinterface/ (WIP). 32 | 33 | 34 | ## Requirements 35 | - PHP 8.1+ 36 | - [`ext-curl`](https://www.php.net/manual/book.curl.php) 37 | - from dependencies: 38 | - [`ext-fileinfo`](https://www.php.net/manual/book.fileinfo.php) 39 | - [`ext-intl`](https://www.php.net/manual/book.intl.php) 40 | - [`ext-json`](https://www.php.net/manual/book.json.php) 41 | - [`ext-mbstring`](https://www.php.net/manual/book.mbstring.php) 42 | - [`ext-simplexml`](https://www.php.net/manual/book.simplexml.php) 43 | - [`ext-zlib`](https://www.php.net/manual/book.zlib.php) 44 | 45 | 46 | ## Installation with [composer](https://getcomposer.org) 47 | 48 | ### Terminal 49 | 50 | ``` 51 | composer require chillerlan/php-httpinterface 52 | ``` 53 | 54 | ### composer.json 55 | 56 | ```json 57 | { 58 | "require": { 59 | "php": "^8.1", 60 | "chillerlan/php-httpinterface": "dev-main#" 61 | } 62 | } 63 | ``` 64 | Note: replace `dev-main` with a [version constraint](https://getcomposer.org/doc/articles/versions.md#writing-version-constraints), e.g. `^6.0` - see [releases](https://github.com/chillerlan/php-httpinterface/releases) for valid versions. 65 | 66 | Profit! 67 | 68 | 69 | ## Quickstart 70 | 71 | The HTTP clients `CurlClient` and `StreamClient` are invoked with a `ResponseFactoryInterface` instance 72 | as the first parameter, followed by optional `HTTPOptions` and PSR-3 `LoggerInterface` instances. 73 | You can then send a request via the implemented PSR-18 method `ClientInterface::sendRequest()`, 74 | using a PSR-7 `RequestInterface` and expect a PSR-7 `ResponseInterface`. 75 | 76 | 77 | ### `CurlClient`, `StreamClient` 78 | 79 | ```php 80 | $options = new HTTPOptions; 81 | $options->ca_info = '/path/to/cacert.pem'; 82 | $options->user_agent = 'my cool user agent 1.0'; 83 | $options->dns_over_https = 'https://cloudflare-dns.com/dns-query'; 84 | 85 | $httpClient = new CurlClient($responseFactory, $options, $logger); 86 | $request = $requestFactory->createRequest('GET', 'https://www.example.com?foo=bar'); 87 | 88 | $httpClient->sendRequest($request); 89 | ``` 90 | 91 | 92 | ### `CurlMultiClient` 93 | 94 | The `CurlMultiClient` client implements asynchronous multi requests (["rolling-curl"](https://code.google.com/archive/p/rolling-curl/)). 95 | It needs a `MultiResponseHandlerInterface` that parses the incoming responses, the callback may return a failed request to the stack: 96 | 97 | ```php 98 | $handler = new class () implements MultiResponseHandlerInterface{ 99 | 100 | public function handleResponse( 101 | ResponseInterface $response, // the incoming response 102 | RequestInterface $request, // the corresponding request 103 | int $id, // the request id 104 | array|null $curl_info, // the curl_getinfo() result for this request 105 | ):RequestInterface|null{ 106 | 107 | if($response->getStatusCode() !== 200){ 108 | // return the failed request back to the stack 109 | return $request; 110 | } 111 | 112 | try{ 113 | $body = $response->getBody(); 114 | 115 | // the response body is empty for some reason, we pretend that's fine and exit 116 | if($body->getSize() === 0){ 117 | return null; 118 | } 119 | 120 | // parse the response body, store the result etc. 121 | $data = $body->getContents(); 122 | 123 | // save data to file, database or whatever... 124 | // ... 125 | 126 | } 127 | catch(Throwable){ 128 | // something went wrong, return the request to the stack for another try 129 | return $request; 130 | } 131 | 132 | // everything ok, nothing to return 133 | return null; 134 | } 135 | 136 | }; 137 | ``` 138 | 139 | You can then invoke the multi request client - the `MultiResponseHandlerInterface` and `ResponseFactoryInterface` are mandatory, 140 | `HTTPOptions` and `LoggerInterface` are optional: 141 | 142 | ```php 143 | $options = new HTTPOptions; 144 | $options->ca_info = '/path/to/cacert.pem'; 145 | $options->user_agent = 'my cool user agent 1.0'; 146 | $options->sleep = 750000; // microseconds, see usleep() 147 | $options->window_size = 5; 148 | $options->retries = 1; 149 | 150 | $multiClient = new CurlMultiClient($handler, $responseFactory, $options, $logger); 151 | 152 | // create and add the requests 153 | foreach(['..', '...', '....'] as $item){ 154 | $multiClient->addRequest($factory->createRequest('GET', $endpoint.'/'.$item)); 155 | } 156 | 157 | // process the queue 158 | $multiClient->process(); 159 | ``` 160 | 161 | 162 | ### `URLExtractor` 163 | 164 | The `URLExtractor` wraps a PSR-18 `ClientInterface` to extract and follow shortened URLs to their original location. 165 | 166 | ```php 167 | $options = new HTTPOptions; 168 | $options->user_agent = 'my cool user agent 1.0'; 169 | $options->ssl_verifypeer = false; 170 | $options->curl_options = [ 171 | CURLOPT_FOLLOWLOCATION => false, 172 | CURLOPT_MAXREDIRS => 25, 173 | ]; 174 | 175 | $httpClient = new CurlClient($responseFactory, $options, $logger); 176 | $urlExtractor = new URLExtractor($httpClient, $responseFactory); 177 | 178 | $request = $factory->createRequest('GET', 'https://t.co/ZSS6nVOcVp'); 179 | 180 | $urlExtractor->sendRequest($request); // -> response from the final location 181 | 182 | // you can retrieve an array with all followed locations afterwards 183 | $responses = $this->http->getResponses(); // -> ResponseInterface[] 184 | 185 | // if you just want the URL of the final location, you can use the extract method: 186 | $url = $this->http->extract('https://t.co/ZSS6nVOcVp'); // -> https://api.guildwars2.com/v2/build 187 | ``` 188 | 189 | 190 | ### `LoggingClient` 191 | 192 | The `LoggingClient` wraps a `ClientInterface` and outputs the HTTP messages in a readable way through a `LoggerInterface` (do NOT use in production!). 193 | 194 | ```php 195 | $loggingClient = new LoggingClient($httpClient, $logger); 196 | 197 | $loggingClient->sendRequest($request); // -> log to output given via logger 198 | ``` 199 | 200 | 201 | ### Auto generated API documentation 202 | 203 | The API documentation can be auto generated with [phpDocumentor](https://www.phpdoc.org/). 204 | There is an [online version available](https://chillerlan.github.io/php-httpinterface/) via the [gh-pages branch](https://github.com/chillerlan/php-httpinterface/tree/gh-pages) that is [automatically deployed](https://github.com/chillerlan/php-httpinterface/deployments) on each push to main. 205 | 206 | Locally created docs will appear in the directory `.build/phpdocs/`. If you'd like to create local docs, please follow these steps: 207 | 208 | - [download phpDocumentor](https://github.com/phpDocumentor/phpDocumentor/releases) v3+ as .phar archive 209 | - run it in the repository root directory: 210 | - on Windows `c:\path\to\php.exe c:\path\to\phpDocumentor.phar --config=phpdoc.xml` 211 | - on Linux just `php /path/to/phpDocumentor.phar --config=phpdoc.xml` 212 | - open [index.html](./.build/phpdocs/index.html) in a browser 213 | - profit! 214 | 215 | 216 | ## Disclaimer 217 | 218 | Use at your own risk! 219 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chillerlan/php-httpinterface", 3 | "description": "A PSR-7/17/18 http message/client implementation", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "http", "request", "response", "message", "client", "factory", "psr-7", "psr-17", "psr-18" 8 | ], 9 | "authors": [ 10 | { 11 | "name": "smiley", 12 | "email": "smiley@chillerlan.net", 13 | "homepage": "https://github.com/codemasher" 14 | } 15 | ], 16 | "support": { 17 | "issues": "https://github.com/chillerlan/php-httpinterface/issues", 18 | "source": "https://github.com/chillerlan/php-httpinterface" 19 | }, 20 | "provide": { 21 | "psr/http-client-implementation": "1.0", 22 | "psr/http-factory-implementation": "1.0", 23 | "psr/http-message-implementation": "1.0" 24 | }, 25 | "minimum-stability": "stable", 26 | "prefer-stable": true, 27 | "require": { 28 | "php": "^8.1", 29 | "ext-curl": "*", 30 | "chillerlan/php-http-message-utils": "^2.2", 31 | "chillerlan/php-settings-container": "^3.1.1", 32 | "chillerlan/psr-7": "^1.0", 33 | "psr/http-client": "^1.0", 34 | "psr/http-message": "^1.1 || ^2.0", 35 | "psr/http-factory": "^1.0", 36 | "psr/log": "^1.1 || ^2.0 || ^3.0" 37 | }, 38 | "require-dev": { 39 | "chillerlan/phpunit-http": "^1.0", 40 | "phan/phan": "^5.4", 41 | "phpmd/phpmd": "^2.15", 42 | "phpunit/phpunit": "^10.5", 43 | "squizlabs/php_codesniffer": "^3.9" 44 | }, 45 | "suggest": { 46 | "chillerlan/php-oauth": "A PSR-7 OAuth client/handler that also acts as PSR-18 HTTP client" 47 | }, 48 | "autoload": { 49 | "psr-4": { 50 | "chillerlan\\HTTP\\": "src/" 51 | } 52 | }, 53 | "autoload-dev": { 54 | "psr-4": { 55 | "chillerlan\\HTTPTest\\": "tests/" 56 | } 57 | }, 58 | "scripts": { 59 | "phpunit": "@php vendor/bin/phpunit", 60 | "phan": "@php vendor/bin/phan" 61 | }, 62 | "config": { 63 | "lock": false, 64 | "sort-packages": true, 65 | "platform-check": true 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ClientException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use Exception; 16 | use Psr\Http\Client\ClientExceptionInterface; 17 | 18 | /** 19 | * 20 | */ 21 | class ClientException extends Exception implements ClientExceptionInterface{ 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/CurlClient.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use Psr\Http\Message\{RequestInterface, ResponseInterface}; 16 | use function in_array, sprintf; 17 | use const CURLE_OK; 18 | 19 | /** 20 | * A "simple" cURL http client 21 | */ 22 | class CurlClient extends HTTPClientAbstract{ 23 | 24 | /** 25 | * @inheritDoc 26 | */ 27 | public function sendRequest(RequestInterface $request):ResponseInterface{ 28 | 29 | $handle = new CurlHandle( 30 | $request, 31 | $this->responseFactory->createResponse(), 32 | $this->options, 33 | $this->streamFactory?->createStream(), 34 | ); 35 | 36 | $errno = $handle->exec(); 37 | 38 | if($errno !== CURLE_OK){ 39 | $error = $handle->getError(); 40 | 41 | $this->logger->error(sprintf('cURL error #%s: %s', $errno, $error)); 42 | 43 | if(in_array($errno, $handle::CURL_NETWORK_ERRORS, true)){ 44 | throw new NetworkException($error, $request); 45 | } 46 | 47 | throw new RequestException($error, $request); 48 | } 49 | 50 | $handle->close(); 51 | 52 | return $handle->getResponse(); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/CurlHandle.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use chillerlan\HTTP\Utils\HeaderUtil; 16 | use chillerlan\Settings\SettingsContainerInterface; 17 | use Psr\Http\Message\{RequestInterface, ResponseInterface, StreamInterface}; 18 | use CurlHandle as CH; 19 | use function count, curl_close, curl_errno, curl_error, curl_exec, curl_getinfo, curl_init, curl_setopt_array, explode, 20 | file_exists, in_array, ini_get, is_dir, is_file, is_link, readlink, realpath, sprintf, strlen, strtoupper, substr, trim; 21 | use const CURL_HTTP_VERSION_2TLS, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_RESOLVE_PROXY, 22 | CURLE_GOT_NOTHING, CURLE_OPERATION_TIMEOUTED, CURLE_SSL_CONNECT_ERROR, CURLOPT_CAINFO, CURLOPT_CAPATH, 23 | CURLOPT_CONNECTTIMEOUT, CURLOPT_CUSTOMREQUEST, CURLOPT_FOLLOWLOCATION, CURLOPT_FORBID_REUSE, CURLOPT_FRESH_CONNECT, 24 | CURLOPT_HEADER, CURLOPT_HEADERFUNCTION, CURLOPT_HTTP_VERSION, CURLOPT_HTTPHEADER, CURLOPT_INFILESIZE, CURLOPT_MAXREDIRS, 25 | CURLOPT_NOBODY, CURLOPT_POSTFIELDS, CURLOPT_PROTOCOLS, CURLOPT_READFUNCTION, CURLOPT_REDIR_PROTOCOLS, CURLOPT_RETURNTRANSFER, 26 | CURLOPT_SSL_VERIFYHOST, CURLOPT_SSL_VERIFYPEER, CURLOPT_SSL_VERIFYSTATUS, CURLOPT_TIMEOUT, CURLOPT_UPLOAD, CURLOPT_URL, 27 | CURLOPT_USERAGENT, CURLOPT_USERPWD, CURLOPT_WRITEFUNCTION, CURLPROTO_HTTP, CURLPROTO_HTTPS; 28 | use const CURLOPT_DOH_URL; 29 | 30 | /** 31 | * Implements a cURL connection object 32 | */ 33 | final class CurlHandle{ 34 | 35 | public const CURL_NETWORK_ERRORS = [ 36 | CURLE_COULDNT_RESOLVE_PROXY, 37 | CURLE_COULDNT_RESOLVE_HOST, 38 | CURLE_COULDNT_CONNECT, 39 | CURLE_OPERATION_TIMEOUTED, 40 | CURLE_SSL_CONNECT_ERROR, 41 | CURLE_GOT_NOTHING, 42 | ]; 43 | 44 | // these options shall not be overwritten 45 | private const NEVER_OVERWRITE = [ 46 | CURLOPT_CAINFO, 47 | CURLOPT_CAPATH, 48 | CURLOPT_DOH_URL, 49 | CURLOPT_CUSTOMREQUEST, 50 | CURLOPT_HTTPHEADER, 51 | CURLOPT_NOBODY, 52 | CURLOPT_FORBID_REUSE, 53 | CURLOPT_FRESH_CONNECT, 54 | ]; 55 | 56 | private CH $curl; 57 | private int $handleID; 58 | private array $curlOptions = []; 59 | private bool $initialized = false; 60 | private StreamInterface $requestBody; 61 | private StreamInterface $responseBody; 62 | 63 | /** 64 | * CurlHandle constructor. 65 | */ 66 | public function __construct( 67 | private RequestInterface $request, 68 | private ResponseInterface $response, 69 | private HTTPOptions|SettingsContainerInterface $options, 70 | StreamInterface|null $stream = null, 71 | ){ 72 | $this->curl = curl_init(); 73 | $this->handleID = (int)$this->curl; 74 | $this->requestBody = $this->request->getBody(); 75 | $this->responseBody = ($stream ?? $this->response->getBody()); 76 | } 77 | 78 | /** 79 | * close an existing cURL handle on exit 80 | */ 81 | public function __destruct(){ 82 | $this->close(); 83 | } 84 | 85 | /** 86 | * closes the handle 87 | */ 88 | public function close():self{ 89 | curl_close($this->curl); 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * returns the internal cURL resource in its current state 96 | * 97 | * @codeCoverageIgnore 98 | */ 99 | public function getCurlResource():CH{ 100 | return $this->curl; 101 | } 102 | 103 | /** 104 | * returns the handle ID (cURL resource id) 105 | * 106 | * @codeCoverageIgnore 107 | */ 108 | public function getHandleID():int{ 109 | return $this->handleID; 110 | } 111 | 112 | /** 113 | * returns the result from `curl_getinfo()` or `null` in case of an error 114 | * 115 | * @see \curl_getinfo() 116 | * 117 | * @see https://www.php.net/manual/function.curl-getinfo.php#111678 118 | * @see https://www.openssl.org/docs/manmaster/man1/verify.html#VERIFY_OPERATION 119 | * @see https://github.com/openssl/openssl/blob/91cb81d40a8102c3d8667629661be8d6937db82b/include/openssl/x509_vfy.h#L99-L189 120 | */ 121 | public function getInfo():array|null{ 122 | $info = curl_getinfo($this->curl); 123 | 124 | if($info !== false){ 125 | return $info; 126 | } 127 | 128 | return null; 129 | } 130 | 131 | /** 132 | * @codeCoverageIgnore 133 | */ 134 | public function getRequest():RequestInterface{ 135 | return $this->request; 136 | } 137 | 138 | /** 139 | * @codeCoverageIgnore 140 | */ 141 | public function getResponse():ResponseInterface{ 142 | $this->responseBody->rewind(); 143 | 144 | return $this->response->withBody($this->responseBody); 145 | } 146 | 147 | /** 148 | * @codeCoverageIgnore 149 | */ 150 | public function getCurlOptions():array{ 151 | return $this->curlOptions; 152 | } 153 | 154 | /** 155 | * Check default locations for the CA bundle 156 | * 157 | * @see https://packages.ubuntu.com/search?suite=all&searchon=names&keywords=ca-certificates 158 | * @see https://packages.debian.org/search?suite=all&searchon=names&keywords=ca-certificates 159 | * 160 | * @codeCoverageIgnore 161 | * @throws \chillerlan\HTTP\ClientException 162 | */ 163 | private function guessCA():string{ 164 | 165 | $cafiles = [ 166 | // check other php.ini settings 167 | ini_get('openssl.cafile'), 168 | // Red Hat, CentOS, Fedora (provided by the ca-certificates package) 169 | '/etc/pki/tls/certs/ca-bundle.crt', 170 | // Ubuntu, Debian (provided by the ca-certificates package) 171 | '/etc/ssl/certs/ca-certificates.crt', 172 | // FreeBSD (provided by the ca_root_nss package) 173 | '/usr/local/share/certs/ca-root-nss.crt', 174 | // SLES 12 (provided by the ca-certificates package) 175 | '/var/lib/ca-certificates/ca-bundle.pem', 176 | // OS X provided by homebrew (using the default path) 177 | '/usr/local/etc/openssl/cert.pem', 178 | // Google app engine 179 | '/etc/ca-certificates.crt', 180 | // https://www.jetbrains.com/help/idea/ssl-certificates.html 181 | '/etc/ssl/ca-bundle.pem', 182 | '/etc/pki/tls/cacert.pem', 183 | '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem', 184 | '/etc/ssl/cert.pem', 185 | // Windows? 186 | // http://php.net/manual/en/function.curl-setopt.php#110457 187 | 'C:\\Windows\\system32\\curl-ca-bundle.crt', 188 | 'C:\\Windows\\curl-ca-bundle.crt', 189 | 'C:\\Windows\\system32\\cacert.pem', 190 | 'C:\\Windows\\cacert.pem', 191 | // library path??? 192 | __DIR__.'/cacert.pem', 193 | ]; 194 | 195 | foreach($cafiles as $file){ 196 | 197 | if(file_exists($file) || (is_link($file) && file_exists(readlink($file)))){ 198 | return $file; 199 | } 200 | 201 | } 202 | 203 | // still nothing??? 204 | $msg = 'No system CA bundle could be found in any of the the common system locations. ' 205 | .'In order to verify peer certificates, you will need to supply the path on disk to a certificate ' 206 | .'bundle via HTTPOptions::$ca_info. If you do not need a specific certificate bundle, ' 207 | .'then you can download a CA bundle over here: https://curl.se/docs/caextract.html. ' 208 | .'Once you have a CA bundle available on disk, you can set the "curl.cainfo" php.ini setting to point ' 209 | .'to the path of the file, allowing you to omit the $ca_info setting. ' 210 | .'See https://curl.se/docs/sslcerts.html for more information.'; 211 | 212 | throw new ClientException($msg); 213 | } 214 | 215 | /** 216 | * @throws \chillerlan\HTTP\ClientException 217 | */ 218 | private function setCA():void{ 219 | 220 | // early exit - nothing to do 221 | if(!$this->options->ssl_verifypeer){ 222 | return; 223 | } 224 | 225 | $ca = $this->options->ca_info; 226 | 227 | if($ca === null){ 228 | 229 | // check php.ini options - PHP should find the file by itself 230 | if(file_exists(ini_get('curl.cainfo'))){ 231 | return; 232 | } 233 | 234 | // this is getting weird. as a last resort, we're going to check some default paths for a CA bundle file 235 | $ca = $this->guessCA(); 236 | } 237 | 238 | $ca = trim($ca); 239 | 240 | // if you - for whatever obscure reason - need to check Windows .lnk links, 241 | // see http://php.net/manual/en/function.is-link.php#91249 242 | if(is_link($ca)){ 243 | $ca = readlink($ca); 244 | } 245 | 246 | $ca = realpath($ca); 247 | 248 | if($ca !== false){ 249 | 250 | if(is_file($ca)){ 251 | $this->curlOptions[CURLOPT_CAINFO] = $ca; 252 | 253 | return; 254 | } 255 | 256 | if(is_dir($ca)){ 257 | $this->curlOptions[CURLOPT_CAPATH] = $ca; 258 | 259 | return; 260 | } 261 | 262 | } 263 | 264 | throw new ClientException(sprintf('invalid path to SSL CA bundle: "%s"', $this->options->ca_info)); 265 | } 266 | 267 | /** 268 | * @throws \chillerlan\HTTP\ClientException 269 | */ 270 | private function setRequestOptions():void{ 271 | $method = strtoupper($this->request->getMethod()); 272 | $userinfo = $this->request->getUri()->getUserInfo(); 273 | $bodySize = $this->requestBody->getSize(); 274 | 275 | if($method === ''){ 276 | throw new ClientException('invalid HTTP method'); 277 | } 278 | 279 | if(!empty($userinfo)){ 280 | $this->curlOptions[CURLOPT_USERPWD] = $userinfo; 281 | } 282 | 283 | /* 284 | * Some HTTP methods cannot have payload: 285 | * 286 | * - GET — cURL will automatically change the method to PUT or POST 287 | * if we set CURLOPT_UPLOAD or CURLOPT_POSTFIELDS. 288 | * - HEAD — cURL treats HEAD as a GET request with same restrictions. 289 | * - TRACE — According to RFC7231: a client MUST NOT send a message body in a TRACE request. 290 | */ 291 | if(in_array($method, ['DELETE', 'PATCH', 'POST', 'PUT'], true) && $bodySize > 0){ 292 | 293 | if(!$this->request->hasHeader('Content-Length')){ 294 | $this->request = $this->request->withHeader('Content-Length', (string)$bodySize); 295 | } 296 | 297 | if($this->requestBody->isSeekable()){ 298 | $this->requestBody->rewind(); 299 | } 300 | 301 | // Message has non-empty body. 302 | if($bodySize === null || $bodySize > (1 << 20)){ 303 | // Avoid full loading large or unknown size body into memory 304 | $this->curlOptions[CURLOPT_UPLOAD] = true; 305 | 306 | if($bodySize !== null){ 307 | $this->curlOptions[CURLOPT_INFILESIZE] = $bodySize; 308 | } 309 | 310 | $this->curlOptions[CURLOPT_READFUNCTION] = $this->readFunction(...); 311 | } 312 | // Small body can be loaded into memory 313 | else{ 314 | $this->curlOptions[CURLOPT_POSTFIELDS] = (string)$this->requestBody; 315 | } 316 | 317 | } 318 | else{ 319 | // Else if a body is not present, force "Content-length" to 0 320 | $this->request = $this->request->withHeader('Content-Length', '0'); 321 | } 322 | 323 | // This will set HTTP method to "HEAD". 324 | if($method === 'HEAD'){ 325 | $this->curlOptions[CURLOPT_NOBODY] = true; 326 | } 327 | 328 | // GET is a default method. Other methods should be specified explicitly. 329 | if($method !== 'GET'){ 330 | $this->curlOptions[CURLOPT_CUSTOMREQUEST] = $method; 331 | } 332 | 333 | } 334 | 335 | /** 336 | * @see https://php.watch/articles/php-curl-security-hardening 337 | */ 338 | public function init():CH|null{ 339 | 340 | $this->curlOptions = [ 341 | CURLOPT_HEADER => false, 342 | CURLOPT_HTTPHEADER => [], 343 | CURLOPT_RETURNTRANSFER => false, 344 | CURLOPT_FOLLOWLOCATION => false, 345 | CURLOPT_MAXREDIRS => 5, 346 | CURLOPT_URL => (string)$this->request->getUri()->withFragment(''), 347 | CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, 348 | CURLOPT_USERAGENT => $this->options->user_agent, 349 | CURLOPT_PROTOCOLS => (CURLPROTO_HTTP | CURLPROTO_HTTPS), 350 | CURLOPT_REDIR_PROTOCOLS => (CURLPROTO_HTTP | CURLPROTO_HTTPS), 351 | CURLOPT_TIMEOUT => $this->options->timeout, 352 | CURLOPT_CONNECTTIMEOUT => 30, 353 | CURLOPT_FORBID_REUSE => true, 354 | CURLOPT_FRESH_CONNECT => true, 355 | CURLOPT_HEADERFUNCTION => $this->headerFunction(...), 356 | CURLOPT_WRITEFUNCTION => $this->writeFunction(...), 357 | CURLOPT_SSL_VERIFYHOST => 2, 358 | CURLOPT_SSL_VERIFYPEER => $this->options->ssl_verifypeer, 359 | CURLOPT_SSL_VERIFYSTATUS => ($this->options->ssl_verifypeer && $this->options->curl_check_OCSP), 360 | CURLOPT_DOH_URL => $this->options->dns_over_https, 361 | 362 | // PHP 8.2+ 363 | # CURLOPT_DOH_SSL_VERIFYHOST, CURLOPT_DOH_SSL_VERIFYPEER, CURLOPT_DOH_SSL_VERIFYSTATUS, CURLOPT_CAINFO_BLOB 364 | ]; 365 | 366 | $this->setCA(); 367 | $this->setRequestOptions(); 368 | 369 | // curl-client does not support "Expect-Continue", so dropping "expect" headers 370 | $headers = HeaderUtil::normalize($this->request->withoutHeader('Expect')->getHeaders()); 371 | 372 | foreach($headers as $name => $value){ 373 | // cURL requires a special format for empty headers. 374 | // See https://github.com/guzzle/guzzle/issues/1882 for more details. 375 | $this->curlOptions[CURLOPT_HTTPHEADER][] = ($value === '') ? $name.';' : $name.': '.$value; 376 | } 377 | 378 | // If the Expect header is not present (it isn't), prevent curl from adding it 379 | $this->curlOptions[CURLOPT_HTTPHEADER][] = 'Expect:'; 380 | 381 | // cURL sometimes adds a content-type by default. Prevent this. 382 | if(!$this->request->hasHeader('Content-Type')){ 383 | $this->curlOptions[CURLOPT_HTTPHEADER][] = 'Content-Type:'; 384 | } 385 | 386 | // overwrite the default values with $curl_options 387 | foreach($this->options->curl_options as $k => $v){ 388 | // skip some options that are only set automatically or shall not be overwritten 389 | if(in_array($k, $this::NEVER_OVERWRITE, true)){ 390 | continue; 391 | } 392 | 393 | $this->curlOptions[$k] = $v; 394 | } 395 | 396 | curl_setopt_array($this->curl, $this->curlOptions); 397 | 398 | $this->initialized = true; 399 | 400 | return $this->curl; 401 | } 402 | 403 | /** 404 | * executes the current cURL instance and returns the error number 405 | * 406 | * @see \curl_exec() 407 | * @see \curl_errno() 408 | */ 409 | public function exec():int{ 410 | 411 | if(!$this->initialized){ 412 | $this->init(); 413 | } 414 | 415 | curl_exec($this->curl); 416 | 417 | return curl_errno($this->curl); 418 | } 419 | 420 | /** 421 | * returns a string containing the last error 422 | * 423 | * @see \curl_error() 424 | */ 425 | public function getError():string{ 426 | return curl_error($this->curl); 427 | } 428 | 429 | /** 430 | * A callback accepting three parameters. The first is the cURL resource, the second is a stream resource 431 | * provided to cURL through the option CURLOPT_INFILE, and the third is the maximum amount of data to be read. 432 | * The callback must return a string with a length equal or smaller than the amount of data requested, 433 | * typically by reading it from the passed stream resource. It should return an empty string to signal EOF. 434 | * 435 | * @see https://www.php.net/manual/function.curl-setopt 436 | * 437 | * @internal 438 | * @noinspection PhpUnusedParameterInspection 439 | */ 440 | public function readFunction(CH $curl, $stream, int $length):string{ 441 | return $this->requestBody->read($length); 442 | } 443 | 444 | /** 445 | * A callback accepting two parameters. The first is the cURL resource, and the second is a string 446 | * with the data to be written. The data must be saved by this callback. It must return the exact 447 | * number of bytes written or the transfer will be aborted with an error. 448 | * 449 | * @see https://www.php.net/manual/function.curl-setopt 450 | * 451 | * @internal 452 | * @noinspection PhpUnusedParameterInspection 453 | */ 454 | public function writeFunction(CH $curl, string $data):int{ 455 | return $this->responseBody->write($data); 456 | } 457 | 458 | /** 459 | * A callback accepting two parameters. The first is the cURL resource, the second is a string with the header 460 | * data to be written. The header data must be written by this callback. Return the number of bytes written. 461 | * 462 | * @see https://www.php.net/manual/function.curl-setopt 463 | * 464 | * @internal 465 | * @noinspection PhpUnusedParameterInspection 466 | */ 467 | public function headerFunction(CH $curl, string $line):int{ 468 | $header = explode(':', trim($line), 2); 469 | 470 | if(count($header) === 2){ 471 | $this->response = $this->response->withAddedHeader(trim($header[0]), trim($header[1])); 472 | } 473 | elseif(str_starts_with(strtoupper($header[0]), 'HTTP/')){ 474 | $status = explode(' ', $header[0], 3); 475 | $reason = (count($status) > 2) ? trim($status[2]) : ''; 476 | 477 | $this->response = $this->response 478 | ->withStatus((int)$status[1], $reason) 479 | ->withProtocolVersion(substr($status[0], 5)) 480 | ; 481 | } 482 | 483 | return strlen($line); 484 | } 485 | 486 | } 487 | -------------------------------------------------------------------------------- /src/CurlMultiClient.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use chillerlan\Settings\SettingsContainerInterface; 16 | use Psr\Http\Message\{RequestInterface, ResponseFactoryInterface}; 17 | use Psr\Log\{LoggerInterface, NullLogger}; 18 | use CurlMultiHandle as CMH; 19 | use function array_shift, count, curl_close, curl_multi_add_handle, curl_multi_close, curl_multi_exec, 20 | curl_multi_info_read, curl_multi_init, curl_multi_remove_handle, curl_multi_select, curl_multi_setopt, sprintf, usleep; 21 | use const CURLM_OK, CURLMOPT_MAXCONNECTS, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX; 22 | 23 | /** 24 | * Curl multi http client 25 | */ 26 | class CurlMultiClient{ 27 | 28 | /** 29 | * the cURL multi handle instance 30 | */ 31 | protected CMH $curl_multi; 32 | 33 | /** 34 | * An array of RequestInterface to run 35 | */ 36 | protected array $requests = []; 37 | 38 | /** 39 | * the stack of running handles 40 | * 41 | * cURL instance id => [counter, retries, handle] 42 | */ 43 | protected array $handles = []; 44 | 45 | /** 46 | * the request counter (request ID/order in multi response handler) 47 | */ 48 | protected int $counter = 0; 49 | 50 | /** 51 | * CurlMultiClient constructor. 52 | */ 53 | public function __construct( 54 | protected MultiResponseHandlerInterface $multiResponseHandler, 55 | protected ResponseFactoryInterface $responseFactory, 56 | protected HTTPOptions|SettingsContainerInterface $options = new HTTPOptions, 57 | protected LoggerInterface $logger = new NullLogger, 58 | ){ 59 | $this->curl_multi = curl_multi_init(); 60 | 61 | $this->initCurlMultiOptions(); 62 | } 63 | 64 | protected function initCurlMultiOptions():void{ 65 | 66 | $curl_multi_options = [ 67 | CURLMOPT_PIPELINING => CURLPIPE_MULTIPLEX, 68 | CURLMOPT_MAXCONNECTS => $this->options->window_size, 69 | ]; 70 | 71 | $curl_multi_options += $this->options->curl_multi_options; 72 | 73 | foreach($curl_multi_options as $k => $v){ 74 | curl_multi_setopt($this->curl_multi, $k, $v); 75 | } 76 | 77 | } 78 | 79 | /** 80 | * close an existing cURL multi handle on exit 81 | */ 82 | public function __destruct(){ 83 | $this->close(); 84 | } 85 | 86 | /** 87 | * @inheritDoc 88 | * @codeCoverageIgnore 89 | */ 90 | public function setLogger(LoggerInterface $logger):static{ 91 | $this->logger = $logger; 92 | 93 | return $this; 94 | } 95 | 96 | /** 97 | * closes the handle 98 | */ 99 | public function close():static{ 100 | curl_multi_close($this->curl_multi); 101 | 102 | return $this; 103 | } 104 | 105 | /** 106 | * adds a request to the stack 107 | */ 108 | public function addRequest(RequestInterface $request):static{ 109 | $this->requests[] = $request; 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * adds multiple requests to the stack 116 | * 117 | * @param \Psr\Http\Message\RequestInterface[] $stack 118 | */ 119 | public function addRequests(iterable $stack):static{ 120 | 121 | foreach($stack as $request){ 122 | 123 | if($request instanceof RequestInterface){ 124 | $this->requests[] = $request; 125 | } 126 | 127 | } 128 | 129 | return $this; 130 | } 131 | 132 | /** 133 | * processes the stack 134 | * 135 | * @throws \Psr\Http\Client\ClientExceptionInterface 136 | */ 137 | public function process():static{ 138 | 139 | if(empty($this->requests)){ 140 | throw new ClientException('request stack is empty'); 141 | } 142 | 143 | // shoot out the first batch of requests 144 | for($i = 0; $i < $this->options->window_size; $i++){ 145 | $this->createHandle(); 146 | } 147 | 148 | // ...and process the stack 149 | do{ 150 | // $still_running is not a "flag" as the documentation states, but the number of currently active handles 151 | $status = curl_multi_exec($this->curl_multi, $active_handles); 152 | 153 | if(curl_multi_select($this->curl_multi, $this->options->timeout) === -1){ 154 | usleep(100000); // sleep a bit (100ms) 155 | } 156 | 157 | // this assignment-in-condition is intentional btw 158 | while($state = curl_multi_info_read($this->curl_multi)){ 159 | $this->resolve((int)$state['handle']); 160 | 161 | curl_multi_remove_handle($this->curl_multi, $state['handle']); 162 | curl_close($state['handle']); 163 | } 164 | 165 | } 166 | while($active_handles > 0 && $status === CURLM_OK); 167 | 168 | // for some reason not all requests were processed (errors while adding to curl_multi) 169 | if(!empty($this->requests)){ 170 | $this->logger->warning(sprintf('%s request(s) in the stack could not be processed', count($this->requests))); 171 | } 172 | 173 | return $this; 174 | } 175 | 176 | /** 177 | * resolves the handle, calls the response handler callback and creates the next handle 178 | */ 179 | protected function resolve(int $handleID):void{ 180 | [$counter, $retries, $handle] = $this->handles[$handleID]; 181 | 182 | $result = $this->multiResponseHandler->handleResponse( 183 | $handle->getResponse(), 184 | $handle->getRequest(), 185 | $counter, 186 | $handle->getInfo(), 187 | ); 188 | 189 | $handle->close(); 190 | unset($this->handles[$handleID]); 191 | 192 | ($result instanceof RequestInterface && $retries < $this->options->retries) 193 | ? $this->createHandle($result, $counter, ++$retries) 194 | : $this->createHandle(); 195 | } 196 | 197 | /** 198 | * creates a new request handle 199 | */ 200 | protected function createHandle( 201 | RequestInterface|null $request = null, 202 | int|null $counter = null, 203 | int|null $retries = null, 204 | ):void{ 205 | 206 | if($request === null){ 207 | 208 | if(empty($this->requests)){ 209 | return; 210 | } 211 | 212 | $request = array_shift($this->requests); 213 | } 214 | 215 | $handle = (new CurlHandle($request, $this->responseFactory->createResponse(), $this->options)); 216 | 217 | // initialize the handle get the cURL resource and add it to the multi handle 218 | $error = curl_multi_add_handle($this->curl_multi, $handle->init()); 219 | 220 | if($error !== CURLM_OK){ 221 | $this->addRequest($request); // re-add the request 222 | 223 | $this->logger->error(sprintf('could not attach current handle to curl_multi instance. (error: %s)', $error)); 224 | 225 | return; 226 | } 227 | 228 | $this->handles[$handle->getHandleID()] = [($counter ?? ++$this->counter) , ($retries ?? 0), $handle]; 229 | 230 | if($this->options->sleep > 0){ 231 | usleep($this->options->sleep); 232 | } 233 | 234 | } 235 | 236 | } 237 | -------------------------------------------------------------------------------- /src/HTTPClientAbstract.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use chillerlan\Settings\SettingsContainerInterface; 16 | use Psr\Http\Message\ResponseFactoryInterface; 17 | use Psr\Http\Message\StreamFactoryInterface; 18 | use Psr\Log\{LoggerInterface, NullLogger}; 19 | 20 | /** 21 | * 22 | */ 23 | abstract class HTTPClientAbstract implements HTTPClientInterface{ 24 | 25 | protected StreamFactoryInterface|null $streamFactory = null; 26 | 27 | /** 28 | * HTTPClientAbstract constructor. 29 | */ 30 | public function __construct( 31 | protected ResponseFactoryInterface $responseFactory, 32 | protected HTTPOptions|SettingsContainerInterface $options = new HTTPOptions, 33 | protected LoggerInterface $logger = new NullLogger, 34 | ){ 35 | 36 | } 37 | 38 | /** 39 | * @inheritDoc 40 | * @codeCoverageIgnore 41 | */ 42 | public function setLogger(LoggerInterface $logger):static{ 43 | $this->logger = $logger; 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * @inheritDoc 50 | * @codeCoverageIgnore 51 | */ 52 | public function setResponseFactory(ResponseFactoryInterface $responseFactory):static{ 53 | $this->responseFactory = $responseFactory; 54 | 55 | return $this; 56 | } 57 | 58 | /** 59 | * @inheritDoc 60 | * @codeCoverageIgnore 61 | */ 62 | public function setStreamFactory(StreamFactoryInterface $streamFactory):static{ 63 | $this->streamFactory = $streamFactory; 64 | 65 | return $this; 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/HTTPClientInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2023 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use Psr\Http\Client\ClientInterface; 16 | use Psr\Http\Message\{ResponseFactoryInterface, StreamFactoryInterface}; 17 | use Psr\Log\LoggerInterface; 18 | 19 | /** 20 | * 21 | */ 22 | interface HTTPClientInterface extends ClientInterface{ 23 | 24 | /** 25 | * Sets a PSR-3 Logger 26 | */ 27 | public function setLogger(LoggerInterface $logger):static; 28 | 29 | /** 30 | * Sets a PSR-17 response factory 31 | */ 32 | public function setResponseFactory(ResponseFactoryInterface $responseFactory):static; 33 | 34 | /** 35 | * Sets a PSR-17 stream factory 36 | */ 37 | public function setStreamFactory(StreamFactoryInterface $streamFactory):static; 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/HTTPOptions.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use chillerlan\Settings\SettingsContainerAbstract; 16 | 17 | /** 18 | * 19 | */ 20 | class HTTPOptions extends SettingsContainerAbstract{ 21 | use HTTPOptionsTrait; 22 | } 23 | -------------------------------------------------------------------------------- /src/HTTPOptionsTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | * 10 | * @phan-file-suppress PhanTypeInvalidThrowsIsInterface 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace chillerlan\HTTP; 16 | 17 | use function parse_url, sprintf, strtolower, trim; 18 | use const CURLOPT_CAINFO, CURLOPT_CAPATH; 19 | 20 | /** 21 | * 22 | */ 23 | trait HTTPOptionsTrait{ 24 | 25 | /** 26 | * A custom user agent string 27 | */ 28 | protected string $user_agent = 'chillerlanHttpInterface/6.0 +https://github.com/chillerlan/php-httpinterface'; 29 | 30 | /** 31 | * options for each curl instance 32 | * 33 | * this array is being merged into the default options as the last thing before curl_exec(). 34 | * none of the values (except existence of the CA file) will be checked - that's up to the implementation. 35 | */ 36 | protected array $curl_options = []; 37 | 38 | /** 39 | * CA Root Certificates for use with CURL/SSL 40 | * 41 | * (if not configured in php.ini or available in a default path via the `ca-certificates` package) 42 | * 43 | * @see https://curl.se/docs/caextract.html 44 | * @see https://curl.se/ca/cacert.pem 45 | * @see https://raw.githubusercontent.com/bagder/ca-bundle/master/ca-bundle.crt 46 | */ 47 | protected string|null $ca_info = null; 48 | 49 | /** 50 | * see CURLOPT_SSL_VERIFYPEER 51 | * requires either HTTPOptions::$ca_info or a properly working system CA file 52 | * 53 | * @see https://php.net/manual/function.curl-setopt.php 54 | */ 55 | protected bool $ssl_verifypeer = true; 56 | 57 | /** 58 | * options for the curl multi instance 59 | * 60 | * @see https://www.php.net/manual/function.curl-multi-setopt.php 61 | */ 62 | protected array $curl_multi_options = []; 63 | 64 | /** 65 | * cURL extra hardening 66 | * 67 | * When set to true, cURL validates that the server staples an OCSP response during the TLS handshake. 68 | * 69 | * Use with caution as cURL will refuse a connection if it doesn't receive a valid OCSP response - 70 | * this does not necessarily mean that the TLS connection is insecure. 71 | * 72 | * @see \CURLOPT_SSL_VERIFYSTATUS 73 | */ 74 | protected bool $curl_check_OCSP = false; 75 | 76 | /** 77 | * maximum of concurrent requests for curl_multi 78 | */ 79 | protected int $window_size = 5; 80 | 81 | /** 82 | * sleep timer (microseconds) between each fired multi request on startup 83 | */ 84 | protected int $sleep = 0; 85 | 86 | /** 87 | * Timeout value 88 | * 89 | * @see \CURLOPT_TIMEOUT 90 | */ 91 | protected int $timeout = 10; 92 | 93 | /** 94 | * Number of retries (multi fetch) 95 | */ 96 | protected int $retries = 3; 97 | 98 | /** 99 | * Sets a DNS-over-HTTPS provider URL 100 | * 101 | * e.g. 102 | * 103 | * - https://cloudflare-dns.com/dns-query 104 | * - https://dns.google/dns-query 105 | * - https://dns.nextdns.io 106 | * 107 | * @see https://en.wikipedia.org/wiki/DNS_over_HTTPS 108 | * @see https://github.com/curl/curl/wiki/DNS-over-HTTPS 109 | */ 110 | protected string|null $dns_over_https = null; 111 | 112 | /** 113 | * @throws \Psr\Http\Client\ClientExceptionInterface 114 | */ 115 | protected function set_user_agent(string $user_agent):void{ 116 | $user_agent = trim($user_agent); 117 | 118 | if(empty($user_agent)){ 119 | throw new ClientException('invalid user agent'); 120 | } 121 | 122 | $this->user_agent = $user_agent; 123 | } 124 | 125 | /** 126 | * 127 | */ 128 | protected function set_curl_options(array $curl_options):void{ 129 | 130 | // let's check if there's a CA bundle given via the cURL options and move it to the ca_info option instead 131 | foreach([CURLOPT_CAINFO, CURLOPT_CAPATH] as $opt){ 132 | 133 | if(!empty($curl_options[$opt])){ 134 | 135 | if($this->ca_info === null){ 136 | $this->ca_info = $curl_options[$opt]; 137 | } 138 | 139 | unset($curl_options[$opt]); 140 | } 141 | } 142 | 143 | $this->curl_options = $curl_options; 144 | } 145 | 146 | /** 147 | * @throws \chillerlan\HTTP\ClientException 148 | */ 149 | protected function set_dns_over_https(string|null $dns_over_https):void{ 150 | 151 | if($dns_over_https === null){ 152 | $this->dns_over_https = null; 153 | 154 | return; 155 | } 156 | 157 | $dns_over_https = trim($dns_over_https); 158 | $parsed = parse_url($dns_over_https); 159 | 160 | if(empty($dns_over_https) || !isset($parsed['scheme'], $parsed['host']) || strtolower($parsed['scheme']) !== 'https'){ 161 | throw new ClientException(sprintf('invalid DNS-over-HTTPS URL: "%s"', $dns_over_https)); 162 | } 163 | 164 | $this->dns_over_https = $dns_over_https; 165 | } 166 | 167 | } 168 | -------------------------------------------------------------------------------- /src/MultiResponseHandlerInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use Psr\Http\Message\{RequestInterface, ResponseInterface}; 16 | 17 | /** 18 | * The multi response handler. (Schrödinger's cat state handler) 19 | */ 20 | interface MultiResponseHandlerInterface{ 21 | 22 | /** 23 | * This method will be called within a loop in MultiRequest::processStack(). 24 | * It may return a RequestInterface object (e.g. as a replacement for a failed request), 25 | * which then will be re-added to the running queue, otherwise NULL. 26 | * 27 | * @param \Psr\Http\Message\ResponseInterface $response the response 28 | * @param \Psr\Http\Message\RequestInterface $request the original request 29 | * @param int $id the request ID (order of outgoing requests) 30 | * @param array|null $curl_info curl_info() result for the current request, 31 | * empty array on curl_info() failure 32 | * 33 | * @return \Psr\Http\Message\RequestInterface|null an optional replacement request if the previous request failed 34 | */ 35 | public function handleResponse( 36 | ResponseInterface $response, 37 | RequestInterface $request, 38 | int $id, 39 | array|null $curl_info, 40 | ):RequestInterface|null; 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/NetworkException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use Psr\Http\Client\NetworkExceptionInterface; 16 | use Psr\Http\Message\RequestInterface; 17 | use Throwable; 18 | 19 | /** 20 | * @codeCoverageIgnore 21 | */ 22 | class NetworkException extends ClientException implements NetworkExceptionInterface{ 23 | 24 | /** 25 | * 26 | */ 27 | public function __construct( 28 | string $message, 29 | protected RequestInterface $request, 30 | Throwable|null $previous = null 31 | ){ 32 | parent::__construct($message, 0, $previous); 33 | } 34 | 35 | /** 36 | * @inheritDoc 37 | */ 38 | public function getRequest():RequestInterface{ 39 | return $this->request; 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/RequestException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use Psr\Http\Client\RequestExceptionInterface; 16 | use Psr\Http\Message\RequestInterface; 17 | use Throwable; 18 | 19 | /** 20 | * @codeCoverageIgnore 21 | */ 22 | class RequestException extends ClientException implements RequestExceptionInterface{ 23 | 24 | /** 25 | * 26 | */ 27 | public function __construct( 28 | string $message, 29 | protected RequestInterface $request, 30 | Throwable|null $previous = null 31 | ){ 32 | parent::__construct($message, 0, $previous); 33 | } 34 | 35 | /** 36 | * @inheritDoc 37 | */ 38 | public function getRequest():RequestInterface{ 39 | return $this->request; 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/StreamClient.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace chillerlan\HTTP; 14 | 15 | use chillerlan\HTTP\Utils\HeaderUtil; 16 | use Psr\Http\Message\{RequestInterface, ResponseInterface}; 17 | use Exception, Throwable; 18 | use function explode, file_get_contents, get_headers, in_array, intval, is_file, restore_error_handler, 19 | set_error_handler, sprintf, stream_context_create, strtolower, str_starts_with, trim; 20 | 21 | /** 22 | * A http client via PHP streams 23 | * 24 | * (I'm not exactly sure why I'm keeping this - use CurlClient in production) 25 | * 26 | * @see \file_get_contents() 27 | * @see \stream_context_create() 28 | */ 29 | class StreamClient extends HTTPClientAbstract{ 30 | 31 | /** 32 | * @inheritDoc 33 | * @throws \Exception|\chillerlan\HTTP\ClientException 34 | */ 35 | public function sendRequest(RequestInterface $request):ResponseInterface{ 36 | 37 | $errorHandler = function(int $errno, string $errstr):bool{ 38 | $this->logger->error(sprintf('StreamClient error #%s: %s', $errno, $errstr)); 39 | 40 | throw new Exception($errstr, $errno); 41 | }; 42 | 43 | set_error_handler($errorHandler); 44 | 45 | $exception = null; 46 | 47 | try{ 48 | $context = stream_context_create($this->getContextOptions($request)); 49 | $requestUri = (string)$request->getUri()->withFragment(''); 50 | $responseBody = file_get_contents($requestUri, false, $context); 51 | $response = $this->createResponse(get_headers($requestUri, true, $context)); 52 | } 53 | catch(Throwable $e){ 54 | $exception = $e; 55 | } 56 | 57 | restore_error_handler(); 58 | 59 | if($exception !== null){ 60 | throw new ClientException($exception->getMessage()); 61 | } 62 | 63 | $body = $this->streamFactory !== null 64 | ? $this->streamFactory->createStream() 65 | : $response->getBody() 66 | ; 67 | 68 | $body->write($responseBody); 69 | $body->rewind(); 70 | 71 | return $response->withBody($body); 72 | } 73 | 74 | /** 75 | * 76 | */ 77 | protected function getContextOptions(RequestInterface $request):array{ 78 | $method = $request->getMethod(); 79 | $body = null; 80 | 81 | if(in_array($method, ['DELETE', 'PATCH', 'POST', 'PUT'], true)){ 82 | $body = $request->getBody()->getContents(); 83 | } 84 | 85 | $options = [ 86 | 'http' => [ 87 | 'method' => $method, 88 | 'header' => $this->getRequestHeaders($request), 89 | 'content' => $body, 90 | 'protocol_version' => $request->getProtocolVersion(), 91 | 'user_agent' => $this->options->user_agent, 92 | 'max_redirects' => 0, 93 | 'timeout' => 5, 94 | ], 95 | 'ssl' => [ 96 | 'verify_peer' => $this->options->ssl_verifypeer, 97 | 'verify_depth' => 3, 98 | 'peer_name' => $request->getUri()->getHost(), 99 | 'ciphers' => 'HIGH:!SSLv2:!SSLv3', 100 | 'disable_compression' => true, 101 | ], 102 | ]; 103 | 104 | if($this->options->ca_info){ 105 | $ca = (is_file($this->options->ca_info)) ? 'capath' : 'cafile'; 106 | $options['ssl'][$ca] = $this->options->ca_info; 107 | } 108 | 109 | return $options; 110 | } 111 | 112 | /** 113 | * 114 | */ 115 | protected function getRequestHeaders(RequestInterface $request):array{ 116 | $headers = []; 117 | 118 | foreach($request->getHeaders() as $name => $values){ 119 | $name = strtolower($name); 120 | 121 | foreach($values as $value){ 122 | // cURL requires a special format for empty headers. 123 | // See https://github.com/guzzle/guzzle/issues/1882 for more details. 124 | $headers[] = ($value === '') ? $name.';' : $name.': '.$value; 125 | } 126 | } 127 | 128 | return $headers; 129 | } 130 | 131 | /** 132 | * @param string[] $headers 133 | */ 134 | protected function createResponse(array $headers):ResponseInterface{ 135 | $h = []; 136 | 137 | $httpversion = ''; 138 | $statuscode = 0; 139 | $statustext = ''; 140 | 141 | foreach($headers as $k => $v){ 142 | 143 | if($k === 0 && str_starts_with($v, 'HTTP')){ 144 | $status = explode(' ', $v, 3); 145 | 146 | $httpversion = explode('/', $status[0], 2)[1]; 147 | $statuscode = intval($status[1]); 148 | $statustext = trim($status[2]); 149 | 150 | continue; 151 | } 152 | 153 | $h[$k] = $v; 154 | } 155 | 156 | $response = $this->responseFactory 157 | ->createResponse($statuscode, $statustext) 158 | ->withProtocolVersion($httpversion) 159 | ; 160 | 161 | foreach(HeaderUtil::normalize($h) as $k => $v){ 162 | $response = $response->withAddedHeader($k, $v); 163 | } 164 | 165 | return $response; 166 | } 167 | 168 | } 169 | --------------------------------------------------------------------------------