├── .gitignore ├── .travis.yml ├── composer.json ├── doc ├── clients.md ├── library.md ├── readme.md └── request-response.md ├── readme.md ├── src ├── Http │ ├── Browser.php │ ├── Clients │ │ ├── AbstractClient.php │ │ ├── CachedClient.php │ │ ├── CurlClient.php │ │ └── StreamClient.php │ ├── Coders │ │ └── DefaultCoder.php │ ├── Helpers.php │ ├── ICache.php │ ├── IClient.php │ ├── ICoder.php │ ├── Library.php │ ├── Message.php │ ├── Request.php │ ├── Response.php │ ├── Storages │ │ └── FileCache.php │ ├── Strict.php │ └── exceptions.php └── http.php └── tests ├── Http ├── Browser.phpt ├── Clients │ ├── CachedClient.phpt │ ├── CurlClient.phpt │ ├── CurlClient.ssl.phpt │ ├── StreamClient.phpt │ ├── StreamClient.ssl.phpt │ └── inc │ │ └── ClientsTestCase.php ├── DefaultCoder.phpt ├── Helpers.aboslutizeUrl.phpt ├── Helpers.parseUrl.phpt ├── Message.phpt ├── Request.phpt ├── Response.phpt ├── Storages │ └── FileCache.phpt └── Strict.phpt ├── bootstrap.php ├── server.ini ├── server ├── BackgroundProcess.php ├── SslWrapper.php ├── cert │ ├── ca.pem │ └── server.pem ├── index.php └── ssl-wrapper.php └── setup.php /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /tests/**/output/ 3 | /tests/temp/ 4 | /vendor 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.4 5 | - 5.5 6 | - 5.6 7 | - 7.0 8 | - hhvm 9 | 10 | matrix: 11 | allow_failures: 12 | - php: hhvm 13 | 14 | before_script: 15 | - composer self-update 16 | - composer install --no-interaction --prefer-source 17 | 18 | script: 19 | - vendor/bin/tester tests --setup tests/setup.php -p php -s 20 | 21 | after_failure: 22 | - for i in $(find tests -name \*.actual); do echo "--- $i"; cat $i; echo; echo; done 23 | - test -f tests/temp/http.log && cat tests/temp/http.log 24 | - test -f tests/temp/ssl-wrapper.log && cat tests/temp/ssl-wrapper.log 25 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bitbang/http", 3 | "type": "library", 4 | "description": "HTTP client library", 5 | "license": ["MIT"], 6 | "authors": [ 7 | { 8 | "name": "Bitbang Contributors", 9 | "homepage": "https://github.com/bitbang/http/graphs/contributors" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=5.4.0" 14 | }, 15 | "require-dev": { 16 | "nette/tester": "~1.7.0" 17 | }, 18 | "suggest": { 19 | "ext-curl": "Allows you to use Bitbang\\Http\\CurlClient", 20 | "kdyby/curl-ca-bundle": "Auto-updated trusted CA Certs bundle. Handy on Windows." 21 | }, 22 | "autoload": { 23 | "classmap": ["src/Http/"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /doc/clients.md: -------------------------------------------------------------------------------- 1 | # Client implementations 2 | 3 | There are two (three) HTTP client implementations. The [Http\Clients\StreamClient](https://github.com/bitbang/http/blob/master/src/Http/Clients/StreamClient.php) and the [Http\Clients\CurlClients](https://github.com/bitbang/http/blob/master/src/Http/Clients/CurlClient.php). They implement the [Http\IClient](https://github.com/bitbang/http/blob/master/src/Http/IClient.php) interface which looks like: 4 | 5 | ```php 6 | namespace Bitbang\Http; 7 | 8 | interface IClient 9 | { 10 | /** @return Response */ 11 | function process(Request $request); 12 | 13 | function onRequest($callback); 14 | 15 | function onResponse($callback); 16 | } 17 | ``` 18 | 19 | The `process()` method is the most important, the `onRequest()` and `onResponse()` are for traffic observation. 20 | 21 | 22 | ## StreamClient 23 | 24 | This client uses `file_get_contents()` function with HTTP context. By constructor, you can pass [SSL Context options](http://php.net/manual/en/context.ssl.php) or callback. 25 | 26 | ```php 27 | use Bitbang\Http; 28 | 29 | # Without parameters 30 | $client = new Http\Clients\StreamClient; 31 | 32 | # SSL Context options 33 | $client = new Http\Clients\StreamClient([ 34 | 'cafile' => '/etc/ssl/trusted.ca.pem', 35 | ]); 36 | 37 | # Callback for fine adjustment 38 | # It is called before every HTTP request (before stream_get_contents() to be exact) 39 | $client = new Http\Clients\StreamClient(function (resource $context, string $url) { 40 | stream_context_set_option($context, [...]); 41 | }); 42 | ``` 43 | 44 | 45 | ## CurlClient 46 | 47 | This client uses `cURL` functions, so the PHP cURL extension has to be loaded. I recommend this client, because it uses HTTP keep-alive connection. By constructor, you can pass [cURL options](http://php.net/manual/en/function.curl-setopt.php) or callback. 48 | 49 | ```php 50 | use Bitbang\Http; 51 | 52 | # Without parameters 53 | $client = new Http\Clients\CurlClient; 54 | 55 | # SSL Context options 56 | $client = new Http\Clients\CurlClient([ 57 | CURLOPT_CAINFO => '/path/to/trusted-ca.pem', 58 | CURLOPT_CONNECTTIMEOUT => 2, 59 | ]); 60 | 61 | # Callback for fine adjustment 62 | # It is called before every HTTP request (before curl_exec() to be exact) 63 | $client = new Http\Clients\CurlClient(function (resource $curl, string $url) { 64 | curl_setopt_array($curl, [...]); 65 | }); 66 | ``` 67 | 68 | 69 | ## CachedClient 70 | 71 | It is not real client. It is a client wrapper with caching capability. The caching strategy is now simple (and not perfect): 72 | 73 | 1. calculate the request finger print 74 | 2. if exists response for such request in cache and has `Last-Modified` or `ETag` header, add those header to request by `If-Modified-Since` or `If-None-Match` 75 | 3. perform HTTP request 76 | 4. web server may return HTTP status 304 - NOT MODIFIED 77 | 5. if so, return response from cache (and this saves traffic) 78 | 79 | The imperfection is the request finger print calculation. It should be calculated from all headers mentioned by `Vary` header, but it is calculated only from `Accept`, `Accept-Encoding` and `Authorization` headers now. Well, I'm using it and it works. If you need, open the issue please. 80 | 81 | ```php 82 | use Bitbang\Http; 83 | 84 | $cache = new Http\Storages\FileCache('/tmp'); # this is naive Http\ICache implementation 85 | $inner = new Http\Clients\CurlClient; 86 | 87 | $client = new Http\Clients\CachedClient($cache, $client); 88 | ``` 89 | 90 | The cache storage has to implement [Http\ICache](https://github.com/bitbang/http/blob/master/src/Http/ICache.php) interface. The naive implementation [Http\Storages\FileCache](https://github.com/bitbang/http/blob/master/src/Http/Storages/FileCache.php) does not implement cache invalidation. 91 | 92 | 93 | ## Redirection (following the Location) 94 | 95 | Mentioned client implementations disable internal HTTP redirection (`CURLOPT_FOLLOWLOCATION = FALSE` for CurlClient, `follow_location = 0` for StreamClient) and solves it by theirs own. The implementation is done in their parent, the [Http\Clients\AbstractClient](https://github.com/bitbang/http/blob/master/src/Http/Clients/AbstractClient.php). You can control redirection by two public properties: 96 | 97 | ```php 98 | use Bitbang\Http; 99 | 100 | $client = new Http\Clients\...; 101 | 102 | # How many times can client follow Location (default is 20) 103 | $client->maxRedirects = 50; 104 | 105 | # On which HTTP response code can client redirect 106 | $client->redirectCodes = NULL; # always (default) 107 | $client->redirectCodes = []; # never 108 | $client->redirectCodes = [301, 308]; # only for these 109 | ``` 110 | 111 | A result of this implementation is, that you always get all requests and response in observation callbacks (read below). 112 | 113 | 114 | ## onRequest() & onResponse() 115 | 116 | You can observe HTTP traffic by these methods. Usage is: 117 | ```php 118 | use Bitbang\Http; 119 | 120 | $client = new Http\Clients\...; 121 | $client->onRequest(function (Http\Request $request) { 122 | var_dump($request); 123 | }); 124 | $client->onResponse(function (Http\Response $response) { 125 | var_dump($response); 126 | }); 127 | ``` 128 | 129 | Note: Never modify `$request` or `$response` here. It's not worth it. 130 | 131 | 132 | ### Example - Download some HTML 133 | ```php 134 | use Bitbang\Http; 135 | 136 | $request = new Http\Request('GET', 'http://example.com'); 137 | $client = new Http\Clients\CurlClient; 138 | 139 | $response = $client->process($request); 140 | 141 | var_dump($response->getBody()); 142 | ``` 143 | 144 | 145 | ### Example - REST API request 146 | 147 | TODO: show some meaningful code and delete the crap above 148 | -------------------------------------------------------------------------------- /doc/library.md: -------------------------------------------------------------------------------- 1 | # Library class 2 | 3 | Well, nothing to say about the `Bitbang\Http\Library` class. The only purpose for it is the `VERSION` constant. 4 | -------------------------------------------------------------------------------- /doc/readme.md: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | This library is a quite low-level light-weight HTTP client. You send GET/POST/HEAD/DELETE/... request and you get simple HTTP response object. It works with `cURL` or with the `stream_get_contents()`. 4 | 5 | 6 | Quick start 7 | =========== 8 | ```php 9 | use Bitbang\Http; 10 | 11 | $client = new Http\Clients\CurlClient; 12 | 13 | $request = new Http\Request('GET', 'http://example.com', [ 14 | 'Accept' => 'text/plain', 15 | 'User-Agent' => 'milo/http-client', 16 | ]); 17 | 18 | $response = $client->process($request); 19 | 20 | var_dump($response->getCode()); 21 | var_dump($response->getHeaders()); 22 | vat_dump($response->getBody()); 23 | ``` 24 | 25 | 26 | Topics 27 | ====== 28 | - [Library class](library.md) 29 | - Exceptions (TODO) 30 | - [Working with Request & Response](request-response.md) 31 | - [Client implementations](clients.md) 32 | - Caching (TODO) 33 | - Browser (TODO) 34 | - Helpers (TODO) 35 | -------------------------------------------------------------------------------- /doc/request-response.md: -------------------------------------------------------------------------------- 1 | # HTTP Request & Response 2 | 3 | There are two classes. [Http\Request](https://github.com/bitbang/http/blob/master/src/Http/Request.php) and [Http\Response](https://github.com/bitbang/http/blob/master/src/Http/Response.php). They have few common methods encapsulated in their ancestor, the abstract [Http\Message](https://github.com/bitbang/http/blob/master/src/Http/Message.php) class. 4 | 5 | You will usually instantiate the Request object. And the Response object you will usually get from the client as a response to request. 6 | 7 | 8 | ## Common methods to work with HTTP headers 9 | 10 | Both Request and Response objects have the same methods for HTTP headers reading. Header names are always case-insensitive. 11 | 12 | #### hasHeader($name) 13 | Returns TRUE/FALSE when header exists. 14 | 15 | #### hasMultiHeader($name) 16 | Returns TRUE/FALSE when header exists and exists more then once. E.g `Set-Cookie`. 17 | 18 | #### getHeader($name, $default = NULL) 19 | Returns header value if exists, `$default` otherwise. 20 | 21 | #### getMultiHeader($name, array $defaults = []) 22 | Returns header values as an array if exists, `$defaults` otherwise. The result is always an array even the header value is only one. 23 | 24 | #### getHeaders() 25 | Returns all headers as an indexed array. Indexes are lower-cased header names. If is the header multi-value, the last one is returned. 26 | 27 | #### getMultiHeaders() 28 | Returns all headers as an indexed array of arrays. Indexes are lower-cased header names. Even simple header values are returns as an array. 29 | 30 | 31 | ## Working with payload 32 | 33 | Both Request and Response objects have the same method `getBody()`. It returns raw body payload. It can be NULL when never set. 34 | 35 | 36 | ## Request 37 | 38 | ```php 39 | use Bitbang\Http; 40 | 41 | $request = new Http\Request( 42 | Http\Request::POST, # HTTP request method 43 | 'http://example.com', # request URL 44 | ['X-Some-Header' => 'value'], # HTTP headers (optional) 45 | '...raw payload...' # payload (optional) 46 | ); 47 | ``` 48 | 49 | Among the headers methods mentioned above, the Request has more methods to adjust HTTP headers. And other methods specific to HTTP request. 50 | 51 | #### addHeader($name, $value) 52 | Sets header value only if header does not exist. 53 | 54 | #### addMultiHeader($name, $value) 55 | Adds another value to header. The value is always added even the same pair header-value already exists. 56 | 57 | #### setHeader($name, $value) 58 | Replaces header value. The header value is always overwritten. The `NULL` removes header. 59 | 60 | #### setMultiHeader($name, array $value) 61 | Replaces header with multi value. The header value is always overwritten. The empty array removes header. 62 | 63 | #### isMethod($method) 64 | Returns TRUE/FALSE when request HTTP method is `$method`. Comparison is case-insensitive, so result is same for `POST` or `PoSt`. You can find some class constants but list is not exhausting. 65 | 66 | #### getMethod() 67 | Returns request HTTP method as it has been set in constructor. When you set `DeLeTe` you will get `DeLeTe`. 68 | 69 | #### getUrl() 70 | Returns request URL as it has been set in constructor. 71 | 72 | 73 | ## Response 74 | 75 | ```php 76 | use Bitbang\Http; 77 | 78 | $request = new Http\Response( 79 | 404, # HTTP status code 80 | ['X-Some-Header' => 'value'], # HTTP headers 81 | '...raw payload...' # payload 82 | ); 83 | ``` 84 | 85 | The Response class has no methods to set or append HTTP headers. By other words, headers are read only. If you need it, instantiate a new Response object and pass it by constructor. 86 | 87 | Following methods are response specific. 88 | 89 | #### getCode() 90 | Returns HTTP response status code. Always as an integer. E.g. 200, 301, 404, 500... Some of them are available as class constants. 91 | 92 | #### isCode($code) 93 | Returns TRUE/FALSE when response status code is `$code`. 94 | 95 | #### getPrevious() 96 | Returns previous HTTP response if exists, NULL otherwise. This response chaining happens on HTTP redirection or response caching. So you can see whole HTTP communication. 97 | 98 | #### setPrevious(Http\Response $response) 99 | Sets previous HTTP response. You will get `Http\LogicException` when previous is already set. More or less, you should not use this method. 100 | 101 | 102 | ### Example - Working with headers 103 | ```php 104 | use Bitbang\Http; 105 | 106 | $request = new Http\Request('GET', 'http://example.com', ['X-My-Header' => "It's me there"]); 107 | 108 | $request->addHeader('X-My-Header', 'Hello'); # does nothing, header already exists 109 | $request->setHeader('X-My-Header', 'Hello'); # replaces header value 110 | $request->setHeader('X-My-Header', NULL); # removes header 111 | $request->getHeader('X-My-Header', 'abcde'); # returns 'abcde', 112 | # because header has been removed before 113 | 114 | $request->addHeader('User-Agent', 'bitbang/http'); # adds header, previous does not exist 115 | 116 | $request->addMultiHeader('Set-Cookie', 'key1=value'); # adds header 117 | $request->addMultiHeader('Set-Cookie', 'key2=value'); # adds header 118 | $request->addMultiHeader('Set-Cookie', 'key3=value'); # adds header 119 | 120 | $request->getHeaders(); # returns 121 | [ 122 | 'user-agent' => 'bitbang/http', 123 | 'set-cookie' => 'key3=value', 124 | ] 125 | 126 | $request->getMultiHeaders(); # returns 127 | [ 128 | 'user-agent' => [ 129 | 'bitbang/http', 130 | ], 131 | 132 | 'set-cookie' => [ 133 | 'key1=value', 134 | 'key2=value', 135 | 'key3=value', 136 | ], 137 | ] 138 | ``` 139 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | HTTP Client 2 | =========== 3 | 4 | # Documentation 5 | Documentation is a part of the library. It is in [doc directory](doc/). You can find there quick-start example. [API documentation](https://codedoc.pub/bitbang/http/master/index.html) is on Codedoc. 6 | 7 | [![Build Status](https://api.travis-ci.org/bitbang/http.svg?branch=master)](https://travis-ci.org/bitbang/http) 8 | 9 | # License 10 | The MIT License (MIT) 11 | 12 | Copyright (c) 2014 Miloslav Hůla 13 | 14 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /src/Http/Browser.php: -------------------------------------------------------------------------------- 1 | baseUrl = $baseUrl; 46 | $this->defaultHeaders = $defaultHeaders; 47 | $this->coder = $coder ?: new Coders\DefaultCoder; 48 | $this->client = $client ?: (extension_loaded('curl') ? new Clients\CurlClient : new Clients\StreamClient); 49 | } 50 | 51 | 52 | /** 53 | * @return string|NULL 54 | */ 55 | public function getBaseUrl() 56 | { 57 | return $this->baseUrl; 58 | } 59 | 60 | 61 | /** 62 | * @return array 63 | */ 64 | public function getDefaultHeaders() 65 | { 66 | return $this->defaultHeaders; 67 | } 68 | 69 | 70 | /** 71 | * @return ICoder 72 | */ 73 | public function getCoder() 74 | { 75 | return $this->coder; 76 | } 77 | 78 | 79 | /** 80 | * @return IClient 81 | */ 82 | public function getClient() 83 | { 84 | return $this->client; 85 | } 86 | 87 | 88 | /** 89 | * @param string 90 | * @param array 91 | * @return Response 92 | */ 93 | public function delete($url, array $headers = []) 94 | { 95 | return $this->process( 96 | $this->createRequest(Request::DELETE, $url, $headers) 97 | ); 98 | } 99 | 100 | 101 | /** 102 | * @param string 103 | * @param array 104 | * @return Response 105 | */ 106 | public function get($url, array $headers = []) 107 | { 108 | return $this->process( 109 | $this->createRequest(Request::GET, $url, $headers) 110 | ); 111 | } 112 | 113 | 114 | /** 115 | * @param string 116 | * @param array 117 | * @return Response 118 | */ 119 | public function head($url, array $headers = []) 120 | { 121 | return $this->process( 122 | $this->createRequest(Request::HEAD, $url, $headers) 123 | ); 124 | } 125 | 126 | 127 | /** 128 | * @param string 129 | * @param mixed 130 | * @param array 131 | * @return Response 132 | */ 133 | public function patch($url, $body, array $headers = []) 134 | { 135 | return $this->process( 136 | $this->createRequest(Request::PATCH, $url, $headers, $body) 137 | ); 138 | } 139 | 140 | 141 | /** 142 | * @param string 143 | * @param mixed 144 | * @param array 145 | * @return Response 146 | */ 147 | public function post($url, $body, array $headers = []) 148 | { 149 | return $this->process( 150 | $this->createRequest(Request::POST, $url, $headers, $body) 151 | ); 152 | } 153 | 154 | 155 | /** 156 | * @param string 157 | * @param mixed 158 | * @param array 159 | * @return Response 160 | */ 161 | public function put($url, $body = '', array $headers = []) 162 | { 163 | return $this->process( 164 | $this->createRequest(Request::PUT, $url, $headers, $body) 165 | ); 166 | } 167 | 168 | 169 | /** 170 | * @param string 171 | * @param string 172 | * @param array 173 | * @param mixed|NULL 174 | * @return Request 175 | */ 176 | protected function createRequest($method, $url, array $headers, $body = NULL) 177 | { 178 | if ($this->baseUrl !== NULL) { 179 | $url = Helpers::absolutizeUrl($this->baseUrl, $url); 180 | } 181 | 182 | $request = new Request($method, $url, $headers, $body, $this->getCoder()); 183 | foreach ($this->defaultHeaders as $name => $value) { 184 | $request->addHeader($name, $value); 185 | } 186 | 187 | return $request; 188 | } 189 | 190 | 191 | /** 192 | * @param Request 193 | * @return Response 194 | */ 195 | protected function process(Request $request) 196 | { 197 | return $this->client->process($request); 198 | } 199 | 200 | } 201 | -------------------------------------------------------------------------------- /src/Http/Clients/AbstractClient.php: -------------------------------------------------------------------------------- 1 | maxRedirects; 42 | $previous = NULL; 43 | do { 44 | $this->setupRequest($request); 45 | 46 | $this->onRequest && call_user_func($this->onRequest, $request); 47 | $response = $this->processRequest($request); 48 | $this->onResponse && call_user_func($this->onResponse, $response); 49 | 50 | $previous = $response->setPrevious($previous); 51 | 52 | $isRedirectCode = $this->redirectCodes === NULL || in_array($response->getCode(), $this->redirectCodes); 53 | if ($isRedirectCode && $response->hasHeader('Location')) { 54 | if ($counter < 1) { 55 | throw new Http\RedirectLoopException("Maximum redirect count ($this->maxRedirects) achieved."); 56 | } 57 | $counter--; 58 | 59 | /** @todo Use the same HTTP $method for redirection? Set $body to NULL? */ 60 | $request = new Http\Request( 61 | $request->getMethod(), 62 | Http\Helpers::absolutizeUrl($request->getUrl(), $response->getHeader('Location')), 63 | $request->getHeaders(), 64 | $request->getBody() 65 | ); 66 | continue; 67 | } 68 | break; 69 | 70 | } while (TRUE); 71 | 72 | return $response; 73 | } 74 | 75 | 76 | /** 77 | * @param callable|NULL function(Request $request) 78 | * @return self 79 | */ 80 | public function onRequest($callback) 81 | { 82 | $this->onRequest = $callback; 83 | return $this; 84 | } 85 | 86 | 87 | /** 88 | * @param callable|NULL function(Response $response) 89 | * @return self 90 | */ 91 | public function onResponse($callback) 92 | { 93 | $this->onResponse = $callback; 94 | return $this; 95 | } 96 | 97 | 98 | protected function setupRequest(Http\Request $request) 99 | { 100 | $request->addHeader('Expect', ''); 101 | } 102 | 103 | 104 | /** 105 | * @param Http\Request $request 106 | * @return Http\Response 107 | * 108 | * @throws Http\BadResponseException 109 | */ 110 | abstract protected function processRequest(Http\Request $request); 111 | 112 | 113 | /** 114 | * @deprecated 115 | * @param Http\Request $request 116 | * @return Http\Response 117 | */ 118 | public function request(Http\Request $request) 119 | { 120 | return $this->process($request); 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /src/Http/Clients/CachedClient.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 37 | $this->client = $client; 38 | } 39 | 40 | 41 | /** 42 | * @return Http\IClient 43 | */ 44 | public function getInnerClient() 45 | { 46 | return $this->client; 47 | } 48 | 49 | 50 | /** 51 | * @param bool $greedy if TRUE, every response will be cached and never re-checked on server 52 | * @return self 53 | */ 54 | public function setGreedyCaching($greedy) 55 | { 56 | $this->greedyCaching = (bool) $greedy; 57 | return $this; 58 | } 59 | 60 | 61 | /** 62 | * @return bool 63 | */ 64 | public function getGreedyCaching() 65 | { 66 | return $this->greedyCaching; 67 | } 68 | 69 | 70 | /** 71 | * @param Http\Request $request 72 | * @return Http\Response 73 | * 74 | * @throws Http\BadResponseException 75 | */ 76 | public function process(Http\Request $request) 77 | { 78 | $request = clone $request; 79 | 80 | $cacheKey = implode('.', [ 81 | $request->getMethod(), 82 | $request->getUrl(), 83 | 84 | /** @todo This should depend on Vary: header */ 85 | $request->getHeader('Accept'), 86 | $request->getHeader('Accept-Encoding'), 87 | $request->getHeader('Authorization') 88 | ]); 89 | 90 | if ($cached = $this->cache->load($cacheKey)) { 91 | if ($this->greedyCaching) { 92 | $cached = clone $cached; 93 | $this->onResponse && call_user_func($this->onResponse, $cached); 94 | return $cached; 95 | } 96 | 97 | /** @var $cached Http\Response */ 98 | if ($cached->hasHeader('Last-Modified')) { 99 | $request->addHeader('If-Modified-Since', $cached->getHeader('Last-Modified')); 100 | } elseif ($cached->hasHeader('ETag')) { 101 | $request->addHeader('If-None-Match', $cached->getHeader('ETag')); 102 | } 103 | } 104 | 105 | $response = $this->client->process($request); 106 | 107 | if ($this->isCacheable($response) || $this->greedyCaching) { 108 | $this->cache->save($cacheKey, clone $response); 109 | } 110 | 111 | if (isset($cached) && $response->getCode() === Http\Response::S304_NOT_MODIFIED) { 112 | $cached = clone $cached; 113 | 114 | /** @todo Should be responses somehow combined into one? */ 115 | $response = $cached->setPrevious($response); 116 | } 117 | 118 | $this->onResponse && call_user_func($this->onResponse, $response); 119 | 120 | return $response; 121 | } 122 | 123 | 124 | /** 125 | * @param callable|NULL function(Request $request) 126 | * @return self 127 | */ 128 | public function onRequest($callback) 129 | { 130 | $this->client->onRequest($callback); 131 | return $this; 132 | } 133 | 134 | 135 | /** 136 | * @param callable|NULL function(Response $response) 137 | * @return self 138 | */ 139 | public function onResponse($callback) 140 | { 141 | $this->client->onResponse(NULL); 142 | $this->onResponse = $callback; 143 | return $this; 144 | } 145 | 146 | 147 | /** 148 | * @param Http\Response $response 149 | * @return bool 150 | */ 151 | protected function isCacheable(Http\Response $response) 152 | { 153 | /** @todo Do it properly. Vary:, Pragma:, TTL... */ 154 | if (!$response->isCode(200)) { 155 | return FALSE; 156 | } elseif (preg_match('#max-age=0|must-revalidate#i', $response->getHeader('Cache-Control', ''))) { 157 | return FALSE; 158 | } 159 | 160 | return $response->hasHeader('ETag') || $response->hasHeader('Last-Modified'); 161 | } 162 | 163 | 164 | /** 165 | * @deprecated 166 | * @param Http\Request $request 167 | * @return Http\Response 168 | */ 169 | public function request(Http\Request $request) 170 | { 171 | return $this->process($request); 172 | } 173 | 174 | } 175 | -------------------------------------------------------------------------------- /src/Http/Clients/CurlClient.php: -------------------------------------------------------------------------------- 1 | options = $optionsOrCallback; 38 | } else { 39 | $this->beforeCurlExec = $optionsOrCallback; 40 | } 41 | } 42 | 43 | 44 | protected function setupRequest(Http\Request $request) 45 | { 46 | parent::setupRequest($request); 47 | $request->addHeader('Connection', 'keep-alive'); 48 | $request->addHeader('User-Agent', 'Bitbang/' . Http\Library::VERSION . ' (cURL)'); 49 | } 50 | 51 | 52 | /** 53 | * @param Http\Request $request 54 | * @return Http\Response 55 | * 56 | * @throws Http\BadResponseException 57 | */ 58 | protected function processRequest(Http\Request $request) 59 | { 60 | $headers = []; 61 | foreach ($request->getHeaders() as $name => $value) { 62 | $headers[] = "$name: $value"; 63 | } 64 | 65 | $responseHeaders = []; 66 | 67 | $options = [ 68 | CURLOPT_FOLLOWLOCATION => FALSE, 69 | CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, 70 | CURLOPT_CUSTOMREQUEST => $request->getMethod(), 71 | CURLOPT_NOBODY => $request->isMethod(Http\Request::HEAD), 72 | CURLOPT_URL => $request->getUrl(), 73 | CURLOPT_HTTPHEADER => $headers, 74 | CURLOPT_RETURNTRANSFER => TRUE, 75 | CURLOPT_POSTFIELDS => $request->getBody(), 76 | CURLOPT_HEADER => FALSE, 77 | CURLOPT_CONNECTTIMEOUT => 10, 78 | CURLOPT_SSL_VERIFYHOST => 2, 79 | CURLOPT_SSL_VERIFYPEER => 1, 80 | CURLOPT_HEADERFUNCTION => function($curl, $line) use (& $responseHeaders, & $last) { 81 | if (strncasecmp($line, 'HTTP/', 5) === 0) { 82 | /** @todo Set proxy response as Response::setPrevious($proxyResponse)? */ 83 | # The HTTP/x.y may occur multiple times with proxy (HTTP/1.1 200 Connection Established) 84 | $responseHeaders = []; 85 | 86 | } elseif (in_array(substr($line, 0, 1), [' ', "\t"], TRUE)) { 87 | $last .= ' ' . trim($line); # RFC2616, 2.2 88 | 89 | } elseif ($line !== "\r\n") { 90 | list($name, $value) = explode(':', $line, 2); 91 | $key = trim($name); 92 | $responseHeaders[$key][] = trim($value); 93 | $last = & $responseHeaders[$key][count($responseHeaders[$key]) - 1]; 94 | } 95 | 96 | return strlen($line); 97 | }, 98 | ]; 99 | 100 | if (defined('CURLOPT_PROTOCOLS')) { # HHVM issue. Even cURL v7.26.0, constants are missing. 101 | $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS; 102 | } 103 | 104 | $options = array_replace($options, $this->options); 105 | 106 | if (!$this->curl) { 107 | $this->curl = curl_init(); 108 | if ($this->curl === FALSE) { 109 | throw new Http\BadResponseException('Cannot init cURL handler.'); 110 | } 111 | } 112 | 113 | $result = curl_setopt_array($this->curl, $options); 114 | if ($result === FALSE) { 115 | throw new Http\BadResponseException('Setting cURL options failed: ' . curl_error($this->curl), curl_errno($this->curl)); 116 | } 117 | 118 | $this->beforeCurlExec && call_user_func($this->beforeCurlExec, $this->curl, $request->getUrl()); 119 | 120 | $body = curl_exec($this->curl); 121 | if ($body === FALSE) { 122 | throw new Http\BadResponseException(curl_error($this->curl), curl_errno($this->curl)); 123 | } 124 | 125 | $code = curl_getinfo($this->curl, CURLINFO_HTTP_CODE); 126 | if ($code === FALSE) { 127 | throw new Http\BadResponseException('HTTP status code is missing: ' . curl_error($this->curl), curl_errno($this->curl)); 128 | } 129 | 130 | return new Http\Response($code, $responseHeaders, $body, $request->getCoder()); 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/Http/Clients/StreamClient.php: -------------------------------------------------------------------------------- 1 | options = $optionsOrCallback; 29 | } else { 30 | $this->onContextCreate = $optionsOrCallback; 31 | } 32 | } 33 | 34 | 35 | protected function setupRequest(Http\Request $request) 36 | { 37 | parent::setupRequest($request); 38 | $request->setHeader('Connection', 'close'); 39 | $request->addHeader('User-Agent', 'Bitbang/' . Http\Library::VERSION . ' (Stream)'); 40 | } 41 | 42 | 43 | /** 44 | * @return Http\Response 45 | * 46 | * @throws Http\BadResponseException 47 | */ 48 | protected function processRequest(Http\Request $request) 49 | { 50 | $headerStr = []; 51 | foreach ($request->getHeaders() as $name => $value) { 52 | foreach ((array) $value as $v) { 53 | $headerStr[] = "$name: $v"; 54 | } 55 | } 56 | 57 | $options = [ 58 | 'http' => [ 59 | 'method' => $request->getMethod(), 60 | 'header' => implode("\r\n", $headerStr) . "\r\n", 61 | 'follow_location' => 0, # Handled manually 62 | 'protocol_version' => 1.1, 63 | 'ignore_errors' => TRUE, 64 | ], 65 | 'ssl' => [ 66 | 'verify_peer' => TRUE, 67 | 'disable_compression' => TRUE, # Effective since PHP 5.4.13 68 | 'SNI_enabled' => TRUE, 69 | 70 | /** @see https://wiki.mozilla.org/Security/Server_Side_TLS#Recommended_Ciphersuite */ 71 | 'ciphers' => 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:' 72 | . 'ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:' 73 | . 'ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:' 74 | . 'ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:' 75 | . 'DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:' 76 | . 'DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK', 77 | ], 78 | ]; 79 | 80 | if (($body = $request->getBody()) !== NULL) { 81 | $options['http']['content'] = $body; 82 | } 83 | 84 | $options = array_replace_recursive($options, $this->options); 85 | 86 | list($code, $headers, $body) = $this->fileGetContents($request->getUrl(), $options); 87 | return new Http\Response($code, $headers, $body, $request->getCoder()); 88 | } 89 | 90 | 91 | /** 92 | * @param string 93 | * @param array 94 | * @return array 95 | * 96 | * @throws Http\BadResponseException 97 | */ 98 | private function fileGetContents($url, array $contextOptions) 99 | { 100 | $context = stream_context_create($contextOptions); 101 | $this->onContextCreate && call_user_func($this->onContextCreate, $context, $url); 102 | 103 | $e = NULL; 104 | set_error_handler(function($severity, $message, $file, $line) use (& $e) { 105 | $e = new \ErrorException($message, 0, $severity, $file, $line, $e); 106 | }, E_WARNING); 107 | 108 | $content = file_get_contents($url, FALSE, $context); 109 | restore_error_handler(); 110 | 111 | if (!isset($http_response_header)) { 112 | throw new Http\BadResponseException('Missing HTTP headers, request failed.', 0, $e); 113 | } 114 | 115 | if (!isset($http_response_header[0]) || !preg_match('~^HTTP/1[.]. (\d{3})~i', $http_response_header[0], $m)) { 116 | throw new Http\BadResponseException('HTTP status code is missing.', 0, $e); 117 | } 118 | unset($http_response_header[0]); 119 | 120 | $headers = []; 121 | foreach ($http_response_header as $header) { 122 | if (in_array(substr($header, 0, 1), [' ', "\t"], TRUE)) { 123 | $last .= ' ' . trim($header); # RFC2616, 2.2 124 | 125 | } else { 126 | list($name, $value) = explode(':', $header, 2) + [NULL, NULL]; 127 | $key = trim($name); 128 | $headers[$key][] = trim($value); 129 | $last = & $headers[$key][count($headers[$key]) - 1]; 130 | } 131 | } 132 | 133 | return [$m[1], $headers, $content]; 134 | } 135 | 136 | } 137 | -------------------------------------------------------------------------------- /src/Http/Coders/DefaultCoder.php: -------------------------------------------------------------------------------- 1 | setHeader('Content-Type', 'application/x-www-form-urlencoded'); 20 | $request->setHeader('Content-Length', strlen($body)); 21 | } 22 | 23 | return $body; 24 | } 25 | 26 | 27 | /** 28 | * @param Http\Response $response 29 | * @return string 30 | */ 31 | public function decode(Http\Response $response) 32 | { 33 | return $response->getBody(); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/Http/Helpers.php: -------------------------------------------------------------------------------- 1 | '']; 25 | } 26 | 27 | $parsed = []; 28 | 29 | list($url, $fragment) = explode('#', $url, 2) + [NULL, NULL]; 30 | list($url, $query) = explode('?', $url, 2) + [NULL, NULL]; 31 | 32 | if (preg_match('#^(?:([a-z][a-z0-9.+-]*):)?//([^/]+)(.*?)$#i', $url, $m)) { 33 | if ($m[1] !== '') { 34 | $parsed['scheme'] = $m[1]; 35 | } 36 | 37 | $parsed['authority'] = $authority = $m[2]; 38 | 39 | $parts = explode('@', $authority, 2); 40 | if (count($parts) === 2) { 41 | list($user, $pass) = explode(':', $parts[0]) + [NULL, NULL]; 42 | $parsed['user'] = $user; 43 | if (isset($pass)) { 44 | $parsed['pass'] = $pass; 45 | } 46 | 47 | $authority = $parts[1]; 48 | } 49 | 50 | list($host, $port) = explode(':', $authority, 2) + [NULL, NULL]; 51 | $parsed['host'] = $host; 52 | if ($port !== NULL) { 53 | $parsed['port'] = $port; 54 | } 55 | 56 | if ($m[3] !== '') { 57 | $parsed['path'] = $m[3]; 58 | } 59 | 60 | } elseif ($url !== '') { 61 | $parsed['path'] = $url; 62 | } 63 | 64 | if (isset($query)) { 65 | $parsed['query'] = $query; 66 | } 67 | if (isset($fragment)) { 68 | $parsed['fragment'] = $fragment; 69 | } 70 | 71 | return $parsed; 72 | } 73 | 74 | 75 | /** 76 | * Creates absolute URL from $relative. 77 | * 78 | * @param string 79 | * @param string 80 | * @return string 81 | */ 82 | public static function absolutizeUrl($absolute, $relative) 83 | { 84 | $parts = self::parseUrl($relative); 85 | if (isset($parts['scheme'])) { 86 | return $relative; 87 | } 88 | 89 | $absolute = self::parseUrl($absolute); 90 | $url = $absolute['scheme'] . ':'; 91 | 92 | if (isset($parts['authority'])) { 93 | return $url . $relative; 94 | } 95 | $url .= '//' . $absolute['authority']; 96 | 97 | if (isset($parts['path']) && $parts['path'] !== '') { 98 | if ($parts['path'][0] === '/') { 99 | return $url . $parts['path']; 100 | } elseif (isset($absolute['path'])) { 101 | return $url . substr($absolute['path'], 0, strrpos($absolute['path'], '/')) . '/' . $relative; 102 | } 103 | 104 | return $url . '/' . $relative; 105 | } 106 | $url .= isset($absolute['path']) ? $absolute['path'] : ''; 107 | 108 | if (isset($parts['query'])) { 109 | return $url . $relative; 110 | } 111 | $url .= isset($absolute['query']) ? "?$absolute[query]" : ''; 112 | 113 | if (isset($parts['fragment'])) { 114 | return $url . $relative; 115 | } 116 | 117 | return $url . (isset($absolute['fragment']) ? "#$absolute[fragment]" : ''); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/Http/ICache.php: -------------------------------------------------------------------------------- 1 | value] */ 16 | private $headers = []; 17 | 18 | /** @var string|NULL */ 19 | private $body; 20 | 21 | /** @var ICoder */ 22 | private $coder; 23 | 24 | 25 | /** 26 | * @param array 27 | * @param mixed|NULL 28 | * @param ICoder 29 | */ 30 | public function __construct(array $headers = [], $body = NULL, ICoder $coder = NULL) 31 | { 32 | foreach ($headers as $name => $values) { 33 | $values = (array) $values; 34 | if (count($values)) { 35 | $this->headers[strtolower($name)] = array_values($values); 36 | } 37 | } 38 | 39 | $this->coder = $coder ?: new Coders\DefaultCoder; 40 | $this->body = $this instanceof Request 41 | ? $this->coder->encode($this, $body) 42 | : $body; 43 | } 44 | 45 | 46 | /** 47 | * Does header exist? 48 | * @param string 49 | * @return bool 50 | */ 51 | public function hasHeader($name) 52 | { 53 | return array_key_exists(strtolower($name), $this->headers); 54 | } 55 | 56 | 57 | /** 58 | * Does header exist and have more than one value? 59 | * @param string 60 | * @return bool 61 | */ 62 | public function hasMultiHeader($name) 63 | { 64 | $name = strtolower($name); 65 | return array_key_exists($name, $this->headers) && count($this->headers[$name]) > 1; 66 | } 67 | 68 | 69 | /** 70 | * @param string 71 | * @param mixed 72 | * @return mixed 73 | */ 74 | public function getHeader($name, $default = NULL) 75 | { 76 | $name = strtolower($name); 77 | return array_key_exists($name, $this->headers) 78 | ? end($this->headers[$name]) 79 | : $default; 80 | } 81 | 82 | 83 | /** 84 | * @param string 85 | * @param array 86 | * @return mixed[] 87 | */ 88 | public function getMultiHeader($name, array $defaults = []) 89 | { 90 | $name = strtolower($name); 91 | return array_key_exists($name, $this->headers) 92 | ? $this->headers[$name] 93 | : $defaults; 94 | } 95 | 96 | 97 | /** 98 | * Set header if not exist. 99 | * @param string 100 | * @param string 101 | * @return self 102 | */ 103 | protected function addHeader($name, $value) 104 | { 105 | $name = strtolower($name); 106 | if (!array_key_exists($name, $this->headers) && $value !== NULL) { 107 | $this->headers[$name] = [$value]; 108 | } 109 | 110 | return $this; 111 | } 112 | 113 | 114 | /** 115 | * Appends next header value. 116 | * @param string 117 | * @param string|string[] 118 | * @return self 119 | */ 120 | protected function addMultiHeader($name, $value) 121 | { 122 | $name = strtolower($name); 123 | $value = array_values((array) $value); 124 | 125 | $this->headers[$name] = array_key_exists($name, $this->headers) 126 | ? array_merge($this->headers[$name], $value) 127 | : $value; 128 | 129 | return $this; 130 | } 131 | 132 | 133 | /** 134 | * @param string 135 | * @param string|NULL NULL unset header 136 | * @return self 137 | */ 138 | protected function setHeader($name, $value) 139 | { 140 | $name = strtolower($name); 141 | if ($value === NULL) { 142 | unset($this->headers[$name]); 143 | } else { 144 | $this->headers[$name] = [$value]; 145 | } 146 | 147 | return $this; 148 | } 149 | 150 | 151 | /** 152 | * @param string 153 | * @param string[] empty array unset header 154 | * @return self 155 | */ 156 | protected function setMultiHeader($name, array $value) 157 | { 158 | $name = strtolower($name); 159 | if (count($value) < 1) { 160 | unset($this->headers[$name]); 161 | } else { 162 | $this->headers[$name] = array_values($value); 163 | } 164 | 165 | return $this; 166 | } 167 | 168 | 169 | /** 170 | * @return array 171 | */ 172 | public function getHeaders() 173 | { 174 | return array_map(function (array $values) { 175 | return end($values); 176 | }, $this->headers); 177 | } 178 | 179 | 180 | /** 181 | * @return array[] 182 | */ 183 | public function getMultiHeaders() 184 | { 185 | return $this->headers; 186 | } 187 | 188 | 189 | /** 190 | * @return string|NULL 191 | */ 192 | public function getBody() 193 | { 194 | return $this->body; 195 | } 196 | 197 | 198 | /** 199 | * @return ICoder 200 | */ 201 | public function getCoder() 202 | { 203 | return $this->coder; 204 | } 205 | 206 | } 207 | -------------------------------------------------------------------------------- /src/Http/Request.php: -------------------------------------------------------------------------------- 1 | method = $method; 40 | $this->url = $url; 41 | parent::__construct($headers, $body, $coder); 42 | } 43 | 44 | 45 | /** 46 | * @param string 47 | * @return bool 48 | */ 49 | public function isMethod($method) 50 | { 51 | return strcasecmp($this->method, $method) === 0; 52 | } 53 | 54 | 55 | /** 56 | * @return string 57 | */ 58 | public function getMethod() 59 | { 60 | return $this->method; 61 | } 62 | 63 | 64 | /** 65 | * @return string 66 | */ 67 | public function getUrl() 68 | { 69 | return $this->url; 70 | } 71 | 72 | 73 | /** 74 | * @param string 75 | * @param string 76 | * @return self 77 | */ 78 | public function addHeader($name, $value) 79 | { 80 | return parent::addHeader($name, $value); 81 | } 82 | 83 | 84 | /** 85 | * @param string 86 | * @param string|string[] 87 | * @return self 88 | */ 89 | public function addMultiHeader($name, $value) 90 | { 91 | return parent::addMultiHeader($name, $value); 92 | } 93 | 94 | 95 | /** 96 | * @param string 97 | * @param string|NULL 98 | * @return self 99 | */ 100 | public function setHeader($name, $value) 101 | { 102 | return parent::setHeader($name, $value); 103 | } 104 | 105 | 106 | /** 107 | * @param string 108 | * @param string[] 109 | * @return self 110 | */ 111 | public function setMultiHeader($name, array $value) 112 | { 113 | return parent::setMultiHeader($name, $value); 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /src/Http/Response.php: -------------------------------------------------------------------------------- 1 | code = (int) $code; 42 | parent::__construct($headers, $body, $coder); 43 | } 44 | 45 | 46 | /** 47 | * HTTP code. 48 | * @return int 49 | */ 50 | public function getCode() 51 | { 52 | return $this->code; 53 | } 54 | 55 | 56 | /** 57 | * @param int 58 | * @return bool 59 | */ 60 | public function isCode($code) 61 | { 62 | return $this->code === (int) $code; 63 | } 64 | 65 | 66 | /** 67 | * @return Response|NULL 68 | */ 69 | public function getPrevious() 70 | { 71 | return $this->previous; 72 | } 73 | 74 | 75 | /** 76 | * @param Response 77 | * @return self 78 | * 79 | * @throws LogicException 80 | */ 81 | public function setPrevious(Response $previous = NULL) 82 | { 83 | if ($this->previous) { 84 | throw new LogicException('Previous response is already set.'); 85 | } 86 | $this->previous = $previous; 87 | 88 | return $this; 89 | } 90 | 91 | 92 | /** 93 | * @return mixed 94 | */ 95 | public function decode() 96 | { 97 | return $this->getCoder()->decode($this); 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/Http/Storages/FileCache.php: -------------------------------------------------------------------------------- 1 | dir = $dir; 48 | } 49 | 50 | 51 | /** 52 | * @param string 53 | * @param mixed 54 | * @return mixed stored value 55 | */ 56 | public function save($key, $value) 57 | { 58 | file_put_contents( 59 | $this->filePath($key), 60 | serialize($value), 61 | LOCK_EX 62 | ); 63 | 64 | return $value; 65 | } 66 | 67 | 68 | /** 69 | * @param string 70 | * @return mixed|NULL 71 | */ 72 | public function load($key) 73 | { 74 | $path = $this->filePath($key); 75 | if (is_file($path) && ($fd = fopen($path, 'rb')) && flock($fd, LOCK_SH)) { 76 | $cached = stream_get_contents($fd); 77 | flock($fd, LOCK_UN); 78 | fclose($fd); 79 | 80 | $success = TRUE; 81 | set_error_handler(function() use (& $success) { return $success = FALSE; }, E_NOTICE); 82 | $cached = unserialize($cached); 83 | restore_error_handler(); 84 | 85 | if ($success) { 86 | return $cached; 87 | } 88 | } 89 | } 90 | 91 | 92 | /** 93 | * @param string 94 | * @return string 95 | */ 96 | private function filePath($key) 97 | { 98 | return $this->dir . DIRECTORY_SEPARATOR . sha1($key) . '.php'; 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /src/Http/Strict.php: -------------------------------------------------------------------------------- 1 | getHeaders(), $request->getUrl(), $request->getCoder()); 32 | } 33 | 34 | function onRequest($callback) {} 35 | function onResponse($callback) {} 36 | } 37 | 38 | 39 | class BrowserTestCase extends Tester\TestCase 40 | { 41 | 42 | public function testGetters() 43 | { 44 | $browser = new Http\Browser( 45 | 'http://hostname.tld', 46 | ['Default' => 'Header'], 47 | $coder = new MockCoder, 48 | $client = new MockClient 49 | ); 50 | 51 | Assert::same('http://hostname.tld', $browser->getBaseUrl()); 52 | Assert::same(['Default' => 'Header'], $browser->getDefaultHeaders()); 53 | Assert::same($coder, $browser->getCoder()); 54 | Assert::same($client, $browser->getClient()); 55 | } 56 | 57 | 58 | public function testExceptions() 59 | { 60 | Assert::exception(function () { 61 | new Http\Browser('/relative'); 62 | }, 'Bitbang\Http\LogicException', "Base URL '/relative' must be absolute."); 63 | } 64 | 65 | 66 | public function testCreateRequest() 67 | { 68 | $browser = new Http\Browser('http://absolute/', ['Default' => 'Header', 'A' => 'B'], NULL, new MockClient); 69 | $response = $browser->get('?query', ['A' => 'C']); 70 | Assert::same('http://absolute/?query', $response->getBody()); 71 | Assert::same(['a' => 'C', 'default' => 'Header'], $response->getHeaders()); 72 | } 73 | 74 | 75 | public function testRequestFactories() 76 | { 77 | $browser = new MockBrowser; 78 | 79 | $this->assertRequest( 80 | $browser->delete('url://', ['H' => 'V']), 81 | 'DELETE', 82 | NULL 83 | ); 84 | 85 | $this->assertRequest( 86 | $browser->get('url://', ['H' => 'V']), 87 | 'GET', 88 | NULL 89 | ); 90 | 91 | $this->assertRequest( 92 | $browser->head('url://', ['H' => 'V']), 93 | 'HEAD', 94 | NULL 95 | ); 96 | 97 | $this->assertRequest( 98 | $browser->patch('url://', 'BoDy', ['H' => 'V']), 99 | 'PATCH' 100 | ); 101 | 102 | $this->assertRequest( 103 | $browser->post('url://', 'BoDy', ['H' => 'V']), 104 | 'POST' 105 | ); 106 | 107 | $this->assertRequest( 108 | $browser->put('url://', 'BoDy', ['H' => 'V']), 109 | 'PUT' 110 | ); 111 | } 112 | 113 | 114 | private function assertRequest($request, $method, $body = 'BoDy') 115 | { 116 | /** @var Http\Request $request */ 117 | Assert::type('Bitbang\Http\Request', $request); 118 | Assert::same('url://', $request->getUrl()); 119 | Assert::same(['h' => 'V'], $request->getHeaders()); 120 | Assert::same($method, $request->getMethod()); 121 | Assert::same($body, $request->getBody()); 122 | } 123 | 124 | } 125 | 126 | 127 | (new BrowserTestCase)->run(); 128 | -------------------------------------------------------------------------------- /tests/Http/Clients/CachedClient.phpt: -------------------------------------------------------------------------------- 1 | requestCallback, $request); 23 | $this->requestCount++; 24 | return $response; 25 | } 26 | 27 | public function onRequest($foo) 28 | { 29 | trigger_error('Inner onRequest called: ' . var_export($foo, TRUE), E_USER_NOTICE); 30 | } 31 | 32 | public function onResponse($foo) 33 | { 34 | trigger_error('Inner onResponse called: ' . var_export($foo, TRUE), E_USER_NOTICE); 35 | } 36 | 37 | } 38 | 39 | 40 | class MockCache implements Http\ICache 41 | { 42 | private $cache = []; 43 | 44 | public function save($key, $value) 45 | { 46 | return $this->cache[$key] = $value; 47 | } 48 | 49 | public function load($key) 50 | { 51 | return isset($this->cache[$key]) ? $this->cache[$key] : NULL; 52 | } 53 | 54 | } 55 | 56 | 57 | class TestedCachedClient extends Http\Clients\CachedClient 58 | { 59 | public function isCacheable(Http\Response $response) { return parent::isCacheable($response); } 60 | } 61 | 62 | 63 | class CachedClientTestCase extends Tester\TestCase 64 | { 65 | /** @var TestedCachedClient */ 66 | private $client; 67 | 68 | /** @var MockClient */ 69 | private $innerClient; 70 | 71 | 72 | public function setup() 73 | { 74 | $cache = new MockCache; 75 | $this->innerClient = new MockClient; 76 | $this->client = new TestedCachedClient($cache, $this->innerClient); 77 | 78 | $this->innerClient->requestCallback = function (Http\Request $request) { 79 | return $request->hasHeader('If-None-Match') 80 | ? new Http\Response(304, [], "inner-304-{$request->getBody()}") 81 | : new Http\Response(200, ['ETag' => '"inner"'], "inner-200-{$request->getBody()}"); 82 | }; 83 | } 84 | 85 | 86 | public function testOnRequestOnResponse() 87 | { 88 | Assert::same($this->innerClient, $this->client->getInnerClient()); 89 | 90 | Assert::error(function() { 91 | Assert::same($this->client, $this->client->onRequest('callback-1')); 92 | Assert::same($this->client, $this->client->onResponse('callback-2')); 93 | }, [ 94 | [E_USER_NOTICE, "Inner onRequest called: 'callback-1'"], 95 | [E_USER_NOTICE, 'Inner onResponse called: NULL'], 96 | ]); 97 | 98 | $onResponseCalled = FALSE; 99 | Assert::error(function() use (& $onResponseCalled) { 100 | $this->client->onResponse(function() use (& $onResponseCalled) { 101 | $onResponseCalled = TRUE; 102 | }); 103 | }, E_USER_NOTICE, 'Inner onResponse called: NULL'); 104 | 105 | $this->client->process(new Http\Request('', '')); 106 | Assert::true($onResponseCalled); 107 | 108 | Assert::same(1, $this->innerClient->requestCount); 109 | } 110 | 111 | 112 | public function testNoCaching() 113 | { 114 | $this->innerClient->requestCallback = function (Http\Request $request) { 115 | Assert::false($request->hasHeader('ETag')); 116 | Assert::false($request->hasHeader('If-Modified-Since')); 117 | 118 | return new Http\Response(200, [], "response-{$request->getBody()}"); 119 | }; 120 | 121 | $response = $this->client->process(new Http\Request('', '', [], '1')); 122 | Assert::same('response-1', $response->getBody()); 123 | Assert::same(1, $this->innerClient->requestCount); 124 | 125 | $response = $this->client->process(new Http\Request('', '', [], '2')); 126 | Assert::same('response-2', $response->getBody()); 127 | Assert::same(2, $this->innerClient->requestCount); 128 | } 129 | 130 | 131 | public function testETagCaching() 132 | { 133 | $this->innerClient->requestCallback = function (Http\Request $request) { 134 | Assert::false($request->hasHeader('If-None-Match')); 135 | Assert::false($request->hasHeader('If-Modified-Since')); 136 | 137 | return new Http\Response(200, ['ETag' => 'e-tag'], "response-{$request->getBody()}"); 138 | }; 139 | 140 | $response = $this->client->process(new Http\Request('', '', [], '1')); 141 | Assert::same('response-1', $response->getBody()); 142 | Assert::same(1, $this->innerClient->requestCount); 143 | 144 | 145 | $this->innerClient->requestCallback = function (Http\Request $request) { 146 | Assert::same('e-tag', $request->getHeader('If-None-Match')); 147 | Assert::false($request->hasHeader('If-Modified-Since')); 148 | 149 | return new Http\Response(304, [], "response-{$request->getBody()}"); 150 | }; 151 | $response = $this->client->process(new Http\Request('', '', [], '2')); 152 | Assert::same('response-1', $response->getBody()); 153 | Assert::type('Bitbang\Http\Response', $response->getPrevious()); 154 | Assert::same(304, $response->getPrevious()->getCode()); 155 | Assert::same(2, $this->innerClient->requestCount); 156 | } 157 | 158 | 159 | public function testIfModifiedCaching() 160 | { 161 | $this->innerClient->requestCallback = function (Http\Request $request) { 162 | Assert::false($request->hasHeader('If-None-Match')); 163 | Assert::false($request->hasHeader('If-Modified-Since')); 164 | 165 | return new Http\Response(200, ['Last-Modified' => 'today'], "response-{$request->getBody()}"); 166 | }; 167 | 168 | $response = $this->client->process(new Http\Request('', '', [], '1')); 169 | Assert::same('response-1', $response->getBody()); 170 | Assert::same(1, $this->innerClient->requestCount); 171 | 172 | 173 | $this->innerClient->requestCallback = function (Http\Request $request) { 174 | Assert::false($request->hasHeader('ETag')); 175 | Assert::same('today', $request->getHeader('If-Modified-Since')); 176 | 177 | return new Http\Response(304, [], "response-{$request->getBody()}"); 178 | }; 179 | 180 | $response = $this->client->process(new Http\Request('', '', [], '2')); 181 | Assert::same('response-1', $response->getBody()); 182 | Assert::type('Bitbang\Http\Response', $response->getPrevious()); 183 | Assert::same(304, $response->getPrevious()->getCode()); 184 | Assert::same(2, $this->innerClient->requestCount); 185 | } 186 | 187 | 188 | public function testPreferIfModifiedAgainstETag() 189 | { 190 | $this->innerClient->requestCallback = function (Http\Request $request) { 191 | Assert::false($request->hasHeader('If-None-Match')); 192 | Assert::false($request->hasHeader('If-Modified-Since')); 193 | 194 | return new Http\Response(200, ['Last-Modified' => 'today', 'ETag' => 'e-tag'], "response-{$request->getBody()}"); 195 | }; 196 | 197 | $response = $this->client->process(new Http\Request('', '', [], '1')); 198 | Assert::same('response-1', $response->getBody()); 199 | Assert::same(1, $this->innerClient->requestCount); 200 | 201 | 202 | $this->innerClient->requestCallback = function (Http\Request $request) { 203 | Assert::false($request->hasHeader('ETag')); 204 | Assert::same('today', $request->getHeader('If-Modified-Since')); 205 | 206 | return new Http\Response(304, [], "response-{$request->getBody()}"); 207 | }; 208 | 209 | $response = $this->client->process(new Http\Request('', '', [], '2')); 210 | Assert::same('response-1', $response->getBody()); 211 | Assert::type('Bitbang\Http\Response', $response->getPrevious()); 212 | Assert::same(304, $response->getPrevious()->getCode()); 213 | Assert::same(2, $this->innerClient->requestCount); 214 | } 215 | 216 | 217 | public function testRepeatedRequest() 218 | { 219 | $request = new Http\Request('', '', [], 'same'); 220 | 221 | # Empty cache 222 | $response = $this->client->process($request); 223 | Assert::same('inner-200-same', $response->getBody()); 224 | Assert::null($response->getPrevious()); 225 | Assert::same(1, $this->innerClient->requestCount); 226 | 227 | # From cache 228 | $response = $this->client->process($request); 229 | Assert::same('inner-200-same', $response->getBody()); 230 | Assert::type('Bitbang\Http\Response', $response->getPrevious()); 231 | Assert::same('inner-304-same', $response->getPrevious()->getBody()); 232 | Assert::same(2, $this->innerClient->requestCount); 233 | 234 | # Again 235 | $response = $this->client->process($request); 236 | Assert::same('inner-200-same', $response->getBody()); 237 | Assert::type('Bitbang\Http\Response', $response->getPrevious()); 238 | Assert::same('inner-304-same', $response->getPrevious()->getBody()); 239 | Assert::same(3, $this->innerClient->requestCount); 240 | } 241 | 242 | 243 | public function testGreedyCachingDisabled() 244 | { 245 | Assert::false($this->client->getGreedyCaching()); 246 | 247 | $request = new Http\Request('', '', [], 'disabled'); 248 | 249 | $response = $this->client->process($request); 250 | Assert::same('inner-200-disabled', $response->getBody()); 251 | Assert::null($response->getPrevious()); 252 | Assert::same(1, $this->innerClient->requestCount); 253 | 254 | $response = $this->client->process($request); 255 | Assert::same('inner-200-disabled', $response->getBody()); 256 | Assert::type('Bitbang\Http\Response', $response->getPrevious()); 257 | Assert::same('inner-304-disabled', $response->getPrevious()->getBody()); 258 | Assert::same(2, $this->innerClient->requestCount); 259 | 260 | $response = $this->client->process($request); 261 | Assert::same('inner-200-disabled', $response->getBody()); 262 | Assert::type('Bitbang\Http\Response', $response->getPrevious()); 263 | Assert::same('inner-304-disabled', $response->getPrevious()->getBody()); 264 | Assert::same(3, $this->innerClient->requestCount); 265 | } 266 | 267 | 268 | public function testGreedyCachingEnabled() 269 | { 270 | $this->client->setGreedyCaching(TRUE); 271 | Assert::true($this->client->getGreedyCaching()); 272 | 273 | $request = new Http\Request('', '', [], 'enabled'); 274 | 275 | $response = $this->client->process($request); 276 | Assert::same('inner-200-enabled', $response->getBody()); 277 | Assert::null($response->getPrevious()); 278 | Assert::same(1, $this->innerClient->requestCount); 279 | 280 | $response = $this->client->process($request); 281 | Assert::same('inner-200-enabled', $response->getBody()); 282 | Assert::null($response->getPrevious()); 283 | Assert::same(1, $this->innerClient->requestCount); 284 | 285 | $response = $this->client->process($request); 286 | Assert::same('inner-200-enabled', $response->getBody()); 287 | Assert::null($response->getPrevious()); 288 | Assert::same(1, $this->innerClient->requestCount); 289 | } 290 | 291 | 292 | /** 293 | * Caching normally non-cacheable responses in greedy caching mode. 294 | */ 295 | public function testGreedyCachingEveryResponse() 296 | { 297 | $this->client->setGreedyCaching(TRUE); 298 | Assert::true($this->client->getGreedyCaching()); 299 | 300 | $response = new Http\Response(404, [], ''); 301 | Assert::false($this->client->isCacheable($response)); 302 | 303 | $this->innerClient->requestCallback = function (Http\Request $request) use ($response) { 304 | return $response; 305 | }; 306 | 307 | $response = $this->client->process(new Http\Request('GET', '')); 308 | Assert::same(404, $response->getCode()); 309 | Assert::null($response->getPrevious()); 310 | Assert::same(1, $this->innerClient->requestCount); 311 | 312 | $response = $this->client->process(new Http\Request('GET', '')); 313 | Assert::same(404, $response->getCode()); 314 | Assert::null($response->getPrevious()); 315 | Assert::same(1, $this->innerClient->requestCount); 316 | 317 | $response = $this->client->process(new Http\Request('GET', 'next')); 318 | Assert::same(404, $response->getCode()); 319 | Assert::null($response->getPrevious()); 320 | Assert::same(2, $this->innerClient->requestCount); 321 | } 322 | 323 | 324 | public function testIsCacheable() 325 | { 326 | Assert::true($this->client->isCacheable(new Http\Response(200, ['ETag' => 'tag'], ''))); 327 | Assert::true($this->client->isCacheable(new Http\Response(200, ['Last-Modified' => 'today'], ''))); 328 | 329 | Assert::false($this->client->isCacheable(new Http\Response(201, ['ETag' => 'tag'], ''))); 330 | Assert::false($this->client->isCacheable(new Http\Response(201, ['Last-Modified' => 'today'], ''))); 331 | 332 | Assert::false($this->client->isCacheable(new Http\Response(200, ['Cache-Control' => 'max-age=0', 'ETag' => 'tag'], ''))); 333 | Assert::false($this->client->isCacheable(new Http\Response(200, ['Cache-Control' => 'max-age=0', 'Last-Modified' => 'today'], ''))); 334 | 335 | Assert::false($this->client->isCacheable(new Http\Response(200, ['Cache-Control' => 'Must-Revalidate', 'ETag' => 'tag'], ''))); 336 | Assert::false($this->client->isCacheable(new Http\Response(200, ['Cache-Control' => 'Must-Revalidate', 'Last-Modified' => 'today'], ''))); 337 | } 338 | 339 | } 340 | 341 | (new CachedClientTestCase)->run(); 342 | -------------------------------------------------------------------------------- /tests/Http/Clients/CurlClient.phpt: -------------------------------------------------------------------------------- 1 | createClient([ 29 | CURLOPT_CUSTOMREQUEST => 'PUT', 30 | ]); 31 | 32 | $response = $client->process( 33 | new Request('GET', $this->baseUrl . '/method') 34 | ); 35 | 36 | Assert::same('method-PUT', $response->getBody()); 37 | } 38 | 39 | 40 | public function testClientSetupCallback() 41 | { 42 | $client = $this->createClient(function($context, $url) use (& $called) { 43 | if (PHP_VERSION_ID < 80000) { 44 | Assert::type('resource', $context); 45 | Assert::same('curl', get_resource_type($context)); 46 | } else { 47 | Assert::type('CurlHandle', $context); 48 | } 49 | Assert::match('%a%/ping', $url); 50 | 51 | $called = TRUE; 52 | }); 53 | 54 | $client->process( 55 | new Request('GET', $this->baseUrl . '/ping') 56 | ); 57 | 58 | Assert::true($called); 59 | } 60 | 61 | 62 | public function testUserAgent() 63 | { 64 | $response = $this->createClient()->process( 65 | new Request('GET', $this->baseUrl . '/user-agent') 66 | ); 67 | 68 | Assert::same('Bitbang/' . Library::VERSION . ' (cURL)', $response->getHeader('X-User-Agent')); 69 | } 70 | 71 | } 72 | 73 | (new CurlClientTestCase(getBaseUrl()))->run(); 74 | -------------------------------------------------------------------------------- /tests/Http/Clients/CurlClient.ssl.phpt: -------------------------------------------------------------------------------- 1 | process( 20 | new Request('GET', getBaseSslUrl()) 21 | ); 22 | }, 'Bitbang\Http\BadResponseException', '%A%certificate%A%'); 23 | 24 | Assert::null($e->getPrevious()); 25 | }); 26 | 27 | 28 | # Trusted SSL CA 29 | test(function() { 30 | $client = new Clients\CurlClient(function($curl) { 31 | curl_setopt($curl, CURLOPT_CAINFO, __DIR__ . '/../../server/cert/ca.pem'); 32 | }); 33 | 34 | $response = $client->process( 35 | new Request('GET', getBaseSslUrl() . '/ping') 36 | ); 37 | 38 | Assert::type('Bitbang\\Http\\Response', $response); 39 | Assert::same('pong', $response->getBody()); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/Http/Clients/StreamClient.phpt: -------------------------------------------------------------------------------- 1 | createClient([ 25 | 'http' => [ 26 | 'method' => 'PUT', 27 | ], 28 | ]); 29 | 30 | $response = $client->process( 31 | new Request('GET', $this->baseUrl . '/method') 32 | ); 33 | 34 | Assert::same('method-PUT', $response->getBody()); 35 | } 36 | 37 | 38 | public function testClientSetupCallback() 39 | { 40 | $client = $this->createClient(function($context, $url) use (& $called) { 41 | Assert::type('resource', $context); 42 | Assert::same('stream-context', get_resource_type($context)); 43 | Assert::match('%a%/ping', $url); 44 | 45 | $called = TRUE; 46 | }); 47 | 48 | $client->process( 49 | new Request('GET', $this->baseUrl . '/ping') 50 | ); 51 | 52 | Assert::true($called); 53 | } 54 | 55 | 56 | public function testUserAgent() 57 | { 58 | $response = $this->createClient()->process( 59 | new Request('GET', $this->baseUrl . '/user-agent') 60 | ); 61 | 62 | Assert::same('Bitbang/' . Library::VERSION . ' (Stream)', $response->getHeader('X-User-Agent')); 63 | } 64 | 65 | } 66 | 67 | (new StreamClientTestCase(getBaseUrl()))->run(); 68 | -------------------------------------------------------------------------------- /tests/Http/Clients/StreamClient.ssl.phpt: -------------------------------------------------------------------------------- 1 | process( 23 | new Request('GET', getBaseSslUrl()) 24 | ); 25 | }, 'Bitbang\Http\BadResponseException'); 26 | 27 | $e = Assert::exception(function() use ($e) { 28 | throw $e->getPrevious(); 29 | }, 'ErrorException', '%a%ailed to open stream%a%'); 30 | 31 | $e = Assert::exception(function() use ($e) { 32 | throw $e->getPrevious(); 33 | }, 'ErrorException', 'file_get_contents(): Failed to enable crypto'); 34 | 35 | $e = Assert::exception(function() use ($e) { 36 | throw $e->getPrevious(); 37 | }, 'ErrorException', '%A%certificate verify failed'); 38 | 39 | Assert::null($e->getPrevious()); 40 | }); 41 | 42 | 43 | # Trusted SSL CA 44 | test(function() { 45 | $client = new Clients\StreamClient(function($context) { 46 | stream_context_set_option($context, 'ssl', 'ciphers', 'ALL'); # testing SSL wrapper limitation 47 | stream_context_set_option($context, 'ssl', 'cafile', __DIR__ . '/../../server/cert/ca.pem'); 48 | }); 49 | 50 | $response = $client->process( 51 | new Request('GET', getBaseSslUrl() . '/ping') 52 | ); 53 | 54 | Assert::type('Bitbang\\Http\\Response', $response); 55 | Assert::same('pong', $response->getBody()); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/Http/Clients/inc/ClientsTestCase.php: -------------------------------------------------------------------------------- 1 | baseUrl = $baseUrl; 18 | } 19 | 20 | 21 | /** @return Bitbang\Http\Clients\AbstractClient */ 22 | abstract protected function createClient(); 23 | 24 | 25 | final public function test200() 26 | { 27 | $response = $this->createClient()->process( 28 | new Request('GET', $this->baseUrl . '/200') 29 | ); 30 | 31 | Assert::same('The 200 response.', $response->getBody()); 32 | Assert::same(200, $response->getCode()); 33 | } 34 | 35 | 36 | final public function test404() 37 | { 38 | $response = $this->createClient()->process( 39 | new Request('GET', $this->baseUrl . '/404') 40 | ); 41 | 42 | Assert::same('The 404 response.', $response->getBody()); 43 | Assert::same(404, $response->getCode()); 44 | } 45 | 46 | 47 | final public function testReceiveHeaders() 48 | { 49 | $response = $this->createClient()->process( 50 | new Request('GET', $this->baseUrl . '/receive-headers') 51 | ); 52 | 53 | Assert::same(['bitbang/http.tests'], $response->getMultiHeader('X-Powered-By')); 54 | Assert::same(['one', 'two'], $response->getMultiHeader('X-Multi')); 55 | } 56 | 57 | 58 | final public function testSendHeaders() 59 | { 60 | $rand = rand(1, 999); 61 | 62 | $response = $this->createClient()->process( 63 | new Request('GET', $this->baseUrl . '/send-headers', ['X-Foo' => "foo-$rand"]) 64 | ); 65 | 66 | Assert::same("bar-foo-$rand", $response->getHeader('X-Bar')); 67 | } 68 | 69 | 70 | // final public function testReceiveMultipleLineHeader() 71 | // { 72 | // $response = $this->createClient()->request( 73 | // new Request('GET', $this->baseUrl . '/receive-multiple-line-header') 74 | // ); 75 | // 76 | // Assert::same('a b c', $response->getHeader('X-Bar')); 77 | // } 78 | 79 | 80 | final public function testRedirectAlways() 81 | { 82 | $client = $this->createClient(); 83 | $client->redirectCodes = NULL; 84 | 85 | $response = $client->process( 86 | new Request('GET', $this->baseUrl . '/redirect/201') 87 | ); 88 | 89 | Assert::same('Redirection finished', $response->getBody()); 90 | Assert::same(200, $response->getCode()); 91 | 92 | $previous = $response->getPrevious(); 93 | Assert::type('Bitbang\Http\Response', $previous); 94 | Assert::same('Redirection made', $previous->getBody()); 95 | Assert::same(201, $previous->getCode()); 96 | Assert::null($previous->getPrevious()); 97 | } 98 | 99 | 100 | final public function testRedirectCodes() 101 | { 102 | $client = $this->createClient(); 103 | $client->redirectCodes = [307]; 104 | 105 | $response = $client->process( 106 | new Request('GET', $this->baseUrl . '/redirect/201') 107 | ); 108 | 109 | Assert::same('Redirection made', $response->getBody()); 110 | Assert::same(201, $response->getCode()); 111 | Assert::true($response->hasHeader('Location')); 112 | Assert::null($response->getPrevious()); 113 | 114 | 115 | $response = $client->process( 116 | new Request('GET', $this->baseUrl . '/redirect/307') 117 | ); 118 | 119 | Assert::same('Redirection finished', $response->getBody()); 120 | Assert::same(200, $response->getCode()); 121 | 122 | $previous = $response->getPrevious(); 123 | Assert::type('Bitbang\Http\Response', $previous); 124 | Assert::same('Redirection made', $previous->getBody()); 125 | Assert::same(307, $previous->getCode()); 126 | Assert::null($previous->getPrevious()); 127 | } 128 | 129 | 130 | final public function testRedirectNever() 131 | { 132 | $client = $this->createClient(); 133 | $client->redirectCodes = []; 134 | 135 | $response = $client->process( 136 | new Request('GET', $this->baseUrl . '/redirect/201') 137 | ); 138 | 139 | Assert::same('Redirection made', $response->getBody()); 140 | Assert::same(201, $response->getCode()); 141 | Assert::true($response->hasHeader('Location')); 142 | Assert::null($response->getPrevious()); 143 | 144 | 145 | $response = $client->process( 146 | new Request('GET', $this->baseUrl . '/redirect/307') 147 | ); 148 | 149 | Assert::same('Redirection made', $response->getBody()); 150 | Assert::same(307, $response->getCode()); 151 | Assert::true($response->hasHeader('Location')); 152 | Assert::null($response->getPrevious()); 153 | } 154 | 155 | 156 | final public function testRelativeRedirect() 157 | { 158 | $client = $this->createClient(); 159 | 160 | $response = $client->process( 161 | new Request('GET', $this->baseUrl . '/relative-redirect') 162 | ); 163 | 164 | Assert::same('Redirection finished', $response->getBody()); 165 | Assert::same(200, $response->getCode()); 166 | 167 | $previous = $response->getPrevious(); 168 | Assert::type('Bitbang\Http\Response', $previous); 169 | Assert::same('Redirection made', $previous->getBody()); 170 | Assert::same(301, $previous->getCode()); 171 | Assert::true($previous->hasHeader('Location')); 172 | Assert::same('/redirected', $previous->getHeader('Location')); 173 | Assert::null($previous->getPrevious()); 174 | } 175 | 176 | 177 | final public function testOnRequestOnResponse() 178 | { 179 | $client = $this->createClient(); 180 | 181 | $insideRequest = NULL; 182 | $client->onRequest(function(Request $request) use (& $insideRequest) { 183 | $insideRequest = $request; 184 | }); 185 | 186 | $insideResponse = NULL; 187 | $client->onResponse(function(Response $response) use (& $insideResponse) { 188 | $insideResponse = $response; 189 | }); 190 | 191 | Assert::null($insideRequest); 192 | Assert::null($insideResponse); 193 | 194 | $client->process( 195 | new Request('GET', $this->baseUrl . '/ping') 196 | ); 197 | 198 | Assert::type('Bitbang\Http\Request', $insideRequest); 199 | Assert::type('Bitbang\Http\Response', $insideResponse); 200 | } 201 | 202 | 203 | final public function testMaxRedirects() 204 | { 205 | $client = $this->createClient(); 206 | $client->onRequest(function() use (& $counter) { 207 | $counter++; 208 | }); 209 | 210 | $request = new Request('GET', $this->baseUrl . '/redirect-loop', ['X-Max-Loop-Count' => 5]); 211 | 212 | $client->maxRedirects = 6; 213 | $counter = -1; 214 | $response = $client->process(clone $request); 215 | Assert::same(5, $counter); 216 | Assert::same('Redirection finished', $response->getBody()); 217 | Assert::same(200, $response->getCode()); 218 | 219 | 220 | $client->maxRedirects = 5; 221 | $counter = -1; 222 | $response = $client->process(clone $request); 223 | Assert::same(5, $counter); 224 | Assert::same('Redirection finished', $response->getBody()); 225 | Assert::same(200, $response->getCode()); 226 | 227 | 228 | $client->maxRedirects = 4; 229 | $counter = -1; 230 | Assert::exception(function() use ($client, $request) { 231 | $client->process($request); 232 | }, 'Bitbang\Http\RedirectLoopException', 'Maximum redirect count (4) achieved.'); 233 | Assert::same(4, $counter); 234 | } 235 | 236 | 237 | final public function testOwnUserAgent() 238 | { 239 | $response = $this->createClient()->process( 240 | new Request('GET', $this->baseUrl . '/user-agent', ['User-Agent' => 'Tested']) 241 | ); 242 | 243 | Assert::same('Tested', $response->getHeader('X-User-Agent')); 244 | } 245 | 246 | 247 | final public function testHttpMethod() 248 | { 249 | $response = $this->createClient()->process( 250 | new Request('POST', $this->baseUrl . '/method') 251 | ); 252 | Assert::same('method-POST', $response->getBody()); 253 | 254 | $response = $this->createClient()->process( 255 | new Request('PUT', $this->baseUrl . '/method') 256 | ); 257 | Assert::same('method-PUT', $response->getBody()); 258 | 259 | $response = $this->createClient()->process( 260 | new Request('DELETE', $this->baseUrl . '/method') 261 | ); 262 | Assert::same('method-DELETE', $response->getBody()); 263 | 264 | $response = $this->createClient()->process( 265 | new Request('HEAD', $this->baseUrl . '/method') 266 | ); 267 | Assert::same('', $response->getBody()); 268 | } 269 | 270 | 271 | final public function testBodyIsPassing() 272 | { 273 | $response = $this->createClient()->process( 274 | new Request('POST', $this->baseUrl . '/body', [], 'request-text') 275 | ); 276 | Assert::same('raw-request-text', $response->getBody()); 277 | } 278 | 279 | 280 | final public function testcoderPassing() 281 | { 282 | $coder = new Bitbang\Http\Coders\DefaultCoder; 283 | 284 | $response = $this->createClient()->process( 285 | new Request('GET', $this->baseUrl . '/200', [], NULL, $coder) 286 | ); 287 | 288 | Assert::same($coder, $response->getCoder()); 289 | } 290 | 291 | } 292 | -------------------------------------------------------------------------------- /tests/Http/DefaultCoder.phpt: -------------------------------------------------------------------------------- 1 | getMultiHeaders()); 15 | Assert::same(NULL, $request->getBody()); 16 | }); 17 | 18 | 19 | test(function () { 20 | $coder = new Http\Coders\DefaultCoder; 21 | $request = new Http\Request('POST', 'url://', [], '', $coder); 22 | 23 | Assert::same([], $request->getMultiHeaders()); 24 | Assert::same('', $request->getBody()); 25 | }); 26 | 27 | 28 | test(function () { 29 | $coder = new Http\Coders\DefaultCoder; 30 | $request = new Http\Request('POST', 'url://', [], 'string', $coder); 31 | 32 | Assert::same([], $request->getMultiHeaders()); 33 | Assert::same('string', $request->getBody()); 34 | }); 35 | 36 | 37 | test(function () { 38 | $coder = new Http\Coders\DefaultCoder; 39 | $request = new Http\Request('POST', 'url://', [], [], $coder); 40 | 41 | Assert::same([ 42 | 'content-type' => ['application/x-www-form-urlencoded'], 43 | 'content-length' => [0], 44 | ], $request->getMultiHeaders()); 45 | Assert::same('', $request->getBody()); 46 | }); 47 | 48 | 49 | test(function () { 50 | $coder = new Http\Coders\DefaultCoder; 51 | $request = new Http\Request('POST', 'url://', [], ['a' => 'b', 'c' => 'd'], $coder); 52 | 53 | Assert::same([ 54 | 'content-type' => ['application/x-www-form-urlencoded'], 55 | 'content-length' => [7], 56 | ], $request->getMultiHeaders()); 57 | Assert::same('a=b&c=d', $request->getBody()); 58 | }); 59 | 60 | 61 | test(function () { 62 | $coder = new Http\Coders\DefaultCoder; 63 | $request = new Http\Request('POST', 'url://', ['Content-Type' => 'foo', 'Content-Length' => -1], [], $coder); 64 | 65 | Assert::same([ 66 | 'content-type' => ['application/x-www-form-urlencoded'], 67 | 'content-length' => [0], 68 | ], $request->getMultiHeaders()); 69 | Assert::same('', $request->getBody()); 70 | }); 71 | 72 | 73 | 74 | # Decode 75 | $coder = new Http\Coders\DefaultCoder; 76 | 77 | Assert::null($coder->decode(new Http\Response(200, [], NULL))); 78 | Assert::same('', $coder->decode(new Http\Response(200, [], ''))); 79 | Assert::same('string', $coder->decode(new Http\Response(200, [], 'string'))); 80 | -------------------------------------------------------------------------------- /tests/Http/Helpers.aboslutizeUrl.phpt: -------------------------------------------------------------------------------- 1 | 'http://host2', 13 | '/' => 'http://hostname.tld/', 14 | 'file2' => 'http://hostname.tld/path/file2', 15 | '?query2' => 'http://hostname.tld/path/file.ext?query2', 16 | '#fragment2' => 'http://hostname.tld/path/file.ext?query#fragment2', 17 | ]; 18 | 19 | foreach ($cases as $relative => $result) { 20 | Assert::same($result, Helpers::absolutizeUrl($absolute, $relative)); 21 | } 22 | }); 23 | 24 | 25 | test(function() { 26 | $absolute = 'http://hostname.tld'; 27 | $cases = [ 28 | '//host2' => 'http://host2', 29 | '/' => 'http://hostname.tld/', 30 | 'file2' => 'http://hostname.tld/file2', 31 | '?query2' => 'http://hostname.tld?query2', 32 | '#fragment2' => 'http://hostname.tld#fragment2', 33 | ]; 34 | 35 | foreach ($cases as $relative => $result) { 36 | Assert::same($result, Helpers::absolutizeUrl($absolute, $relative)); 37 | } 38 | }); 39 | 40 | 41 | test(function() { 42 | $absolute = 'http://hostname.tld/'; 43 | $cases = [ 44 | '//host2' => 'http://host2', 45 | '/' => 'http://hostname.tld/', 46 | 'file2' => 'http://hostname.tld/file2', 47 | '?query2' => 'http://hostname.tld/?query2', 48 | '#fragment2' => 'http://hostname.tld/#fragment2', 49 | ]; 50 | 51 | foreach ($cases as $relative => $result) { 52 | Assert::same($result, Helpers::absolutizeUrl($absolute, $relative)); 53 | } 54 | }); 55 | 56 | 57 | test(function() { 58 | $absolute = 'http://hostname.tld/?query'; 59 | $cases = [ 60 | '//host2' => 'http://host2', 61 | '/' => 'http://hostname.tld/', 62 | 'file2' => 'http://hostname.tld/file2', 63 | '?query2' => 'http://hostname.tld/?query2', 64 | '#fragment2' => 'http://hostname.tld/?query#fragment2', 65 | ]; 66 | 67 | foreach ($cases as $relative => $result) { 68 | Assert::same($result, Helpers::absolutizeUrl($absolute, $relative)); 69 | } 70 | }); 71 | 72 | Assert::same('http://hostname.tld/#fragment', Helpers::absolutizeUrl('http://hostname.tld/#fragment', '')); 73 | -------------------------------------------------------------------------------- /tests/Http/Helpers.parseUrl.phpt: -------------------------------------------------------------------------------- 1 | [ 11 | 'path' => '', 12 | ], 13 | 14 | 'http://' => [ 15 | 'path' => 'http://', 16 | ], 17 | 18 | 'http://hostname' => [ 19 | 'scheme' => 'http', 20 | 'authority' => 'hostname', 21 | 'host' => 'hostname', 22 | ], 23 | 24 | 'http://hostname.tld' => [ 25 | 'scheme' => 'http', 26 | 'authority' => 'hostname.tld', 27 | 'host' => 'hostname.tld', 28 | ], 29 | 30 | 'http://hostname.tld/' => [ 31 | 'scheme' => 'http', 32 | 'authority' => 'hostname.tld', 33 | 'host' => 'hostname.tld', 34 | 'path' => '/', 35 | ], 36 | 37 | 'http://hostname.tld/path' => [ 38 | 'scheme' => 'http', 39 | 'authority' => 'hostname.tld', 40 | 'host' => 'hostname.tld', 41 | 'path' => '/path', 42 | ], 43 | 44 | 'http://hostname.tld/path/' => [ 45 | 'scheme' => 'http', 46 | 'authority' => 'hostname.tld', 47 | 'host' => 'hostname.tld', 48 | 'path' => '/path/', 49 | ], 50 | 51 | 'http://hostname.tld/path/file.ext' => [ 52 | 'scheme' => 'http', 53 | 'authority' => 'hostname.tld', 54 | 'host' => 'hostname.tld', 55 | 'path' => '/path/file.ext', 56 | ], 57 | 58 | 'http://hostname.tld/path/file.ext?' => [ 59 | 'scheme' => 'http', 60 | 'authority' => 'hostname.tld', 61 | 'host' => 'hostname.tld', 62 | 'path' => '/path/file.ext', 63 | 'query' => '', 64 | ], 65 | 66 | 'http://hostname.tld/path/file.ext?a=b' => [ 67 | 'scheme' => 'http', 68 | 'authority' => 'hostname.tld', 69 | 'host' => 'hostname.tld', 70 | 'path' => '/path/file.ext', 71 | 'query' => 'a=b', 72 | ], 73 | 74 | 'http://hostname.tld/path/file.ext?a=b#' => [ 75 | 'scheme' => 'http', 76 | 'authority' => 'hostname.tld', 77 | 'host' => 'hostname.tld', 78 | 'path' => '/path/file.ext', 79 | 'query' => 'a=b', 80 | 'fragment' => '', 81 | ], 82 | 83 | 'http://hostname.tld/path/file.ext?a=b#fragment' => [ 84 | 'scheme' => 'http', 85 | 'authority' => 'hostname.tld', 86 | 'host' => 'hostname.tld', 87 | 'path' => '/path/file.ext', 88 | 'query' => 'a=b', 89 | 'fragment' => 'fragment', 90 | ], 91 | 92 | 'http://hostname.tld:8080/path/file.ext?a=b#fragment' => [ 93 | 'scheme' => 'http', 94 | 'authority' => 'hostname.tld:8080', 95 | 'host' => 'hostname.tld', 96 | 'port' => '8080', 97 | 'path' => '/path/file.ext', 98 | 'query' => 'a=b', 99 | 'fragment' => 'fragment', 100 | ], 101 | 102 | 'http://username@hostname.tld:8080/path/file.ext?a=b#fragment' => [ 103 | 'scheme' => 'http', 104 | 'authority' => 'username@hostname.tld:8080', 105 | 'user' => 'username', 106 | 'host' => 'hostname.tld', 107 | 'port' => '8080', 108 | 'path' => '/path/file.ext', 109 | 'query' => 'a=b', 110 | 'fragment' => 'fragment', 111 | ], 112 | 113 | 'http://username:password@hostname.tld:8080/path/file.ext?a=b#fragment' => [ 114 | 'scheme' => 'http', 115 | 'authority' => 'username:password@hostname.tld:8080', 116 | 'user' => 'username', 117 | 'pass' => 'password', 118 | 'host' => 'hostname.tld', 119 | 'port' => '8080', 120 | 'path' => '/path/file.ext', 121 | 'query' => 'a=b', 122 | 'fragment' => 'fragment', 123 | ], 124 | 125 | 'HtTp://UsErNaMe:PaSsWoRd@HoStNaMe.TlD:8080/PaTh/FiLe.ExT?A=b#FrAgMeNt' => [ 126 | 'scheme' => 'HtTp', 127 | 'authority' => 'UsErNaMe:PaSsWoRd@HoStNaMe.TlD:8080', 128 | 'user' => 'UsErNaMe', 129 | 'pass' => 'PaSsWoRd', 130 | 'host' => 'HoStNaMe.TlD', 131 | 'port' => '8080', 132 | 'path' => '/PaTh/FiLe.ExT', 133 | 'query' => 'A=b', 134 | 'fragment' => 'FrAgMeNt', 135 | ], 136 | 137 | '//hostname' => [ 138 | 'authority' => 'hostname', 139 | 'host' => 'hostname', 140 | ], 141 | 142 | '//hostname/' => [ 143 | 'authority' => 'hostname', 144 | 'host' => 'hostname', 145 | 'path' => '/', 146 | ], 147 | 148 | '//hostname:8080' => [ 149 | 'authority' => 'hostname:8080', 150 | 'host' => 'hostname', 151 | 'port' => '8080', 152 | ], 153 | 154 | '//hostname:8080/path' => [ 155 | 'authority' => 'hostname:8080', 156 | 'host' => 'hostname', 157 | 'port' => '8080', 158 | 'path' => '/path', 159 | ], 160 | 161 | '//hostname:8080/path:123' => [ 162 | 'authority' => 'hostname:8080', 163 | 'host' => 'hostname', 164 | 'port' => '8080', 165 | 'path' => '/path:123', 166 | ], 167 | 168 | 'http://hostname#fragment/fragment' => [ 169 | 'scheme' => 'http', 170 | 'authority' => 'hostname', 171 | 'host' => 'hostname', 172 | 'fragment' => 'fragment/fragment', 173 | ], 174 | 175 | 'http://hostname#fragment?fragment' => [ 176 | 'scheme' => 'http', 177 | 'authority' => 'hostname', 178 | 'host' => 'hostname', 179 | 'fragment' => 'fragment?fragment', 180 | ], 181 | 182 | 'http://hostname#fragment#fragment' => [ 183 | 'scheme' => 'http', 184 | 'authority' => 'hostname', 185 | 'host' => 'hostname', 186 | 'fragment' => 'fragment#fragment', 187 | ], 188 | 189 | 'http://hostname?query?query' => [ 190 | 'scheme' => 'http', 191 | 'authority' => 'hostname', 192 | 'host' => 'hostname', 193 | 'query' => 'query?query', 194 | ], 195 | 196 | 'http://:@hostname' => [ 197 | 'scheme' => 'http', 198 | 'authority' => ':@hostname', 199 | 'user' => '', 200 | 'pass' => '', 201 | 'host' => 'hostname', 202 | ], 203 | 204 | 'http://username:@hostname' => [ 205 | 'scheme' => 'http', 206 | 'authority' => 'username:@hostname', 207 | 'user' => 'username', 208 | 'pass' => '', 209 | 'host' => 'hostname', 210 | ], 211 | 212 | 'http://:password@hostname' => [ 213 | 'scheme' => 'http', 214 | 'authority' => ':password@hostname', 215 | 'user' => '', 216 | 'pass' => 'password', 217 | 'host' => 'hostname', 218 | ], 219 | 220 | '/path' => [ 221 | 'path' => '/path', 222 | ], 223 | 224 | 'path' => [ 225 | 'path' => 'path', 226 | ], 227 | 228 | '?query' => [ 229 | 'query' => 'query', 230 | ], 231 | 232 | '#fragment' => [ 233 | 'fragment' => 'fragment', 234 | ], 235 | ]; 236 | 237 | 238 | foreach ($cases as $url => $parsed) { 239 | Assert::same($parsed, Helpers::parseUrl($url)); 240 | } 241 | -------------------------------------------------------------------------------- /tests/Http/Message.phpt: -------------------------------------------------------------------------------- 1 | 'aaa', 22 | 'A' => 'AAA', 23 | 'B' => 'bbb', 24 | 'c' => NULL, 25 | ]; 26 | 27 | $message = new TestMessage($headers); 28 | Assert::same([ 29 | 'a' => 'AAA', 30 | 'b' => 'bbb', 31 | ], $message->getHeaders()); 32 | 33 | Assert::true($message->hasHeader('a')); 34 | Assert::true($message->hasHeader('A')); 35 | Assert::false($message->hasHeader('foo')); 36 | 37 | Assert::null($message->getHeader('foo')); 38 | Assert::same('default', $message->getHeader('foo', 'default')); 39 | 40 | $message->addHeader('Added', 'val'); 41 | Assert::same('val', $message->getHeader('added')); 42 | 43 | $message->addHeader('a', 'new-val'); 44 | Assert::same('AAA', $message->getHeader('a')); 45 | 46 | $message->setHeader('Set', 'val'); 47 | Assert::same('val', $message->getHeader('Set')); 48 | 49 | $message->setHeader('a', 'val'); 50 | Assert::same('val', $message->getHeader('a')); 51 | 52 | $message->setHeader('a', NULL); 53 | Assert::false($message->hasHeader('a')); 54 | }); 55 | 56 | 57 | # Multi-headers 58 | test(function() { 59 | $headers = [ 60 | 'a' => ['aaa', 'aaaa'], 61 | 'A' => ['AAA', 'AAAA'], 62 | 'B' => 'bbb', 63 | 'c' => NULL, 64 | 'd' => [], 65 | 'e' => ['foo' => 'eee', 5 => 'eeee'] 66 | ]; 67 | 68 | $message = new TestMessage($headers); 69 | Assert::same([ 70 | 'a' => ['AAA', 'AAAA'], 71 | 'b' => ['bbb'], 72 | 'e' => ['eee', 'eeee'], 73 | ], $message->getMultiHeaders()); 74 | 75 | Assert::same([ 76 | 'a' => 'AAAA', 77 | 'b' => 'bbb', 78 | 'e' => 'eeee', 79 | ], $message->getHeaders()); 80 | 81 | Assert::true($message->hasMultiHeader('a')); 82 | Assert::true($message->hasMultiHeader('A')); 83 | Assert::false($message->hasMultiHeader('b')); 84 | Assert::false($message->hasMultiHeader('foo')); 85 | 86 | Assert::same([], $message->getMultiHeader('foo')); 87 | Assert::same(['default', 'value'], $message->getMultiHeader('foo', ['default', 'value'])); 88 | Assert::same('AAAA', $message->getHeader('a')); 89 | 90 | Assert::null($message->getHeader('Added')); 91 | Assert::same([], $message->getMultiHeader('Added')); 92 | 93 | $message->addMultiHeader('Added', 'm1'); 94 | Assert::same(['m1'], $message->getMultiHeader('added')); 95 | 96 | $message->addMultiHeader('Added', 'm2'); 97 | Assert::same(['m1', 'm2'], $message->getMultiHeader('added')); 98 | 99 | $message->addMultiHeader('Added', []); 100 | Assert::same(['m1', 'm2'], $message->getMultiHeader('added')); 101 | 102 | $message->addMultiHeader('Added', ['foo' => 'm4', 'bar' => 'm5']); 103 | Assert::same(['m1', 'm2', 'm4', 'm5'], $message->getMultiHeader('added')); 104 | Assert::same('m5', $message->getHeader('added')); 105 | 106 | $message->setMultiHeader('Set', ['val']); 107 | Assert::same(['val'], $message->getMultiHeader('Set')); 108 | 109 | $message->setMultiHeader('Set', []); 110 | Assert::same([], $message->getMultiHeader('Set')); 111 | Assert::null($message->getHeader('Set')); 112 | 113 | $message->setHeader('a', 'val'); 114 | Assert::same('val', $message->getHeader('a')); 115 | 116 | $message->setHeader('a', NULL); 117 | Assert::false($message->hasHeader('a')); 118 | }); 119 | 120 | 121 | # Content 122 | test(function() { 123 | Assert::same(NULL, (new TestMessage([], NULL))->getBody()); 124 | Assert::same('', (new TestMessage([], ''))->getBody()); 125 | }); 126 | 127 | 128 | # Fluent 129 | test(function() { 130 | $message = new TestMessage; 131 | 132 | Assert::same($message, $message->addHeader('foo', 'bar')); 133 | Assert::same($message, $message->setHeader('foo', 'bar')); 134 | Assert::same($message, $message->setMultiHeader('foo', ['bar'])); 135 | }); 136 | 137 | 138 | # Coder 139 | test(function() { 140 | $message = new TestMessage; 141 | Assert::type('Bitbang\Http\Coders\DefaultCoder', $message->getCoder()); 142 | 143 | $coder = new Http\Coders\DefaultCoder; 144 | $message = new TestMessage([], NULL, $coder); 145 | Assert::same($coder, $message->getCoder()); 146 | }); 147 | -------------------------------------------------------------------------------- /tests/Http/Request.phpt: -------------------------------------------------------------------------------- 1 | getMethod()); 12 | Assert::true($request->isMethod('Foo')); 13 | Assert::true($request->isMethod('FOO')); 14 | 15 | Assert::same('http://', $request->getUrl()); 16 | 17 | Assert::same($request, $request->addHeader('foo', 'bar')); 18 | Assert::same($request, $request->addMultiHeader('foo', ['bar'])); 19 | Assert::same($request, $request->setHeader('foo', 'bar')); 20 | Assert::same($request, $request->setMultiHeader('foo', ['bar'])); 21 | -------------------------------------------------------------------------------- /tests/Http/Response.phpt: -------------------------------------------------------------------------------- 1 | getCode()); 12 | Assert::true($response->isCode(200)); 13 | Assert::true($response->isCode('200')); 14 | Assert::false($response->isCode(0)); 15 | 16 | 17 | # Previous 18 | $response = new Http\Response('200', [], '1'); 19 | $previous = new Http\Response('200', [], '2'); 20 | Assert::null($response->getPrevious()); 21 | 22 | $response->setPrevious($previous); 23 | Assert::same($previous, $response->getPrevious()); 24 | 25 | Assert::exception(function() use ($response, $previous) { 26 | $response->setPrevious($previous); 27 | }, 'Bitbang\Http\LogicException', 'Previous response is already set.'); 28 | 29 | 30 | class TestCoder implements Http\ICoder 31 | { 32 | public function encode(Http\Request $request, $body) 33 | { 34 | return $body; 35 | } 36 | 37 | public function decode(Http\Response $response) 38 | { 39 | return 'Decoded: ' . $response->getBody(); 40 | } 41 | } 42 | 43 | $response = new Http\Response('200', [], 'body'); 44 | Assert::same('body', $response->decode()); 45 | 46 | $response = new Http\Response('200', [], 'body', new TestCoder); 47 | Assert::same('Decoded: body', $response->decode()); 48 | -------------------------------------------------------------------------------- /tests/Http/Storages/FileCache.phpt: -------------------------------------------------------------------------------- 1 | getPrevious()); 14 | 15 | 16 | $cache = new Storages\FileCache(getTempDir()); 17 | 18 | Assert::null($cache->load('undefined')); 19 | 20 | $value = $cache->save('key-1', NULL); 21 | Assert::null($cache->load('key-1')); 22 | 23 | $value = $cache->save('key-2', TRUE); 24 | Assert::true($cache->load('key-2')); 25 | 26 | $value = $cache->save('key-3', FALSE); 27 | Assert::false($cache->load('key-3')); 28 | 29 | $value = $cache->save('key-4', []); 30 | Assert::same([], $cache->load('key-4')); 31 | 32 | $value = $cache->save('key-5', [0, 'a', []]); 33 | Assert::same([0, 'a', []], $cache->load('key-5')); 34 | 35 | $value = $cache->save('key-6', new stdClass); 36 | Assert::equal(new stdClass, $cache->load('key-6')); 37 | 38 | 39 | Assert::exception(function() { 40 | mkdir($dir = getTempDir() . DIRECTORY_SEPARATOR . 'sub'); 41 | file_put_contents($dir . DIRECTORY_SEPARATOR . Storages\FileCache::DIRECTORY, ''); 42 | new Storages\FileCache($dir); 43 | }, 'Bitbang\Http\Storages\MissingDirectoryException', "Cannot create '%a%' directory."); 44 | -------------------------------------------------------------------------------- /tests/Http/Strict.phpt: -------------------------------------------------------------------------------- 1 | undefined; 17 | }, 'Bitbang\Http\LogicException', 'Cannot read an undeclared property C::$undefined.'); 18 | 19 | Assert::exception(function() { 20 | $o = new C; 21 | $o->undefined = ''; 22 | }, 'Bitbang\Http\LogicException', 'Cannot write to an undeclared property C::$undefined.'); 23 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | proc = @proc_open( 36 | $command, 37 | [ 38 | ['pipe', 'r'], 39 | $stdout === NULL ? ['pipe', 'w'] : ['file', $stdout, 'wb'], 40 | $stderr === NULL ? ['pipe', 'w'] : ['file', $stderr, 'wb'], 41 | ], 42 | $pipes, 43 | __DIR__, 44 | NULL, 45 | ['bypass_shell' => TRUE] 46 | ); 47 | 48 | if ($this->proc === FALSE) { 49 | throw new \RuntimeException(error_get_last()['message']); 50 | } 51 | 52 | /* @todo: Check that process didn't hang up. */ 53 | 54 | fclose($pipes[0]); 55 | isset($pipes[1]) && $this->stdout = $pipes[1]; 56 | isset($pipes[2]) && $this->stderr = $pipes[2]; 57 | } 58 | 59 | 60 | public function terminate() 61 | { 62 | proc_terminate($this->proc, 2); 63 | proc_terminate($this->proc, 9); # Solves job hanging on Tracis-CI PHP 5.4. Cannot reproduce. 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /tests/server/SslWrapper.php: -------------------------------------------------------------------------------- 1 | address = $address; 28 | $this->port = $port; 29 | } 30 | 31 | 32 | /** 33 | * @param string listening address 34 | * @param int listening port 35 | * @param string path to PEM file 36 | */ 37 | public function listen($address, $port, $pemFile) 38 | { 39 | $context = stream_context_create([ 40 | 'ssl' => [ 41 | 'local_cert' => $pemFile, 42 | ], 43 | ]); 44 | 45 | $server = stream_socket_server("tcp://$address:$port", $errorNo, $errorStr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context); 46 | stream_set_blocking($server, 1); 47 | stream_socket_enable_crypto($server, FALSE); 48 | stream_set_blocking($server, 0); 49 | 50 | do { 51 | $read = array_merge([$server], $this->resources); 52 | $write = $exceptions = NULL; 53 | 54 | stream_select($read, $write, $exceptions, NULL); 55 | foreach ($read as $resource) { 56 | if ($resource === $server) { 57 | $this->accept($server); 58 | } else { 59 | $this->forward($resource); 60 | } 61 | } 62 | } while (TRUE); 63 | } 64 | 65 | 66 | /** 67 | * @param resource 68 | */ 69 | private function accept($server) 70 | { 71 | $resource = stream_socket_accept($server); 72 | 73 | stream_set_blocking($resource, 1); 74 | $res = @stream_socket_enable_crypto($resource, TRUE, STREAM_CRYPTO_METHOD_TLS_SERVER); // @ - e.g. when using HTTP instead of HTTPS 75 | if ($res === FALSE) { 76 | file_put_contents('php://stderr', error_get_last()['message'] . "\n"); 77 | @stream_socket_shutdown($resource, STREAM_SHUT_RDWR); 78 | @fclose($resource); 79 | return; 80 | } 81 | stream_set_blocking($resource, 0); 82 | 83 | $endpoint = stream_socket_client("tcp://{$this->address}:{$this->port}"); 84 | stream_set_blocking($endpoint, 0); 85 | 86 | $this->resources[(string) $resource] = $endpoint; 87 | $this->resources[(string) $endpoint] = $resource; 88 | } 89 | 90 | 91 | /** 92 | * @param resource 93 | */ 94 | private function forward($source) 95 | { 96 | $endpoint = $this->resources[(string) $source]; 97 | 98 | $data = @fread($source, 4096); // @ - SSL/TLS layer may emit warning 99 | 100 | stream_set_blocking($endpoint, 1); // @todo: Non-blocking write? 101 | fwrite($endpoint, $data); 102 | stream_set_blocking($endpoint, 0); 103 | 104 | if ($data == '' && feof($source)) { // intentionally == 105 | $this->close($source); 106 | } 107 | } 108 | 109 | 110 | /** 111 | * @param resource 112 | */ 113 | private function close($resource) 114 | { 115 | $endpoint = $this->resources[(string) $resource]; 116 | unset( 117 | $this->resources[(string) $resource], 118 | $this->resources[(string) $endpoint] 119 | ); 120 | 121 | @stream_set_blocking($endpoint, 1); 122 | @stream_set_blocking($resource, 1); 123 | 124 | @stream_socket_enable_crypto($endpoint, FALSE); 125 | @stream_socket_enable_crypto($resource, FALSE); 126 | 127 | @stream_socket_shutdown($endpoint, STREAM_SHUT_RDWR); 128 | @stream_socket_shutdown($resource, STREAM_SHUT_RDWR); 129 | 130 | @fclose($endpoint); 131 | @fclose($resource); 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /tests/server/cert/ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDZTCCAk2gAwIBAgIJAPpbKW3N0YNoMA0GCSqGSIb3DQEBBQUAMEkxCzAJBgNV 3 | BAYTAkNaMRcwFQYDVQQIDA5DemVjaCBSZXB1YmxpYzEPMA0GA1UEBwwGUHJhZ3Vl 4 | MRAwDgYDVQQKDAdCaXRiYW5nMB4XDTE1MDIxNDIxMzk0NloXDTI1MDIxMTIxMzk0 5 | NlowSTELMAkGA1UEBhMCQ1oxFzAVBgNVBAgMDkN6ZWNoIFJlcHVibGljMQ8wDQYD 6 | VQQHDAZQcmFndWUxEDAOBgNVBAoMB0JpdGJhbmcwggEiMA0GCSqGSIb3DQEBAQUA 7 | A4IBDwAwggEKAoIBAQDe5HqijcqxO0AwnGMzDyX5qObb/Om7iMn/SG1gKmBwNE56 8 | 4qINIHHvFeJntyIPKemBC1Bq5ceKjNJnxKQZ0QT4fxpjlyB/MzLgokm7D+TYJBuq 9 | US6KRkjZ8gtUHyW4t5gXNlmQ0jfixlXIeb2674Lmpnp9YOLZ8lW/ZGM/XKATiioa 10 | E7HvNVd3Txh/Db2utO4vGfE/s1zhGcF4M5iq5HEppmTseGpbWkk6o2Vxb0dQAelp 11 | 2KkqKnFcq03pES1RF7TCJr3x50WGz1mEJEbK00jAHupkj7mV+3o0XDG/iOaVQqqw 12 | 2Z6I9+DOcSyRAmQTH/PUQlEhAu9q/5P6sbikhng7AgMBAAGjUDBOMB0GA1UdDgQW 13 | BBSqmsd7PLQwz5mpECzmSial2DKRrzAfBgNVHSMEGDAWgBSqmsd7PLQwz5mpECzm 14 | Sial2DKRrzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQCbxl7vx8Xv 15 | i94pGUlnjTm+n00/3t/t+xw5tTfzl4ETxEoGYr2lGQ7dS2pAzXxyQjdBiIGCat5t 16 | msegWR40TXpYoNIOCOmd9Virmoagn7AYibtSdT4Tz5YkUlALRJYDUigjZl/JhQN3 17 | qaA0pRccpzfegjlLMDu0Y4hXFmbvTu0RvIhqDA4VFcSREf+O6AqrfnlDbJfakBuo 18 | acVaeehHCUuS+tnZnUXuK4osMX8kmQScF8d3LsVP7bEFbQj/QaBsbXvsPCsUYp2G 19 | iFtXc+iXX66udSw/x37VmL+yAlC2M9rckJTDHWN1rnrLVGZT4vAUHd4NnDnNkmcA 20 | gEoCZ34sXi0u 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /tests/server/cert/server.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAvTy0CTz7kfSNq+f8ktcbRatHWkS6KVtilMr4I5vxn3Maqniw 3 | o6e2e205aHKfvGIJqX1INSRqb660zJuijcM6dvp5Ay3iHSgzMGrIiFK5tg4lao8c 4 | kNCuP5y7cjMYcucmNIUmdHyF8q2QQFo2WK0U7+xH0KupF7b/aF0mOhumTZou1EXP 5 | hbgaEPgCSATnNouoNK0qvUcj6WPurex8c6f1bVEHTuVV6AIt1Glk591xmA7nmgt5 6 | e6ygX486M0RrIHlqmGwVZwB9mwfE5bsBkqz2YVS6Gw4XUI4JBgksjUvxORRpjv0/ 7 | D0AeppRZSHgephLYJL8s0Af656I9gBX1anYNOwIDAQABAoIBAQCn/y0Bio73qzje 8 | ZSwYHDkM55qHq73tG0DwQSR7UGT4HhKNRmniT/CzsGqnrCLc9dgIDL2+195Z2aqO 9 | fpiX67qKh6BIz0IOinKvSA1Q4MgXtIVJDVXZxjj7JiROpMfOsiB7vb0+6pFruOP4 10 | DV5z6OxOdt/mg46xC/fAafmW2pcQCgrspRjCcdjLdOMFsUhld0NP7Xsemw1jZNau 11 | NpCISpSW6X/cM0ktqJ/8S9JWlwHf+5XApiBVHMYubhnDRfvzeDZ60UGq8gKTDZ5O 12 | 2OrZGtVxQhzJvoWsLbzZeyn3yzdNBFDpPM316jeGxonWpRoafpF0t3tacKWlka2s 13 | kWqx1wgBAoGBAOUpPAG7tkP8V06TMjZyiQAwNnuN4e1qgKhDE4kS4V1jrQSReOrR 14 | M8gL+0M+uNWNFgNxfgqlU9plPK1bEad7myBZX3iT4A5e4S7VzXqbSkooTWFTxrVq 15 | XvWb/uEaUfbU++1rC56XHXzJSQgcGxaOnmIWGEvcg5CFjSVxjJGYisQBAoGBANNm 16 | dXZ57YLlZPyT3p5hAG5NDWZA9It/nODOd9sL28R8I4DlOvAdzH5yFHAV6lilrAcZ 17 | KD2L97hK8GxteLTlvNqrXMMRtK76dzu7NmOhHWVQu9mu6zHkMpmkPEFlKfOYpbNp 18 | Wyg0IIySk5Ob6N3e0ruSkyYGFMN7wn2UDT/0NuE7AoGADJsBDwNZWlIGJ29XYsFY 19 | IeeFB7TdSacDHr5Z079zICT8fnTWFuydEZL/JkrL9gtFu7jBeypu+2N4O/z3cqQM 20 | +3GPG93ehEvZzS67l9P0+TFQWFs4YgBQ6ufC1HUTLyW2GfA6emXLnHKiDs0k/E+q 21 | DLE0cu/VWNzPz9B4MAYZFAECgYEApvUBigtrwHhJVI24QR9dBsAF+B8Ow+mKTaXi 22 | 1PW9oKVmKNNhw+fU7HxOleNJDK/zeDuvI0Sa5UsSLKAct8nFaHH0Nf+S0qrvZhZK 23 | M084dx7W7WoPSHzwVZV3HTK3ejKxk0t++faJSlws/2Qf+rKTfh6Z5mrhFS52AVXf 24 | TQYkHacCgYApU7sydsAgjD3kJAp2+K7SX6DZtRHq31GnOakZ6ZzV4dUVCfnFY36L 25 | 9CDQWjiPG5tT1NHSs0cpQ24YsZ4DhPj8rkaXvFEyEFl3uGX/MlPJT4fC2zc4BLWv 26 | SbisGlAZ3YrTKL0Lzp3Rsn7sngbf7hVq9GN5igfqaCLCKIbcrd1Q2A== 27 | -----END RSA PRIVATE KEY----- 28 | -----BEGIN CERTIFICATE----- 29 | MIIDIjCCAgoCCQDbawxj3Zd3ojANBgkqhkiG9w0BAQUFADBJMQswCQYDVQQGEwJD 30 | WjEXMBUGA1UECAwOQ3plY2ggUmVwdWJsaWMxDzANBgNVBAcMBlByYWd1ZTEQMA4G 31 | A1UECgwHQml0YmFuZzAeFw0xNTAyMTQyMTQ0MDJaFw0yNTAyMTEyMTQ0MDJaMF0x 32 | CzAJBgNVBAYTAkNaMRcwFQYDVQQIDA5DemVjaCBSZXB1YmxpYzEPMA0GA1UEBwwG 33 | UHJhZ3VlMRAwDgYDVQQKDAdCaXRiYW5nMRIwEAYDVQQDDAkxMjcuMC4wLjEwggEi 34 | MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9PLQJPPuR9I2r5/yS1xtFq0da 35 | RLopW2KUyvgjm/GfcxqqeLCjp7Z7bTlocp+8YgmpfUg1JGpvrrTMm6KNwzp2+nkD 36 | LeIdKDMwasiIUrm2DiVqjxyQ0K4/nLtyMxhy5yY0hSZ0fIXyrZBAWjZYrRTv7EfQ 37 | q6kXtv9oXSY6G6ZNmi7URc+FuBoQ+AJIBOc2i6g0rSq9RyPpY+6t7Hxzp/VtUQdO 38 | 5VXoAi3UaWTn3XGYDueaC3l7rKBfjzozRGsgeWqYbBVnAH2bB8TluwGSrPZhVLob 39 | DhdQjgkGCSyNS/E5FGmO/T8PQB6mlFlIeB6mEtgkvyzQB/rnoj2AFfVqdg07AgMB 40 | AAEwDQYJKoZIhvcNAQEFBQADggEBAHi7hLPkm0LEN6xyjVBfOlmKtZeuPxgVYpDY 41 | VzaCvN3Uwdz8PNtmn8inE3HxQ5uoXWgNMuFfiHzzVpZoFWQL9VgnWbTKIWJxKCTp 42 | GduKP9TGR+dJET55p/smgiK4AvqWWCKhNQ3xFHBAVIWdlgP6JsT2ZDBn6Bp2h9Cf 43 | 2xpdxNLnYs5w+VCHoUUZnR+cglLP+sSEdO/EJ3XQXAug17aRTyTOkvpxFxzdnxnp 44 | OwE+cSux8txBI0ZXSPOLUahkVgTrd9WxxQklbdtOlGTs6JHDd77uiu5Y8Zs0hiPi 45 | WVGl1i7GrtpnphmZDaHUiV0R2MvnFCclqV0yAA3pf6/CI6L34IA= 46 | -----END CERTIFICATE----- 47 | -------------------------------------------------------------------------------- /tests/server/index.php: -------------------------------------------------------------------------------- 1 | = $_SERVER['HTTP_X_MAX_LOOP_COUNT']) { 46 | header("Location: http://$_SERVER[HTTP_HOST]/redirected"); 47 | } else { 48 | header("Location: http://$_SERVER[HTTP_HOST]/redirect-loop/" . ($count + 1)); 49 | } 50 | echo 'Redirection loop'; 51 | 52 | } elseif ($requestUri === '/user-agent') { 53 | if (isset($_SERVER['HTTP_USER_AGENT'])) { 54 | header("X-User-Agent: $_SERVER[HTTP_USER_AGENT]"); 55 | } 56 | 57 | } elseif ($requestUri === '/method') { 58 | echo "method-$_SERVER[REQUEST_METHOD]"; 59 | 60 | } elseif ($requestUri === '/body') { 61 | echo 'raw-' . file_get_contents('php://input'); 62 | 63 | } else { 64 | header('HTTP/1.1 500'); 65 | echo $message = "Missing request handler for '$requestUri' . \n"; 66 | file_put_contents('php://stderr', $message); 67 | exit(255); 68 | } 69 | -------------------------------------------------------------------------------- /tests/server/ssl-wrapper.php: -------------------------------------------------------------------------------- 1 | listen($config['address'], $config['port_ssl'], dirname(realpath($configFile)) . '/' . $config['server_pem']); 13 | -------------------------------------------------------------------------------- /tests/setup.php: -------------------------------------------------------------------------------- 1 | start( 20 | Helpers::escapeArg(PHP_BINARY) . " -S $config[address]:$config[port] -d always_populate_raw_post_data=-1" . Helpers::escapeArg(__DIR__ . '/server/index.php'), 21 | __DIR__ . '/temp/http.log', 22 | __DIR__ . '/temp/http.log' 23 | ); 24 | echo "done\n"; 25 | putenv("TESTS_HTTP_LISTEN=$config[address]:$config[port]"); 26 | 27 | echo "# Starting SSL wrapper for tests on $config[address]:$config[port_ssl]... "; 28 | $wrapper = new Tests\BackgroundProcess; 29 | $wrapper->start( 30 | Helpers::escapeArg(PHP_BINARY) . ' ' . Helpers::escapeArg(__DIR__ . '/server/ssl-wrapper.php'), 31 | __DIR__ . '/temp/ssl-wrapper.log', 32 | __DIR__ . '/temp/ssl-wrapper.log' 33 | ); 34 | echo "done\n"; 35 | putenv("TESTS_HTTPS_LISTEN=$config[address]:$config[port_ssl]"); 36 | 37 | register_shutdown_function(function() use ($server, $wrapper) { 38 | echo "\n"; 39 | echo '# Shutting down SSL wrapper... '; 40 | $wrapper->terminate(); 41 | echo "done\n"; 42 | 43 | echo '# Shutting down HTTP server... '; 44 | $server->terminate(); 45 | echo "done\n"; 46 | }); 47 | 48 | } 49 | echo "\n"; 50 | --------------------------------------------------------------------------------