├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── ClientFactory.php ├── Exception │ ├── NetworkException.php │ └── RequestException.php ├── FallbackMiddleware.php ├── Middleware │ ├── Cookie │ │ ├── CookieRequest.php │ │ ├── CookieResponse.php │ │ ├── FileStorage.php │ │ └── Storage.php │ ├── Deflate.php │ └── UserAgent.php ├── MiddlewareClient.php ├── MiddlewareInterface.php ├── RequestConfig.php ├── RequestHandler.php ├── RequestHandlerInterface.php ├── Transport │ ├── CurlTransport.php │ ├── StreamTransport.php │ └── Transport.php └── functions.php └── tests ├── ClientFactoryTest.php ├── Middleware ├── DeflateTest.php └── UserAgentTest.php ├── MiddlewareClientTest.php ├── RequestConfigTest.php ├── Resource └── ClosureWrapMiddleware.php └── Transport ├── CurlTransportTest.php └── StreamTransportTest.php /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - "7.1" 4 | - "7.2" 5 | notifications: 6 | email: 7 | on_success: "always" 8 | on_failure: "always" 9 | install: 10 | - composer install --no-interaction 11 | script: 12 | - mkdir -p build/logs 13 | - php ./vendor/bin/phpunit -c ./phpunit.xml.dist --coverage-clover ./build/logs/clover.xml 14 | after_success: 15 | - travis_retry php vendor/bin/coveralls -v 16 | 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Aleksandr Zelenin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTP client [![Build Status](https://travis-ci.org/zelenin/http-client.svg?branch=master)](https://travis-ci.org/zelenin/http-client) [![Coverage Status](https://coveralls.io/repos/github/zelenin/http-client/badge.svg?branch=master)](https://coveralls.io/github/zelenin/http-client?branch=master) 2 | 3 | [PSR-18](http://www.php-fig.org/psr/psr-18/) compatible low-level HTTP client with middleware support. 4 | 5 | ## Installation 6 | 7 | ### Composer 8 | 9 | The preferred way to install this extension is through [Composer](http://getcomposer.org/). 10 | 11 | Either run 12 | 13 | ``` 14 | php composer.phar require zelenin/http-client "~4.0.0" 15 | ``` 16 | 17 | or add 18 | 19 | ``` 20 | "zelenin/http-client": "~4.0.0" 21 | ``` 22 | 23 | to the ```require``` section of your ```composer.json``` 24 | 25 | ## Usage 26 | 27 | ```php 28 | use Zelenin\HttpClient\ClientFactory; 29 | use Zend\Diactoros\Request; 30 | use Zend\Diactoros\Uri; 31 | 32 | $client = (new ClientFactory())->create(); 33 | 34 | $request = new Request(new Uri('https://example.com/'), 'GET'); 35 | $response = $client->sendRequest($request); 36 | ``` 37 | 38 | ### Full example with middleware stack 39 | 40 | ```php 41 | use Zelenin\HttpClient\Middleware\Cookie\CookieRequest; 42 | use Zelenin\HttpClient\Middleware\Cookie\CookieResponse; 43 | use Zelenin\HttpClient\Middleware\Cookie\FileStorage; 44 | use Zelenin\HttpClient\Middleware\Deflate; 45 | use Zelenin\HttpClient\Middleware\UserAgent; 46 | use Zelenin\HttpClient\MiddlewareClient; 47 | use Zelenin\HttpClient\RequestConfig; 48 | use Zelenin\HttpClient\Transport\CurlTransport; 49 | use Zend\Diactoros\Request; 50 | use Zend\Diactoros\ResponseFactory; 51 | use Zend\Diactoros\StreamFactory; 52 | use Zend\Diactoros\Uri; 53 | 54 | $streamFactory = new StreamFactory(); 55 | $responseFactory = new ResponseFactory(); 56 | 57 | $cookieStorage = new FileStorage('/tmp/http-client/cookies.storage'); 58 | 59 | $client = new MiddlewareClient([ 60 | new CookieRequest($cookieStorage), 61 | new UserAgent(sprintf('HttpClient PHP/%s', PHP_VERSION)), 62 | new Deflate($streamFactory), 63 | new CookieResponse($cookieStorage), 64 | new CurlTransport($streamFactory, $responseFactory, new RequestConfig()), 65 | ], $responseFactory); 66 | 67 | $request = new Request(new Uri('https://example.com/'), 'GET'); 68 | $response = $client->sendRequest($request); 69 | ``` 70 | 71 | ## Author 72 | 73 | [Aleksandr Zelenin](https://github.com/zelenin/), e-mail: [aleksandr@zelenin.me](mailto:aleksandr@zelenin.me) 74 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zelenin/http-client", 3 | "description": "PSR-18 compatible HTTP client with middleware support", 4 | "keywords": [ 5 | "psr-7", 6 | "psr-18", 7 | "http", 8 | "client", 9 | "middleware" 10 | ], 11 | "homepage": "https://github.com/zelenin/http-client", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Aleksandr Zelenin", 16 | "email": "aleksandr@zelenin.me", 17 | "role": "Developer" 18 | } 19 | ], 20 | "support": { 21 | "issues": "https://github.com/zelenin/http-client/issues", 22 | "source": "https://github.com/zelenin/http-client" 23 | }, 24 | "require": { 25 | "php": ">=7.1", 26 | "psr/http-client": "~1.0", 27 | "psr/http-factory": "~1.0", 28 | "dflydev/fig-cookies": "~1.0.2" 29 | }, 30 | "require-dev": { 31 | "phpunit/phpunit": "~6.0", 32 | "satooshi/php-coveralls": "~1.0.0", 33 | "zendframework/zend-diactoros":"~2.0" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Zelenin\\HttpClient\\": "src" 38 | }, 39 | "files": [ 40 | "src/functions.php" 41 | ] 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Zelenin\\HttpClient\\Test\\": "tests" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | ./src 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ClientFactory.php: -------------------------------------------------------------------------------- 1 | request = $request; 27 | parent::__construct($message, 0, $previous); 28 | } 29 | 30 | /** 31 | * @inheritdoc 32 | */ 33 | public function getRequest(): RequestInterface 34 | { 35 | return $this->request; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/FallbackMiddleware.php: -------------------------------------------------------------------------------- 1 | responseFactory = $responseFactory; 24 | } 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 30 | { 31 | return $this->responseFactory->createResponse(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Middleware/Cookie/CookieRequest.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 32 | { 33 | if (!$request->hasHeader('Cookie')) { 34 | $cookies = []; 35 | 36 | $setCookies = $this->storage->getAll(); 37 | 38 | foreach ($setCookies as $setCookie) { 39 | if ($this->validForUri($setCookie, $request->getUri())) { 40 | $cookies[] = sprintf('%s=%s', $setCookie->getName(), $setCookie->getValue()); 41 | } 42 | } 43 | 44 | if ($cookies) { 45 | $request = $request->withHeader('Cookie', implode('; ', $cookies)); 46 | } 47 | } 48 | 49 | return $handler->handle($request); 50 | } 51 | 52 | /** 53 | * @param string $cookieDomain 54 | * @param string $requestDomain 55 | * 56 | * @return bool 57 | */ 58 | public function matchesDomain(string $cookieDomain, string $requestDomain): bool 59 | { 60 | $cookieDomain = ltrim($cookieDomain, '.'); 61 | 62 | if (!$cookieDomain || !strcasecmp($requestDomain, $cookieDomain)) { 63 | return true; 64 | } 65 | 66 | if (filter_var($requestDomain, FILTER_VALIDATE_IP)) { 67 | return false; 68 | } 69 | 70 | return (bool)preg_match('/\.' . preg_quote($cookieDomain, '/') . '$/', $requestDomain); 71 | } 72 | 73 | /** 74 | * @param SetCookie $setCookie 75 | * @param UriInterface $uri 76 | * 77 | * @return bool 78 | */ 79 | private function validForUri(SetCookie $setCookie, UriInterface $uri): bool 80 | { 81 | $requestPath = $uri->getPath() ?: '/'; 82 | 83 | if ($setCookie->getSecure() && $uri->getScheme() !== 'https') { 84 | return false; 85 | } 86 | 87 | if ($setCookie->getExpires() && time() > $setCookie->getExpires()) { 88 | return false; 89 | } 90 | 91 | if (!$this->matchesPath($setCookie->getPath(), $requestPath)) { 92 | return false; 93 | } 94 | 95 | if (!$this->matchesDomain($setCookie->getDomain(), $uri->getHost())) { 96 | return false; 97 | } 98 | 99 | return true; 100 | } 101 | 102 | /** 103 | * @param string $cookiePath 104 | * @param string $requestPath 105 | * 106 | * @return bool 107 | */ 108 | private function matchesPath(string $cookiePath, string $requestPath): bool 109 | { 110 | if ($cookiePath === '/' || $cookiePath == $requestPath) { 111 | return true; 112 | } 113 | 114 | if (0 !== strpos($requestPath, $cookiePath)) { 115 | return false; 116 | } 117 | 118 | if (substr($cookiePath, -1, 1) === '/') { 119 | return true; 120 | } 121 | 122 | return substr($requestPath, strlen($cookiePath), 1) === '/'; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Middleware/Cookie/CookieResponse.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 25 | } 26 | 27 | /** 28 | * @inheritdoc 29 | */ 30 | public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 31 | { 32 | $response = $handler->handle($request); 33 | 34 | foreach (SetCookies::fromResponse($response)->getAll() as $setCookie) { 35 | $this->storage->add($setCookie); 36 | } 37 | 38 | return $response; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Middleware/Cookie/FileStorage.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 27 | $this->storage = []; 28 | 29 | if (file_exists($this->filePath)) { 30 | $this->load(); 31 | } 32 | } 33 | 34 | public function __destruct() 35 | { 36 | $this->save(); 37 | } 38 | 39 | /** 40 | * @inheritdoc 41 | */ 42 | public function add(SetCookie $setCookie) 43 | { 44 | $key = sprintf('%s__%s__%s', $setCookie->getDomain(), $setCookie->getPath(), $setCookie->getName()); 45 | $this->storage[$key] = $setCookie; 46 | } 47 | 48 | /** 49 | * @inheritdoc 50 | */ 51 | public function getAll() 52 | { 53 | return array_values($this->storage); 54 | } 55 | 56 | private function save() 57 | { 58 | $data = []; 59 | foreach ($this->storage as $setCookie) { 60 | $data[] = $setCookie->__toString(); 61 | } 62 | 63 | $json = json_encode($data); 64 | if (false === file_put_contents($this->filePath, $json)) { 65 | throw new RuntimeException(sprintf('Unable to save file %s.', $this->filePath)); 66 | } 67 | } 68 | 69 | private function load() 70 | { 71 | $json = file_get_contents($this->filePath); 72 | if (false === $json) { 73 | throw new RuntimeException(sprintf('Unable to load file %s.', $this->filePath)); 74 | } else if ($json === '') { 75 | return; 76 | } 77 | 78 | $data = json_decode($json); 79 | foreach ($data as $setCookieString) { 80 | $this->add(SetCookie::fromSetCookieString($setCookieString)); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Middleware/Cookie/Storage.php: -------------------------------------------------------------------------------- 1 | streamFactory = $streamFactory; 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 32 | { 33 | $response = $handler->handle($request); 34 | 35 | if ($response->hasHeader('Content-Encoding')) { 36 | $encoding = $response->getHeader('Content-Encoding'); 37 | if ($encoding[0] === 'gzip' || $encoding[0] === 'deflate') { 38 | $stream = inflateStream($response->getBody(), $this->streamFactory); 39 | 40 | $response = $response 41 | ->withBody($stream) 42 | ->withHeader('Content-Encoding-Original', $encoding) 43 | ->withoutHeader('Content-Encoding'); 44 | 45 | if ($response->hasHeader('Content-Length')) { 46 | $response = $response 47 | ->withHeader('Content-Length-Original', $response->getHeader('Content-Length')[0]) 48 | ->withHeader('Content-Length', (string)$response->getBody()->getSize()); 49 | } 50 | } 51 | } 52 | 53 | return $response; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Middleware/UserAgent.php: -------------------------------------------------------------------------------- 1 | userAgent = $userAgent ?: sprintf('HttpClient PHP/%s', PHP_VERSION); 24 | } 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 30 | { 31 | if (!$request->hasHeader('User-Agent')) { 32 | $request = $request->withHeader('User-Agent', $this->userAgent); 33 | } 34 | 35 | return $handler->handle($request); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/MiddlewareClient.php: -------------------------------------------------------------------------------- 1 | middlewares = array_map(function (MiddlewareInterface $middleware) { 30 | return $middleware; 31 | }, $middlewares); 32 | $this->responseFactory = $responseFactory; 33 | } 34 | 35 | /** 36 | * @inheritdoc 37 | */ 38 | public function sendRequest(RequestInterface $request): ResponseInterface 39 | { 40 | $requestHandler = new RequestHandler($this->middlewares, new FallbackMiddleware($this->responseFactory)); 41 | 42 | return $requestHandler->handle($request); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/MiddlewareInterface.php: -------------------------------------------------------------------------------- 1 | followLocation = true; 21 | $this->timeout = 10.0; 22 | } 23 | 24 | /** 25 | * @return bool 26 | */ 27 | public function followLocation(): bool 28 | { 29 | return $this->followLocation; 30 | } 31 | 32 | /** 33 | * @param bool $followLocation 34 | * 35 | * @return RequestConfig 36 | */ 37 | public function setFollowLocation(bool $followLocation): self 38 | { 39 | $this->followLocation = $followLocation; 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * @return float 46 | */ 47 | public function timeout(): float 48 | { 49 | return $this->timeout; 50 | } 51 | 52 | /** 53 | * @param float $timeout 54 | * 55 | * @return RequestConfig 56 | */ 57 | public function setTimeout(float $timeout): self 58 | { 59 | $this->timeout = $timeout; 60 | 61 | return $this; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/RequestHandler.php: -------------------------------------------------------------------------------- 1 | middlewares = array_map(function (MiddlewareInterface $middleware) { 28 | return $middleware; 29 | }, $middlewares); 30 | $this->fallbackMiddleware = $fallbackMiddleware; 31 | } 32 | 33 | /** 34 | * @inheritdoc 35 | */ 36 | public function handle(RequestInterface $request): ResponseInterface 37 | { 38 | if (0 === count($this->middlewares)) { 39 | return $this->fallbackMiddleware->process($request, $this); 40 | } 41 | 42 | $middleware = array_shift($this->middlewares); 43 | 44 | return $middleware->process($request, $this); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/RequestHandlerInterface.php: -------------------------------------------------------------------------------- 1 | streamFactory = $streamFactory; 44 | $this->responseFactory = $responseFactory; 45 | $this->requestConfig = $requestConfig; 46 | } 47 | 48 | /** 49 | * @inheritdoc 50 | */ 51 | public function sendRequest(RequestInterface $request): ResponseInterface 52 | { 53 | $resource = fopen('php://temp', 'wb'); 54 | 55 | $curlOptions = [ 56 | CURLOPT_CUSTOMREQUEST => $request->getMethod(), 57 | CURLOPT_RETURNTRANSFER => false, 58 | CURLOPT_FOLLOWLOCATION => $this->requestConfig->followLocation(), 59 | CURLOPT_HEADER => false, 60 | CURLOPT_CONNECTTIMEOUT => $this->requestConfig->timeout(), 61 | CURLOPT_FILE => $resource, 62 | ]; 63 | 64 | $version = $request->getProtocolVersion(); 65 | switch ($version) { 66 | case "1.0": 67 | $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0; 68 | break; 69 | 70 | case "1.1": 71 | $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1; 72 | break; 73 | 74 | case "2.0": 75 | $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0; 76 | break; 77 | 78 | default: 79 | $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1; 80 | break; 81 | } 82 | 83 | $curlOptions[CURLOPT_HTTPHEADER] = explode("\r\n", serializeHeadersFromPsr7Format($request->getHeaders())); 84 | 85 | if ($request->getBody()->getSize()) { 86 | $curlOptions[CURLOPT_POSTFIELDS] = $request->getBody()->__toString(); 87 | } 88 | 89 | $headers = []; 90 | $curlOptions[CURLOPT_HEADERFUNCTION] = function ($resource, $headerString) use (&$headers) { 91 | $header = trim($headerString); 92 | if (strlen($header) > 0) { 93 | $headers[] = $header; 94 | } 95 | 96 | return mb_strlen($headerString, '8bit'); 97 | }; 98 | 99 | $curlResource = curl_init($request->getUri()->__toString()); 100 | curl_setopt_array($curlResource, $curlOptions); 101 | 102 | curl_exec($curlResource); 103 | 104 | $stream = copyResourceToStream($resource, $this->streamFactory); 105 | 106 | if ($this->requestConfig->followLocation()) { 107 | $headers = filterLastResponseHeaders($headers); 108 | } 109 | 110 | fclose($resource); 111 | 112 | $errorNumber = curl_errno($curlResource); 113 | $errorMessage = curl_error($curlResource); 114 | 115 | if ($errorNumber) { 116 | throw new NetworkException($errorMessage, $request); 117 | } 118 | 119 | $parts = explode(' ', array_shift($headers), 3); 120 | $version = explode('/', $parts[0])[1]; 121 | $status = (int)$parts[1]; 122 | 123 | curl_close($curlResource); 124 | 125 | $response = $this->responseFactory->createResponse($status) 126 | ->withProtocolVersion($version) 127 | ->withBody($stream); 128 | 129 | foreach (deserializeHeadersToPsr7Format($headers) as $key => $value) { 130 | $response = $response->withHeader($key, $value); 131 | } 132 | 133 | return $response; 134 | } 135 | 136 | /** 137 | * @inheritdoc 138 | */ 139 | public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 140 | { 141 | return $this->sendRequest($request); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Transport/StreamTransport.php: -------------------------------------------------------------------------------- 1 | streamFactory = $streamFactory; 45 | $this->responseFactory = $responseFactory; 46 | $this->requestConfig = $requestConfig; 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public function sendRequest(RequestInterface $request): ResponseInterface 53 | { 54 | $context = [ 55 | 'http' => [ 56 | 'method' => $request->getMethod(), 57 | 'header' => serializeHeadersFromPsr7Format($request->getHeaders()), 58 | 'protocol_version' => $request->getProtocolVersion(), 59 | 'ignore_errors' => true, 60 | 'timeout' => $this->requestConfig->timeout(), 61 | 'follow_location' => $this->requestConfig->followLocation(), 62 | ], 63 | ]; 64 | 65 | if ($request->getBody()->getSize()) { 66 | $context['http']['content'] = $request->getBody()->__toString(); 67 | } 68 | 69 | $resource = fopen($request->getUri()->__toString(), 'rb', false, stream_context_create($context)); 70 | 71 | if (!is_resource($resource)) { 72 | $error = error_get_last()['message']; 73 | if (strpos($error, 'getaddrinfo') || strpos($error, 'Connection refused')) { 74 | $e = new NetworkException($error, $request); 75 | } else { 76 | $e = new RequestException($error, $request); 77 | } 78 | throw $e; 79 | } 80 | 81 | $stream = copyResourceToStream($resource, $this->streamFactory); 82 | 83 | $headers = stream_get_meta_data($resource)['wrapper_data'] ?? []; 84 | 85 | if ($this->requestConfig->followLocation()) { 86 | $headers = filterLastResponseHeaders($headers); 87 | } 88 | 89 | fclose($resource); 90 | 91 | $parts = explode(' ', array_shift($headers), 3); 92 | $version = explode('/', $parts[0])[1]; 93 | $status = (int)$parts[1]; 94 | 95 | $response = $this->responseFactory->createResponse($status) 96 | ->withProtocolVersion($version) 97 | ->withBody($stream); 98 | 99 | foreach (deserializeHeadersToPsr7Format($headers) as $key => $value) { 100 | $response = $response->withHeader($key, $value); 101 | } 102 | 103 | return $response; 104 | } 105 | 106 | /** 107 | * @inheritdoc 108 | */ 109 | public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 110 | { 111 | return $this->sendRequest($request); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Transport/Transport.php: -------------------------------------------------------------------------------- 1 | $values) { 20 | $normalized = normalizeHeader($header); 21 | foreach ($values as $value) { 22 | $lines[] = sprintf('%s: %s', $normalized, $value); 23 | } 24 | } 25 | 26 | return implode("\r\n", $lines); 27 | } 28 | 29 | /** 30 | * @param array $lines 31 | * 32 | * @return array 33 | */ 34 | function deserializeHeadersToPsr7Format(array $lines): array 35 | { 36 | $headers = []; 37 | 38 | foreach ($lines as $line) { 39 | $parts = explode(':', $line, 2); 40 | $headers[trim($parts[0])][] = trim($parts[1] ?? null); 41 | } 42 | 43 | return $headers; 44 | } 45 | 46 | /** 47 | * @param string $header 48 | * 49 | * @return string 50 | */ 51 | function normalizeHeader(string $header): string 52 | { 53 | $header = str_replace('-', ' ', $header); 54 | $filtered = ucwords($header); 55 | 56 | return str_replace(' ', '-', $filtered); 57 | } 58 | 59 | /** 60 | * @param array $headers 61 | * 62 | * @return array 63 | */ 64 | function filterLastResponseHeaders(array $headers): array 65 | { 66 | $filteredHeaders = []; 67 | foreach ($headers as $header) { 68 | if (strpos($header, 'HTTP/') === 0) { 69 | $filteredHeaders = []; 70 | } 71 | $filteredHeaders[] = $header; 72 | } 73 | 74 | return $filteredHeaders; 75 | } 76 | 77 | /** 78 | * @param StreamInterface $stream 79 | * 80 | * @return resource 81 | */ 82 | function copyStreamToResource(StreamInterface $stream) 83 | { 84 | $resource = fopen('php://temp', 'rb+'); 85 | 86 | $stream->rewind(); 87 | 88 | while (!$stream->eof()) { 89 | fwrite($resource, $stream->read(1048576)); 90 | } 91 | 92 | fseek($resource, 0); 93 | 94 | return $resource; 95 | } 96 | 97 | /** 98 | * @param $resource 99 | * 100 | * @return StreamInterface 101 | */ 102 | function copyResourceToStream($resource, StreamFactoryInterface $factory): StreamInterface 103 | { 104 | if (!is_resource($resource)) { 105 | throw new InvalidArgumentException('Not resource.'); 106 | } 107 | 108 | if (stream_get_meta_data($resource)['seekable']) { 109 | rewind($resource); 110 | } 111 | 112 | $tempResource = fopen('php://temp', 'rb+'); 113 | 114 | stream_copy_to_stream($resource, $tempResource); 115 | 116 | $stream = $factory->createStreamFromResource($tempResource); 117 | $stream->rewind(); 118 | 119 | return $stream; 120 | } 121 | 122 | /** 123 | * @param StreamInterface $stream 124 | * 125 | * @return StreamInterface 126 | */ 127 | function inflateStream(StreamInterface $stream, StreamFactoryInterface $factory): StreamInterface 128 | { 129 | $stream->rewind(); 130 | 131 | $stream->read(10); 132 | 133 | $resource = fopen('php://temp', 'rb+'); 134 | 135 | while (!$stream->eof()) { 136 | fwrite($resource, $stream->read(1048576)); 137 | } 138 | fseek($resource, 0); 139 | 140 | stream_filter_append($resource, "zlib.inflate", STREAM_FILTER_READ); 141 | 142 | return copyResourceToStream($resource, $factory); 143 | } 144 | -------------------------------------------------------------------------------- /tests/ClientFactoryTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ClientInterface::class, $factory->create()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Middleware/DeflateTest.php: -------------------------------------------------------------------------------- 1 | 'gzip', 31 | ]); 32 | 33 | $response = $requestHandler->handle($request); 34 | 35 | $this->assertFalse($response->hasHeader('Content-Encoding')); 36 | $this->assertEquals('gzip', $response->getHeader('Content-Encoding-Original')[0]); 37 | $this->assertContains('Example Domain', $response->getBody()->getContents()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Middleware/UserAgentTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($request->hasHeader('User-Agent')); 25 | 26 | $requestHandler = new RequestHandler([ 27 | new UserAgent(), 28 | new ClosureWrapMiddleware(function (RequestInterface $request, RequestHandlerInterface $handler) { 29 | $this->assertTrue($request->hasHeader('User-Agent')); 30 | $this->assertEquals(sprintf('HttpClient PHP/%s', PHP_VERSION), $request->getHeader('User-Agent')[0]); 31 | 32 | return $handler->handle($request); 33 | }), 34 | ], new FallbackMiddleware($responseFactory)); 35 | 36 | $requestHandler->handle($request); 37 | } 38 | 39 | public function testUserAgent() 40 | { 41 | $responseFactory = new ResponseFactory(); 42 | 43 | $userAgent = 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0'; 44 | 45 | $request = new Request(); 46 | 47 | $this->assertFalse($request->hasHeader('User-Agent')); 48 | 49 | $requestHandler = new RequestHandler([ 50 | new UserAgent($userAgent), 51 | new ClosureWrapMiddleware(function (RequestInterface $request, RequestHandlerInterface $handler) use ($userAgent) { 52 | $this->assertTrue($request->hasHeader('User-Agent')); 53 | $this->assertEquals($userAgent, $request->getHeader('User-Agent')[0]); 54 | 55 | return $handler->handle($request); 56 | }), 57 | ], new FallbackMiddleware($responseFactory)); 58 | 59 | $requestHandler->handle($request); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/MiddlewareClientTest.php: -------------------------------------------------------------------------------- 1 | sendRequest($request); 28 | 29 | $this->assertEquals(200, $response->getStatusCode()); 30 | $this->assertEquals(['text/html; charset=UTF-8'], $response->getHeader('Content-type')); 31 | $this->assertContains('Example Domain', $response->getBody()->getContents()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/RequestConfigTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(true, $provider->followLocation()); 16 | $this->assertEquals(10.0, $provider->timeout()); 17 | } 18 | 19 | public function testConfig() 20 | { 21 | $followLocation = false; 22 | $timeout = 25.0; 23 | 24 | $provider = (new RequestConfig()) 25 | ->setFollowLocation($followLocation) 26 | ->setTimeout($timeout); 27 | 28 | $this->assertEquals($followLocation, $provider->followLocation()); 29 | $this->assertEquals($timeout, $provider->timeout()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Resource/ClosureWrapMiddleware.php: -------------------------------------------------------------------------------- 1 | closure = $closure; 24 | } 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 30 | { 31 | return call_user_func($this->closure, $request, $handler); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Transport/CurlTransportTest.php: -------------------------------------------------------------------------------- 1 | withHeader('Accept-Encoding', 'text/html'); 25 | 26 | $response = $transport->sendRequest($request); 27 | 28 | $this->assertEquals(200, $response->getStatusCode()); 29 | $this->assertEquals(['text/html; charset=UTF-8'], $response->getHeader('Content-type')); 30 | $this->assertContains('Example Domain', $response->getBody()->getContents()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Transport/StreamTransportTest.php: -------------------------------------------------------------------------------- 1 | withHeader('Accept-Encoding', 'text/html'); 25 | 26 | $response = $transport->sendRequest($request); 27 | 28 | $this->assertEquals(200, $response->getStatusCode()); 29 | $this->assertEquals(['text/html; charset=UTF-8'], $response->getHeader('Content-type')); 30 | $this->assertContains('Example Domain', $response->getBody()->getContents()); 31 | } 32 | } 33 | --------------------------------------------------------------------------------