├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── demo.php ├── httpclient.inc.php └── src ├── Client.php ├── Connection.php ├── HeaderTrait.php ├── ParseInterface.php ├── Processor.php ├── Request.php └── Response.php /.gitattributes: -------------------------------------------------------------------------------- 1 | # export ignore 2 | tests export-ignore 3 | build-dist.php export-ignore 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Package files 2 | .idea 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2015 hightman, https://github.com/hightman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A parallel HTTP client written in pure PHP 2 | ========================================== 3 | 4 | This is a powerful HTTP client written in pure PHP code, dose not require any other 5 | PHP extension. It help you easy to send HTTP request and handle its response. 6 | 7 | - Process multiple requests in parallel 8 | - Full support for HTTP methods, including GET, POST, HEAD, ... 9 | - Customizable HTTP headers, full support for Cookie, X-Server-Ip 10 | - Follows 301/302 redirect, can set the maximum times 11 | - Supports Keep-Alive, reuse connection to the same host 12 | - Supports HTTPS with openssl 13 | - Allow to upload file via POST method 14 | - Detailed information in DEBUG mode 15 | - Free and open source, release under MIT license 16 | 17 | 18 | Requirements 19 | ------------- 20 | 21 | PHP >= 5.4.0 22 | 23 | 24 | Install 25 | ------- 26 | 27 | ### Install from an Archive File 28 | 29 | Extract the archive file downloaded from [httpclient-master.zip](https://github.com/hightman/httpclient/archive/master.zip) 30 | to your project. And then add the library file into your program: 31 | 32 | ```php 33 | require '/path/to/httpclient.inc.php'; 34 | ``` 35 | 36 | ### Install via Composer 37 | 38 | If you do not have [Composer](http://getcomposer.org/), you may install it by following the instructions 39 | at [getcomposer.org](http://getcomposer.org/doc/00-intro.md#installation-nix). 40 | 41 | You can then install this library using the following command: 42 | 43 | ~~~ 44 | php composer.phar require "hightman/httpclient:*" 45 | ~~~ 46 | 47 | 48 | Usage 49 | ------- 50 | 51 | ### Quick to use 52 | 53 | 54 | We have defined some shortcut methods, they can be used as following: 55 | 56 | ```php 57 | use hightman\http\Client; 58 | 59 | $http = new Client(); 60 | 61 | // 1. display response contents 62 | echo $http->get('http://www.baidu.com'); 63 | echo $http->get('http://www.baidu.com/s', ['wd' => 'php']); 64 | 65 | // 2. capture the response object, read the meta information 66 | $res = $http->get('http://www.baidu.com'); 67 | print_r($res->getHeader('content-type')); 68 | print_r($res->getCookie(null)); 69 | 70 | // 3. post request 71 | $res = $http->post('http://www.your.host/', ['field1' => 'value1', 'field2' => 'value2']); 72 | if (!$res->hasError()) { 73 | echo $res->body; // response content 74 | echo $res->status; // response status code 75 | } 76 | 77 | // 4. head request 78 | $res = $http->head('http://www.baidu.com'); 79 | print_r($res->getHeader(null)); 80 | 81 | // delete request 82 | $res = $http->delete('http://www.your.host/request/uri'); 83 | 84 | // 5. restful json requests 85 | // there are sismilar api like: postJson, putJson 86 | $data = $http->getJson('http://www.your.host/request/uri'); 87 | print_r($data); 88 | 89 | $data = $http->postJson('http://www.your.host/reqeust/uri', ['key1' => 'value1', 'key2' => 'value2']); 90 | 91 | ``` 92 | 93 | ### Customize request 94 | 95 | You can also customize various requests by passing in `Request` object. 96 | 97 | ```php 98 | use hightman\http\Client; 99 | use hightman\http\Request; 100 | 101 | $http = new Client(); 102 | $request = new Request('http://www.your.host/request/uri'); 103 | 104 | // set method 105 | $request->setMethod('POST'); 106 | // add headers 107 | $request->setHeader('user-agent', 'test robot'); 108 | 109 | // specify host ip, this will skip DNS resolver 110 | $request->setHeader('x-server-ip', '1.2.3.4'); 111 | 112 | // add post fields 113 | $request->addPostField('name', 'value'); 114 | $request->addPostFile('upload', '/path/to/file'); 115 | $request->addPostFile('upload_virtual', 'virtual.text', 'content of file ...'); 116 | 117 | // or you can specify request body directly 118 | $request->setBody('request body ...'); 119 | 120 | // you also can specify JSON data as request body 121 | // this will set content-type header to 'application/json' automatically. 122 | $request->setJsonBody(['key' => 'value']); 123 | 124 | // specify context options of connect, such as SSL options 125 | $request->contextOptions = [ 126 | 'ssl' => ['verify_peer_name' => false, 'local_cert' => '/path/to/file.pem'], 127 | ]; 128 | 129 | // execute the request 130 | $response = $http->exec($request); 131 | print_r($response); 132 | 133 | ``` 134 | 135 | 136 | ### Multiple get in parallel 137 | 138 | A great features of this library is that we can execute multiple requests in parallel. 139 | For example, executed three requests simultaneously, the total time spent is one of the longest, 140 | rather than their sum. 141 | 142 | 143 | ```php 144 | use hightman\http\Client; 145 | use hightman\http\Request; 146 | use hightman\http\Response; 147 | 148 | // Define callback as function, its signature: 149 | // (callback) (Response $res, Request $req, string|integer $key); 150 | function test_cb($res, $req, $key) 151 | { 152 | echo '[' . $key . '] url: ' . $req->getUrl() . ', '; 153 | echo 'time cost: ' . $res->timeCost . ', size: ' . number_format(strlen($res->body)) . "\n"; 154 | } 155 | 156 | // or you can define callback as a class implemented interface `ParseInterface`. 157 | class testCb implements \hightman\http\ParseInterface 158 | { 159 | public function parse(Response $res, Request $req, $key) 160 | { 161 | // your code here ... 162 | } 163 | } 164 | 165 | // create client object with callback parser 166 | $http = new \hightman\http\Client('test_cb'); 167 | 168 | // or specify later as following 169 | $http->setParser(new testCb); 170 | 171 | // Fetch multiple URLs, it returns after all requests are finished. 172 | // It may be slower for the first time, because of DNS resolover. 173 | $results = $http->mget([ 174 | 'baidu' => 'http://www.baidu.com/', 175 | 'sina' => 'http://news.sina.com.cn/', 176 | 'qq' => 'http://www.qq.com/', 177 | ]); 178 | 179 | // show all results 180 | // print_r($results); 181 | 182 | ``` 183 | 184 | > Note: There are other methods like: mhead, mpost, mput ... 185 | > If you need handle multiple different requests, you can pass an array of `Request` 186 | > objects into `Client::exec($reqs)`. 187 | 188 | 189 | ### Export and reused cookies 190 | 191 | 192 | This library can intelligently manage cookies, default store cookies in memory and send them on need. 193 | We can export all cookies after `Client` object destoried. 194 | 195 | ```php 196 | $http->setCookiePath('/path/to/file'); 197 | ``` 198 | 199 | ### Add bearer authorization token 200 | 201 | ```php 202 | $http->setHeader('authorization', 'Bearer ' . $token); 203 | // or add header for request object 204 | $request->setHeader('authorization', 'Bearer ' . $token); 205 | ``` 206 | 207 | ### Use proxy 208 | 209 | ```php 210 | // use socks5 211 | Connection::useProxy('socks5://127.0.0.1:1080'); 212 | // use socks5 with username & password 213 | Connection::useProxy('socks5://user:pass@127.0.0.1:1080'); 214 | // use HTTP proxy 215 | Connection::useProxy('http://127.0.0.1:8080'); 216 | // use HTTP proxy with basic authentication 217 | Connection::useProxy('http://user:pass@127.0.0.1:8080'); 218 | // use socks4 proxy 219 | Connection::useProxy('socks4://127.0.0.1:1080'); 220 | // disable socks 221 | Connection::useProxy(null); 222 | ``` 223 | 224 | 225 | ### Enable debug mode 226 | 227 | You can turn on debug mode via `Client::debug('open')`. 228 | This will display many debug messages to help you find out problem. 229 | 230 | 231 | ### Others 232 | 233 | Because of `Client` class also `use HeaderTrait`, you can use `Client::setHeader()` 234 | to specify global HTTP headers for requests handled by this client object. 235 | 236 | 237 | Contact me 238 | ----------- 239 | 240 | If you have any questions, please report on github [issues](https://github.com/hightman/httpclient/issues) 241 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hightman/httpclient", 3 | "description": "A parallel HTTP client written in pure PHP", 4 | "keywords": ["http client", "curl", "php", "rest"], 5 | "homepage": "http://hightman.cn/", 6 | "type": "library", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "hightman", 11 | "email": "hightman@twomice.net", 12 | "homoepage": "http://hightman.cn/" 13 | } 14 | ], 15 | "require": { 16 | "php": ">=5.4.0" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "hightman\\http\\": "src/" 21 | } 22 | }, 23 | "extra": { 24 | "branch-alias": { 25 | "dev-master": "1.x-dev" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo.php: -------------------------------------------------------------------------------- 1 | 6 | * @link http://hightman.cn 7 | * @copyright Copyright (c) 2015 Twomice Studio. 8 | */ 9 | 10 | // import library file 11 | require_once 'httpclient.inc.php'; 12 | 13 | use hightman\http\Client; 14 | use hightman\http\Request; 15 | use hightman\http\Response; 16 | 17 | // create client instance 18 | $http = new Client(); 19 | 20 | // set cookie file 21 | $http->setCookiePath('cookie.dat'); 22 | 23 | // add text/plain header for web sapi handler 24 | if (php_sapi_name() !== 'cli') { 25 | header('Content-Type: text/plain'); 26 | } 27 | 28 | // simple load response contents 29 | echo '1. loading content of baidu ... '; 30 | $response = $http->get('http://www.baidu.com'); 31 | echo number_format(strlen($response)) . ' bytes', PHP_EOL; 32 | 33 | echo '2. fetching search results of `php\' in baidu ... '; 34 | $response = $http->get('http://www.baidu.com/s', ['wd' => 'php']); 35 | echo number_format(strlen($response)) . ' bytes', PHP_EOL; 36 | echo ' time costs: ', $response->timeCost, PHP_EOL; 37 | echo ' response.status: ', $response->status, ' ', $response->statusText, PHP_EOL; 38 | echo ' response.headers.content-type: ', $response->getHeader('content-type'), PHP_EOL; 39 | echo ' response.headers.transfer-encoding: ', $response->getHeader('transfer-encoding'), PHP_EOL; 40 | echo ' response.cookies.BDSVRTM: ', $response->getCookie('BDSVRTM'), PHP_EOL; 41 | 42 | echo '3. testing error response ... '; 43 | $response = $http->get('http://127.0.0.1:65535'); 44 | echo $response->hasError() ? 'ERROR: ' . $response->error : 'OK', PHP_EOL; 45 | 46 | echo '4. test head request to baidu ... '; 47 | $response = $http->head('http://www.baidu.com'); 48 | echo number_format(strlen($response)) . ' bytes', PHP_EOL; 49 | echo ' response.headers.server: ', $response->getHeader('server'), PHP_EOL; 50 | 51 | echo '5. post request to baidu ... '; 52 | //Client::debug('open'); 53 | $response = $http->post('http://www.baidu.com/s', ['wd' => 'php', 'ie' => 'utf-8']); 54 | echo number_format(strlen($response)) . ' bytes', PHP_EOL; 55 | if ($response->hasError()) { 56 | echo ' response.error: ', $response->error, PHP_EOL; 57 | } 58 | 59 | echo '6. testing postJSON request ...'; 60 | $data = $http->postJson('http://api.mcloudlife.com/api/version'); 61 | echo 'OK', PHP_EOL; 62 | echo ' response.json: ', json_encode($data), PHP_EOL; 63 | 64 | echo '7. customize request for restful API ... '; 65 | $request = new Request('http://api.mcloudlife.com/open/record/bp/1024'); 66 | $request->setMethod('GET'); 67 | $request->setHeader('user-agent', 'mCloud/2.4.3D'); 68 | // bearer token authorization 69 | $request->setHeader('authorization', 'Bearer f4fe27fe5f270a4e8edc1a07289452d1'); 70 | $request->setHeader('accept', 'application/json'); 71 | $response = $http->exec($request); 72 | if ($response->hasError()) { 73 | echo 'ERROR', PHP_EOL; 74 | echo ' response.error: ', $response->error, PHP_EOL; 75 | } else { 76 | echo 'OK', PHP_EOL; 77 | echo ' response.status: ', $response->status, ' ', $response->statusText, PHP_EOL; 78 | echo ' response.body: ', $response->body, PHP_EOL; 79 | } 80 | 81 | echo '8. post request & upload files ... '; 82 | $request = new Request('http://hightman.cn/post.php'); 83 | $request->setMethod('POST'); 84 | $request->setHeader('x-server-ip', '202.75.216.234'); 85 | $request->addPostField('post1', 'post1-value'); 86 | $request->addPostField('post2', 'post2-value'); 87 | $request->addPostFile('upload1', 'upload1-name.txt', 'hi, just a test'); 88 | $request->addPostFile('upload2', __FILE__); 89 | $response = $http->exec($request); 90 | echo number_format(strlen($response)) . ' bytes', PHP_EOL; 91 | echo $response, PHP_EOL; 92 | 93 | echo '9. multiple get requests in parallel ... ', PHP_EOL; 94 | 95 | // define callback as normal function 96 | function test_cb($res, $req, $key) 97 | { 98 | echo ' ', $req->getUrl(), ', ', number_format(strlen($res)), ' bytes in ', sprintf('%.4f', $res->timeCost), 's', PHP_EOL; 99 | // even you can redirect HERE 100 | if ($key === 'baidu' && !strstr($req->getUrl(), 'czxiu')) { 101 | $res->redirect('http://www.czxiu.com'); 102 | } 103 | } 104 | 105 | $time9 = microtime(true); 106 | $http->setParser('test_cb'); 107 | $responses = $http->mget([ 108 | 'baidu' => 'http://www.baidu.com', 109 | 'sina' => 'http://news.sina.com.cn', 110 | 'qq' => 'http://www.qq.com', 111 | ]); 112 | echo ' >> total time cost: ', round(microtime(true) - $time9, 4), 's', PHP_EOL; 113 | 114 | echo '10. process multiple various requests in parallel ... ', PHP_EOL; 115 | 116 | // define callback as object 117 | class testCb implements \hightman\http\ParseInterface 118 | { 119 | public function parse(Response $res, Request $req, $key) 120 | { 121 | echo ' ', $req->getMethod(), ' /', $key, ' finished, ', number_format(strlen($res)), ' bytes in ', sprintf('%.4f', $res->timeCost), 's', PHP_EOL; 122 | } 123 | } 124 | 125 | // construct requests 126 | $requests = []; 127 | $requests['version'] = new Request('http://api.mcloudlife.com/api/version', 'POST'); 128 | $requests['baidu'] = new Request('http://www.baidu.com/s?wd=php'); 129 | 130 | $request = new Request('http://api.mcloudlife.com/open/auth/token'); 131 | $request->setMethod('POST'); 132 | $request->setHeader('accept', 'application/json'); 133 | $request->setJsonBody([ 134 | 'client_id' => 'client_id', 135 | 'client_secret' => 'client_secret', 136 | ]); 137 | $requests['token'] = $request; 138 | 139 | $http->setParser(new testCb()); 140 | $responses = $http->exec($requests); 141 | -------------------------------------------------------------------------------- /httpclient.inc.php: -------------------------------------------------------------------------------- 1 | 8 | * @link http://hightman.cn 9 | * @copyright Copyright (c) 2015 Twomice Studio. 10 | */ 11 | 12 | require_once __DIR__ . '/src/ParseInterface.php'; 13 | require_once __DIR__ . '/src/HeaderTrait.php'; 14 | require_once __DIR__ . '/src/Client.php'; 15 | require_once __DIR__ . '/src/Connection.php'; 16 | require_once __DIR__ . '/src/Response.php'; 17 | require_once __DIR__ . '/src/Request.php'; 18 | require_once __DIR__ . '/src/Processor.php'; 19 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | 6 | * @link http://hightman.cn 7 | * @copyright Copyright (c) 2015 Twomice Studio. 8 | */ 9 | 10 | namespace hightman\http; 11 | 12 | /** 13 | * Http client 14 | * 15 | * @author hightman 16 | * @since 1.0 17 | */ 18 | class Client 19 | { 20 | use HeaderTrait; 21 | const PACKAGE = __CLASS__; 22 | const VERSION = '1.x-beta'; 23 | const CRLF = "\r\n"; 24 | 25 | /** 26 | * @var int the maximum number of concurrent connections for same host and port pair. 27 | */ 28 | public static $maxBurst = 10; 29 | 30 | private $_cookiePath, $_parser, $_timeout; 31 | private static $_debugOpen = false; 32 | private static $_processKey; 33 | 34 | /** 35 | * Open/close debug mode 36 | * @param string $msg 37 | */ 38 | public static function debug($msg) 39 | { 40 | if ($msg === 'open' || $msg === 'close') { 41 | self::$_debugOpen = $msg === 'open'; 42 | } elseif (self::$_debugOpen === true) { 43 | $key = self::$_processKey === null ? '' : '[' . self::$_processKey . '] '; 44 | echo '[DEBUG] ', date('H:i:s '), $key, implode('', func_get_args()), self::CRLF; 45 | } 46 | } 47 | 48 | /** 49 | * Decompress data 50 | * @param string $data compressed string 51 | * @return string result string 52 | */ 53 | public static function gzdecode($data) 54 | { 55 | return gzinflate(substr($data, 10, -8)); 56 | } 57 | 58 | /** 59 | * Constructor 60 | * @param callable $p response parse handler 61 | */ 62 | public function __construct($p = null) 63 | { 64 | $this->applyDefaultHeader(); 65 | $this->setParser($p); 66 | } 67 | 68 | /** 69 | * Destructor 70 | * Export and save all cookies. 71 | */ 72 | public function __destruct() 73 | { 74 | if ($this->_cookiePath !== null) { 75 | $this->saveCookie($this->_cookiePath); 76 | } 77 | } 78 | 79 | /** 80 | * Set the max network read timeout 81 | * @param float $sec seconds, decimal support 82 | */ 83 | public function setTimeout($sec) 84 | { 85 | $this->_timeout = floatval($sec); 86 | } 87 | 88 | /** 89 | * Set cookie storage path 90 | * If set, all cookies will be saved into this file, and send to request on need. 91 | * @param string $file file path to store cookies. 92 | */ 93 | public function setCookiePath($file) 94 | { 95 | $this->_cookiePath = $file; 96 | $this->loadCookie($file); 97 | } 98 | 99 | /** 100 | * Set response parse handler 101 | * @param callable $p parse handler 102 | */ 103 | public function setParser($p) 104 | { 105 | if ($p === null || $p instanceof ParseInterface || is_callable($p)) { 106 | $this->_parser = $p; 107 | } 108 | } 109 | 110 | /** 111 | * Run parse handler 112 | * @param Response $res response object 113 | * @param Request $req request object 114 | * @param mixed $key the key string of multi request 115 | */ 116 | public function runParser($res, $req, $key = null) 117 | { 118 | if ($this->_parser !== null) { 119 | self::debug('run parser: ', $req->getRawUrl()); 120 | if ($this->_parser instanceof ParseInterface) { 121 | $this->_parser->parse($res, $req, $key); 122 | } else { 123 | call_user_func($this->_parser, $res, $req, $key); 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * Clear headers and apply defaults. 130 | */ 131 | public function clearHeader() 132 | { 133 | $this->_headers = []; 134 | $this->applyDefaultHeader(); 135 | } 136 | 137 | /** 138 | * Shortcut of HEAD request 139 | * @param string $url request URL string. 140 | * @param array $params query params appended to URL. 141 | * @return Response result response object. 142 | */ 143 | public function head($url, $params = []) 144 | { 145 | if (is_array($url)) { 146 | return $this->mhead($url, $params); 147 | } 148 | return $this->exec($this->buildRequest('HEAD', $url, $params)); 149 | } 150 | 151 | /** 152 | * Shortcut of HEAD multiple requests in parallel 153 | * @param array $urls request URL list. 154 | * @param array $params query params appended to each URL. 155 | * @return Response[] result response objects associated with key of URL. 156 | */ 157 | public function mhead($urls, $params = []) 158 | { 159 | return $this->exec($this->buildRequests('HEAD', $urls, $params)); 160 | } 161 | 162 | /** 163 | * Shortcut of GET request 164 | * @param string $url request URL string. 165 | * @param array $params extra query params, appended to URL. 166 | * @return Response result response object. 167 | */ 168 | public function get($url, $params = []) 169 | { 170 | if (is_array($url)) { 171 | return $this->mget($url, $params); 172 | } 173 | return $this->exec($this->buildRequest('GET', $url, $params)); 174 | } 175 | 176 | /** 177 | * Shortcut of GET multiple requests in parallel 178 | * @param array $urls request URL list. 179 | * @param array $params query params appended to each URL. 180 | * @return Response[] result response objects associated with key of URL. 181 | */ 182 | public function mget($urls, $params = []) 183 | { 184 | return $this->exec($this->buildRequests('GET', $urls, $params)); 185 | } 186 | 187 | /** 188 | * Shortcut of DELETE request 189 | * @param string $url request URL string. 190 | * @param array $params extra query params, appended to URL. 191 | * @return Response result response object. 192 | */ 193 | public function delete($url, $params = []) 194 | { 195 | if (is_array($url)) { 196 | return $this->mdelete($url, $params); 197 | } 198 | return $this->exec($this->buildRequest('DELETE', $url, $params)); 199 | } 200 | 201 | /** 202 | * Shortcut of DELETE multiple requests in parallel 203 | * @param array $urls request URL list. 204 | * @param array $params query params appended to each URL. 205 | * @return Response[] result response objects associated with key of URL. 206 | */ 207 | public function mdelete($urls, $params = []) 208 | { 209 | return $this->exec($this->buildRequests('DELETE', $urls, $params)); 210 | } 211 | 212 | /** 213 | * Shortcut of POST request 214 | * @param string|Request $url request URL string, or request object. 215 | * @param array $params post fields. 216 | * @return Response result response object. 217 | */ 218 | public function post($url, $params = []) 219 | { 220 | $req = $url instanceof Request ? $url : $this->buildRequest('POST', $url); 221 | foreach ($params as $key => $value) { 222 | $req->addPostField($key, $value); 223 | } 224 | return $this->exec($req); 225 | } 226 | 227 | /** 228 | * Shortcut of PUT request 229 | * @param string|Request $url request URL string, or request object. 230 | * @param string $content content to be put. 231 | * @return Response result response object. 232 | */ 233 | public function put($url, $content = '') 234 | { 235 | $req = $url instanceof Request ? $url : $this->buildRequest('PUT', $url); 236 | $req->setBody($content); 237 | return $this->exec($req); 238 | } 239 | 240 | /** 241 | * Shortcut of GET restful request as json format 242 | * @param string $url request URL string. 243 | * @param array $params extra query params, appended to URL. 244 | * @return array result json data, or false on failure. 245 | */ 246 | public function getJson($url, $params = []) 247 | { 248 | $req = $this->buildRequest('GET', $url, $params); 249 | $req->setHeader('accept', 'application/json'); 250 | $res = $this->exec($req); 251 | return $res === false ? false : $res->getJson(); 252 | } 253 | 254 | /** 255 | * Shortcut of POST restful request as json format 256 | * @param string $url request URL string. 257 | * @param array $params request json data to be post. 258 | * @return array result json data, or false on failure. 259 | */ 260 | public function postJson($url, $params = []) 261 | { 262 | $req = $this->buildRequest('POST', $url); 263 | $req->setHeader('accept', 'application/json'); 264 | $req->setJsonBody($params); 265 | $res = $this->exec($req); 266 | return $res === false ? false : $res->getJson(); 267 | } 268 | 269 | /** 270 | * Shortcut of PUT restful request as json format 271 | * @param string $url request URL string. 272 | * @param array $params request json data to be put. 273 | * @return array result json data, or false on failure. 274 | */ 275 | public function putJson($url, $params = []) 276 | { 277 | $req = $this->buildRequest('PUT', $url); 278 | $req->setHeader('accept', 'application/json'); 279 | $req->setJsonBody($params); 280 | $res = $this->exec($req); 281 | return $res === false ? false : $res->getJson(); 282 | } 283 | 284 | /** 285 | * Execute http requests 286 | * @param Request|Request[] $req the request object, or array of multiple requests 287 | * @return Response|Response[] result response object, or response array for multiple requests 288 | */ 289 | public function exec($req) 290 | { 291 | // build recs 292 | $recs = []; 293 | if ($req instanceof Request) { 294 | $recs[] = new Processor($this, $req); 295 | } elseif (is_array($req)) { 296 | foreach ($req as $key => $value) { 297 | if ($value instanceof Request) { 298 | $recs[$key] = new Processor($this, $value, $key); 299 | } 300 | } 301 | } 302 | if (count($recs) === 0) { 303 | return false; 304 | } 305 | // loop to process 306 | while (true) { 307 | // build select fds 308 | $rfds = $wfds = $xrec = []; 309 | $xfds = null; 310 | foreach ($recs as $rec) { 311 | /* @var $rec Processor */ 312 | self::$_processKey = $rec->key; 313 | if ($rec->finished || !($conn = $rec->getConn())) { 314 | continue; 315 | } 316 | if ($this->_timeout !== null) { 317 | $xrec[] = $rec; 318 | } 319 | $rfds[] = $conn->getSock(); 320 | if ($conn->hasDataToWrite()) { 321 | $wfds[] = $conn->getSock(); 322 | } 323 | } 324 | self::$_processKey = null; 325 | if (count($rfds) === 0 && count($wfds) === 0) { 326 | // all tasks finished 327 | break; 328 | } 329 | // select sockets 330 | self::debug('stream_select(rfds[', count($rfds), '], wfds[', count($wfds), ']) ...'); 331 | if ($this->_timeout === null) { 332 | $num = stream_select($rfds, $wfds, $xfds, null); 333 | } else { 334 | $sec = intval($this->_timeout); 335 | $usec = intval(($this->_timeout - $sec) * 1000000); 336 | $num = stream_select($rfds, $wfds, $xfds, $sec, $usec); 337 | } 338 | self::debug('select result: ', $num === false ? 'false' : $num); 339 | if ($num === false) { 340 | trigger_error('stream_select() error', E_USER_WARNING); 341 | break; 342 | } elseif ($num > 0) { 343 | // rfds 344 | foreach ($rfds as $sock) { 345 | if (!($conn = Connection::findBySock($sock))) { 346 | continue; 347 | } 348 | $rec = $conn->getExArg(); 349 | /* @var $rec Processor */ 350 | self::$_processKey = $rec->key; 351 | $rec->recv(); 352 | } 353 | // wfds 354 | foreach ($wfds as $sock) { 355 | if (!($conn = Connection::findBySock($sock))) { 356 | continue; 357 | } 358 | $rec = $conn->getExArg(); 359 | /* @var $rec Processor */ 360 | self::$_processKey = $rec->key; 361 | $rec->send(); 362 | } 363 | } else { 364 | // force to close request 365 | foreach ($xrec as $rec) { 366 | self::$_processKey = $rec->key; 367 | $rec->finish('TIMEOUT'); 368 | } 369 | } 370 | } 371 | // return value 372 | if (!is_array($req)) { 373 | $ret = $recs[0]->res; 374 | } else { 375 | $ret = []; 376 | foreach ($recs as $key => $rec) { 377 | $ret[$key] = $rec->res; 378 | } 379 | } 380 | return $ret; 381 | } 382 | 383 | /** 384 | * Build a http request 385 | * @param string $method 386 | * @param string $url 387 | * @param array $params 388 | * @return Request 389 | */ 390 | protected function buildRequest($method, $url, $params = []) 391 | { 392 | if (count($params) > 0) { 393 | $url .= strpos($url, '?') === false ? '?' : '&'; 394 | $url .= http_build_query($params); 395 | } 396 | return new Request($url, $method); 397 | } 398 | 399 | /** 400 | * Build multiple http requests 401 | * @param string $method 402 | * @param array $urls 403 | * @param array $params 404 | * @return Request[] 405 | */ 406 | protected function buildRequests($method, $urls, $params = []) 407 | { 408 | $reqs = []; 409 | foreach ($urls as $key => $url) { 410 | $reqs[$key] = $this->buildRequest($method, $url, $params); 411 | } 412 | return $reqs; 413 | } 414 | 415 | /** 416 | * @return string default user-agent 417 | */ 418 | protected function defaultAgent() 419 | { 420 | $agent = 'Mozilla/5.0 (Compatible; ' . self::PACKAGE . '/' . self::VERSION . ') '; 421 | $agent .= 'php-' . php_sapi_name() . '/' . phpversion() . ' '; 422 | $agent .= php_uname('s') . '/' . php_uname('r'); 423 | return $agent; 424 | } 425 | 426 | /** 427 | * Default HTTP headers 428 | */ 429 | protected function applyDefaultHeader() 430 | { 431 | $this->setHeader([ 432 | 'accept' => '*/*', 433 | 'accept-language' => 'zh-cn,zh', 434 | 'connection' => 'Keep-Alive', 435 | 'user-agent' => $this->defaultAgent(), 436 | ]); 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | 6 | * @link http://hightman.cn 7 | * @copyright Copyright (c) 2015 Twomice Studio. 8 | */ 9 | 10 | namespace hightman\http; 11 | 12 | /** 13 | * Connection manager 14 | * 15 | * @author hightman 16 | * @since 1.0 17 | */ 18 | class Connection 19 | { 20 | /** 21 | * The connection socket flags 22 | */ 23 | const FLAG_NEW = 0x01; 24 | const FLAG_NEW2 = 0x02; 25 | const FLAG_BUSY = 0x04; 26 | const FLAG_OPENED = 0x08; 27 | const FLAG_REUSED = 0x10; 28 | const FLAG_SELECT = 0x20; 29 | 30 | protected $outBuf, $outLen; 31 | protected $arg, $sock, $conn, $flag = 0; 32 | private static $_objs = []; 33 | private static $_refs = []; 34 | private static $_lastError; 35 | 36 | /** 37 | * @var int proxy state 38 | */ 39 | public $proxyState = 0; 40 | 41 | /** 42 | * @var array proxy setting 43 | */ 44 | private static $_proxy = null; 45 | 46 | /** 47 | * Set socks5 proxy server 48 | * @param string $host proxy server address, passed null to disable 49 | * @param int $port proxy server port, default to 1080 50 | * @param string $user authentication username 51 | * @param string $pass authentication password 52 | * @deprecated use `useProxy` instead 53 | */ 54 | public static function useSocks5($host, $port = 1080, $user = null, $pass = null) 55 | { 56 | $url = 'socks5://'; 57 | if ($user !== null && $pass !== null) { 58 | $url .= $user . ':' . $pass . '@'; 59 | } 60 | $url .= $host . ':' . $port; 61 | self::useProxy($url); 62 | } 63 | 64 | /** 65 | * Proxy setting 66 | * - socks5 with authentication: socks5://user:pass@127.0.0.1:1080 67 | * - socks4: socks4://127.0.0.1:1080 68 | * - http with authentication: http://user:pass@127.0.0.1:8080 69 | * - http without authentication: 127.0.0.1:1080 70 | * @param string $url proxy setting URL 71 | */ 72 | public static function useProxy($url) 73 | { 74 | self::$_proxy = null; 75 | if (!empty($url)) { 76 | $pa = parse_url($url); 77 | if (!isset($pa['scheme'])) { 78 | $pa['scheme'] = 'http'; 79 | } else { 80 | $pa['scheme'] = strtolower($pa['scheme']); 81 | } 82 | if (!isset($pa['port'])) { 83 | $pa['port'] = substr($pa['scheme'], 0, 5) === 'socks' ? 1080 : 80; 84 | } 85 | if (isset($pa['user']) && !isset($pa['pass'])) { 86 | $pa['pass'] = ''; 87 | } 88 | if ($pa['scheme'] === 'tcp' || $pa['scheme'] === 'https') { 89 | $pa['scheme'] = 'http'; 90 | } 91 | if ($pa['scheme'] === 'socks') { 92 | $pa['scheme'] = isset($pa['user']) ? 'socks5' : 'socks4'; 93 | } 94 | if (in_array($pa['scheme'], ['http', 'socks4', 'socks5'])) { 95 | self::$_proxy = $pa; 96 | Client::debug('use proxy: ', $url); 97 | } else { 98 | Client::debug('invalid proxy url: ', $url); 99 | } 100 | } 101 | } 102 | 103 | /** 104 | * Create connection, with built-in pool. 105 | * @param string $conn connection string, like `protocol://host:port`. 106 | * @param mixed $arg external argument, fetched by `[[getExArg()]]` 107 | * @return static the connection object, null if it reaches the upper limit of concurrent, or false on failure. 108 | */ 109 | public static function connect($conn, $arg = null) 110 | { 111 | $obj = null; 112 | if (!isset(self::$_objs[$conn])) { 113 | self::$_objs[$conn] = []; 114 | } 115 | foreach (self::$_objs[$conn] as $tmp) { 116 | if (!($tmp->flag & self::FLAG_BUSY)) { 117 | Client::debug('reuse conn \'', $tmp->conn, '\': ', $tmp->sock); 118 | $obj = $tmp; 119 | break; 120 | } 121 | } 122 | if ($obj === null && count(self::$_objs[$conn]) < Client::$maxBurst) { 123 | $obj = new self($conn); 124 | self::$_objs[$conn][] = $obj; 125 | Client::debug('create conn \'', $conn, '\''); 126 | } 127 | if ($obj !== null) { 128 | $obj->arg = $arg; 129 | if ($obj->flag & self::FLAG_OPENED) { 130 | $obj->flag |= self::FLAG_REUSED; 131 | } else { 132 | if (!$obj->openSock()) { 133 | return false; 134 | } 135 | } 136 | $obj->flag |= self::FLAG_BUSY; 137 | $obj->outBuf = null; 138 | $obj->outLen = 0; 139 | } 140 | return $obj; 141 | } 142 | 143 | /** 144 | * Find connection object by socket, used after stream_select() 145 | * @param resource $sock 146 | * @return Connection the connection object or null if not found. 147 | */ 148 | public static function findBySock($sock) 149 | { 150 | $sock = strval($sock); 151 | return isset(self::$_refs[$sock]) ? self::$_refs[$sock] : null; 152 | } 153 | 154 | /** 155 | * Get last error 156 | * @return string the last error message. 157 | */ 158 | public static function getLastError() 159 | { 160 | return self::$_lastError; 161 | } 162 | 163 | /** 164 | * Close the connection 165 | * @param boolean $realClose whether to shutdown the connection, default is added to the pool for next request. 166 | */ 167 | public function close($realClose = false) 168 | { 169 | $this->arg = null; 170 | $this->flag &= ~self::FLAG_BUSY; 171 | if ($realClose === true) { 172 | Client::debug('close conn \'', $this->conn, '\': ', $this->sock); 173 | $this->flag &= ~self::FLAG_OPENED; 174 | @fclose($this->sock); 175 | $this->delSockRef(); 176 | $this->sock = false; 177 | } else { 178 | Client::debug('free conn \'', $this->conn, '\': ', $this->sock); 179 | } 180 | } 181 | 182 | /** 183 | * Append writing cache 184 | * @param $buf string data content. 185 | */ 186 | public function addWriteData($buf) 187 | { 188 | if ($this->outBuf === null) { 189 | $this->outBuf = $buf; 190 | } else { 191 | $this->outBuf .= $buf; 192 | } 193 | } 194 | 195 | /** 196 | * @return boolean if there is data to be written. 197 | */ 198 | public function hasDataToWrite() 199 | { 200 | if ($this->proxyState > 0) { 201 | return $this->proxyState & 1 ? true : false; 202 | } 203 | return ($this->outBuf !== null && strlen($this->outBuf) > $this->outLen); 204 | } 205 | 206 | /** 207 | * Write data to socket 208 | * @param string $buf the string to be written, passing null to flush cache. 209 | * @return mixed the number of bytes were written, 0 if the buffer is full, or false on error. 210 | */ 211 | public function write($buf = null) 212 | { 213 | if ($buf === null) { 214 | if ($this->proxyState > 0) { 215 | return $this->proxyWrite(); 216 | } 217 | $len = 0; 218 | if ($this->hasDataToWrite()) { 219 | $buf = $this->outLen > 0 ? substr($this->outBuf, $this->outLen) : $this->outBuf; 220 | $len = $this->write($buf); 221 | if ($len !== false) { 222 | $this->outLen += $len; 223 | } 224 | } 225 | return $len; 226 | } 227 | $n = @fwrite($this->sock, $buf); 228 | Client::debug('write data to socket: ', strlen($buf), ' = ', $n === false ? 'false' : $n); 229 | if ($n === 0 && $this->ioEmptyError()) { 230 | $n = false; 231 | } 232 | $this->ioFlagReset(); 233 | return $n; 234 | } 235 | 236 | /** 237 | * Read one line (not contains \r\n at the end) 238 | * @return mixed line string, null when has not data, or false on error. 239 | */ 240 | public function getLine() 241 | { 242 | $line = @stream_get_line($this->sock, 2048, "\n"); 243 | if ($line === '' || $line === false) { 244 | $line = $this->ioEmptyError() ? false : null; 245 | } else { 246 | $line = rtrim($line, "\r"); 247 | } 248 | $this->ioFlagReset(); 249 | return $line; 250 | } 251 | 252 | /** 253 | * Read data from socket 254 | * @param int $size the max number of bytes to be read. 255 | * @return mixed the read string, null when has not data, or false on error. 256 | */ 257 | public function read($size = 8192) 258 | { 259 | $buf = @fread($this->sock, $size); 260 | if ($buf === '' || $buf === false) { 261 | $buf = $this->ioEmptyError() ? false : null; 262 | } 263 | $this->ioFlagReset(); 264 | return $buf; 265 | } 266 | 267 | /** 268 | * Read data for proxy communication 269 | * @return bool 270 | */ 271 | public function proxyRead() 272 | { 273 | $proxyState = $this->proxyState; 274 | Client::debug(self::$_proxy['scheme'], ' proxy readState: ', $proxyState); 275 | if (self::$_proxy['scheme'] === 'http') { 276 | while (($line = $this->getLine()) !== null) { 277 | if ($line === false) { 278 | return false; 279 | } 280 | if ($line === '') { 281 | $this->proxyState = 0; 282 | break; 283 | } 284 | $this->proxyState++; 285 | Client::debug('read http proxy line: ', $line); 286 | if (!strncmp('HTTP/', $line, 5)) { 287 | $line = trim(substr($line, strpos($line, ' '))); 288 | if (intval($line) !== 200) { 289 | self::$_lastError = 'Proxy response error: ' . $line; 290 | return false; 291 | } 292 | } 293 | } 294 | } elseif (self::$_proxy['scheme'] === 'socks4') { 295 | if ($proxyState === 2) { 296 | $buf = $this->read(8); 297 | if (substr($buf, 0, 2) === "\x00\x5A") { 298 | $this->proxyState = 0; 299 | } 300 | } 301 | } elseif (self::$_proxy['scheme'] === 'socks5') { 302 | if ($proxyState === 2) { 303 | $buf = $this->read(2); 304 | if ($buf === "\x05\x00") { 305 | $this->proxyState = 5; 306 | } elseif ($buf === "\x05\x02") { 307 | $this->proxyState = 3; 308 | } 309 | } elseif ($proxyState === 4) { 310 | $buf = $this->read(2); 311 | if ($buf === "\x01\x00") { 312 | $this->proxyState = 5; 313 | } 314 | } elseif ($proxyState === 6) { 315 | $buf = $this->read(10); 316 | if (substr($buf, 0, 4) === "\x05\x00\x00\x01") { 317 | $this->proxyState = 0; 318 | } 319 | } 320 | } 321 | if ($proxyState === $this->proxyState) { 322 | self::$_lastError = 'Proxy response error: state=' . $proxyState; 323 | if (isset($buf)) { 324 | $unpack = unpack('H*', $buf); 325 | self::$_lastError .= ', buf=' . $unpack[1]; 326 | } 327 | return false; 328 | } else { 329 | if ($this->proxyState === 0 && !strncmp($this->conn, 'ssl:', 4)) { 330 | Client::debug('enable crypto via proxy tunnel'); 331 | if ($this->enableCrypto() !== true) { 332 | self::$_lastError = 'Enable crypto error: ' . self::lastPhpError(); 333 | return false; 334 | } 335 | } 336 | return true; 337 | } 338 | } 339 | 340 | /** 341 | * Write data for proxy communication 342 | * @return mixed 343 | */ 344 | public function proxyWrite() 345 | { 346 | Client::debug(self::$_proxy['scheme'], ' proxy writeState: ', $this->proxyState); 347 | if (self::$_proxy['scheme'] === 'http') { 348 | if ($this->proxyState === 1) { 349 | $pa = parse_url($this->conn); 350 | $buf = 'CONNECT ' . $pa['host'] . ':' . (isset($pa['port']) ? $pa['port'] : 80) . ' HTTP/1.1' . Client::CRLF; 351 | $buf .= 'Host: ' . $pa['host'] . Client::CRLF . 'Content-Length: 0' . Client::CRLF; 352 | $buf .= 'Proxy-Connection: Keep-Alive' . Client::CRLF; 353 | if (isset(self::$_proxy['user'])) { 354 | $buf .= 'Proxy-Authorization: Basic ' . base64_encode(self::$_proxy['user'] . ':' . self::$_proxy['pass']) . Client::CRLF; 355 | } 356 | $buf .= Client::CRLF; 357 | $this->proxyState++; 358 | return $this->write($buf); 359 | } else { 360 | // wait other response lines 361 | $this->proxyState++; 362 | } 363 | } elseif (self::$_proxy['scheme'] === 'socks4') { 364 | if ($this->proxyState === 1) { 365 | $pa = parse_url($this->conn); 366 | $buf = "\x04\x01" . pack('nN', isset($pa['port']) ? $pa['port'] : 80, ip2long($pa['host'])) . "\x00"; 367 | $this->proxyState++; 368 | return $this->write($buf); 369 | } 370 | } elseif (self::$_proxy['scheme'] === 'socks5') { 371 | if ($this->proxyState === 1) { 372 | $buf = isset(self::$_proxy['user']) ? "\x05\x01\x02" : "\x05\x01\x00"; 373 | $this->proxyState++; 374 | return $this->write($buf); 375 | } elseif ($this->proxyState === 3) { 376 | $buf = chr(0x01) . chr(strlen(self::$_proxy['user'])) . self::$_proxy['user'] 377 | . chr(strlen(self::$_proxy['pass'])) . self::$_proxy['pass']; 378 | $this->proxyState++; 379 | return $this->write($buf); 380 | } elseif ($this->proxyState === 5) { 381 | $pa = parse_url($this->conn); 382 | $buf = "\x05\x01\x00\x01" . pack('Nn', ip2long($pa['host']), isset($pa['port']) ? $pa['port'] : 80); 383 | $this->proxyState++; 384 | return $this->write($buf); 385 | } 386 | } 387 | return false; 388 | } 389 | 390 | /** 391 | * Get the connection socket 392 | * @return resource|false the socket 393 | */ 394 | public function getSock() 395 | { 396 | $this->flag |= self::FLAG_SELECT; 397 | return $this->sock; 398 | } 399 | 400 | /** 401 | * @return mixed the external argument 402 | */ 403 | public function getExArg() 404 | { 405 | return $this->arg; 406 | } 407 | 408 | /** 409 | * Destructor. 410 | */ 411 | public function __destruct() 412 | { 413 | $this->close(true); 414 | } 415 | 416 | /** 417 | * @param boolean $repeat whether it is repeat connection 418 | * @return resource the connection socket 419 | */ 420 | protected function openSock($repeat = false) 421 | { 422 | $this->delSockRef(); 423 | $this->flag |= self::FLAG_NEW; 424 | if ($repeat === true) { 425 | @fclose($this->sock); 426 | $this->flag |= self::FLAG_NEW2; 427 | } 428 | // context options 429 | $useProxy = self::$_proxy !== null; 430 | $ctx = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]; 431 | if ($this->arg instanceof Processor) { 432 | $req = $this->arg->req; 433 | if ($req->disableProxy === true) { 434 | $useProxy = false; 435 | } 436 | if (!strncmp($this->conn, 'ssl:', 4)) { 437 | $ctx['ssl']['peer_name'] = $req->getUrlParam('host'); 438 | } 439 | if (is_array($req->contextOptions)) { 440 | foreach ($req->contextOptions as $key => $value) { 441 | if (isset($ctx[$key])) { 442 | $ctx[$key] = array_merge($ctx[$key], $value); 443 | } else { 444 | $ctx[$key] = $value; 445 | } 446 | } 447 | } 448 | } 449 | $conn = $useProxy ? 'tcp://' . self::$_proxy['host'] . ':' . self::$_proxy['port'] : $this->conn; 450 | $this->sock = @stream_socket_client($conn, $errno, $error, 10, STREAM_CLIENT_ASYNC_CONNECT, stream_context_create($ctx)); 451 | if ($this->sock === false) { 452 | if (empty($error)) { 453 | $error = self::lastPhpError(); 454 | } 455 | Client::debug($repeat ? 're' : '', 'open \'', $conn, '\' failed: ', $error); 456 | self::$_lastError = $error; 457 | } else { 458 | Client::debug($repeat ? 're' : '', 'open \'', $conn, '\' success: ', $this->sock); 459 | stream_set_blocking($this->sock, false); 460 | $this->flag |= self::FLAG_OPENED; 461 | $this->addSockRef(); 462 | if ($useProxy === true) { 463 | $this->proxyState = 1; 464 | } 465 | } 466 | return $this->sock; 467 | } 468 | 469 | protected function ioEmptyError() 470 | { 471 | if ($this->flag & self::FLAG_SELECT) { 472 | if (substr($this->conn, 0, 4) === 'ssl:') { 473 | $meta = stream_get_meta_data($this->sock); 474 | if ($meta['eof'] !== true && $meta['unread_bytes'] === 0) { 475 | return false; 476 | } 477 | } 478 | if (!($this->flag & self::FLAG_REUSED) || !$this->openSock(true)) { 479 | self::$_lastError = ($this->flag & self::FLAG_NEW) ? 'Unable to connect' : 'Stream read error'; 480 | self::$_lastError .= ': ' . self::lastPhpError(); 481 | return true; 482 | } 483 | } 484 | return false; 485 | } 486 | 487 | protected function ioFlagReset() 488 | { 489 | $this->flag &= ~(self::FLAG_NEW | self::FLAG_REUSED | self::FLAG_SELECT); 490 | if ($this->flag & self::FLAG_NEW2) { 491 | $this->flag |= self::FLAG_NEW; 492 | $this->flag ^= self::FLAG_NEW2; 493 | } 494 | } 495 | 496 | protected function addSockRef() 497 | { 498 | if ($this->sock !== false) { 499 | $sock = strval($this->sock); 500 | self::$_refs[$sock] = $this; 501 | } 502 | } 503 | 504 | protected function delSockRef() 505 | { 506 | if ($this->sock !== false) { 507 | $sock = strval($this->sock); 508 | unset(self::$_refs[$sock]); 509 | } 510 | } 511 | 512 | protected function __construct($conn) 513 | { 514 | $this->conn = $conn; 515 | $this->sock = false; 516 | } 517 | 518 | private function enableCrypto($enable = true) 519 | { 520 | $method = STREAM_CRYPTO_METHOD_TLS_CLIENT; 521 | if (defined('STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT')) { 522 | $method |= STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT; 523 | } 524 | if (defined('STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT')) { 525 | $method |= STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; 526 | } 527 | if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) { 528 | $method |= STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; 529 | } 530 | stream_set_blocking($this->sock, true); 531 | $res = @stream_socket_enable_crypto($this->sock, $enable, $method); 532 | stream_set_blocking($this->sock, false); 533 | return $res === true; 534 | } 535 | 536 | private static function lastPhpError() 537 | { 538 | $error = error_get_last(); 539 | return ($error !== null && isset($error['message'])) ? $error['message'] : null; 540 | } 541 | } 542 | -------------------------------------------------------------------------------- /src/HeaderTrait.php: -------------------------------------------------------------------------------- 1 | 6 | * @link http://hightman.cn 7 | * @copyright Copyright (c) 2015 Twomice Studio. 8 | */ 9 | 10 | namespace hightman\http; 11 | 12 | /** 13 | * Http base operations 14 | * Handle cookie and other headers. 15 | * 16 | * @author hightman 17 | * @since 1.0 18 | */ 19 | trait HeaderTrait 20 | { 21 | protected $_headers = []; 22 | protected $_cookies = []; 23 | 24 | /** 25 | * Set http header or headers 26 | * @param mixed $key string key or key-value pairs to set multiple headers. 27 | * @param string $value the header value when key is string, set null to remove header. 28 | * @param boolean $toLower convert key to lowercase 29 | */ 30 | public function setHeader($key, $value = null, $toLower = true) 31 | { 32 | if (is_array($key)) { 33 | foreach ($key as $k => $v) { 34 | $this->setHeader($k, $v); 35 | } 36 | } else { 37 | if ($toLower === true) { 38 | $key = strtolower($key); 39 | } 40 | if ($value === null) { 41 | unset($this->_headers[$key]); 42 | } else { 43 | $this->_headers[$key] = $value; 44 | } 45 | } 46 | } 47 | 48 | /** 49 | * Add http header or headers 50 | * @param mixed $key string key or key-value pairs to be added. 51 | * @param string $value the header value when key is string. 52 | * @param boolean $toLower convert key to lowercase 53 | */ 54 | public function addHeader($key, $value = null, $toLower = true) 55 | { 56 | if (is_array($key)) { 57 | foreach ($key as $k => $v) { 58 | $this->addHeader($k, $v); 59 | } 60 | } else { 61 | if ($value !== null) { 62 | if ($toLower === true) { 63 | $key = strtolower($key); 64 | } 65 | if (!isset($this->_headers[$key])) { 66 | $this->_headers[$key] = $value; 67 | } else { 68 | if (is_array($this->_headers[$key])) { 69 | $this->_headers[$key][] = $value; 70 | } else { 71 | $this->_headers[$key] = [$this->_headers[$key], $value]; 72 | } 73 | } 74 | } 75 | } 76 | } 77 | 78 | /** 79 | * Clean http header 80 | */ 81 | public function clearHeader() 82 | { 83 | $this->_headers = []; 84 | } 85 | 86 | /** 87 | * Get a http header or all http headers 88 | * @param mixed $key the header key to be got, or null to get all headers 89 | * @param boolean $toLower convert key to lowercase 90 | * @return array|string the header value, or headers array when key is null. 91 | */ 92 | public function getHeader($key = null, $toLower = true) 93 | { 94 | if ($key === null) { 95 | return $this->_headers; 96 | } 97 | if ($toLower === true) { 98 | $key = strtolower($key); 99 | } 100 | return isset($this->_headers[$key]) ? $this->_headers[$key] : null; 101 | } 102 | 103 | /** 104 | * Check HTTP header is set or not 105 | * @param string $key the header key to be check, not case sensitive 106 | * @param boolean $toLower convert key to lowercase 107 | * @return boolean if there is http header with the name. 108 | */ 109 | public function hasHeader($key, $toLower = true) 110 | { 111 | if ($toLower === true) { 112 | $key = strtolower($key); 113 | } 114 | return isset($this->_headers[$key]); 115 | } 116 | 117 | /** 118 | * Set a raw cookie 119 | * @param string $key cookie name 120 | * @param string $value cookie value 121 | * @param integer $expires cookie will be expired after this timestamp. 122 | * @param string $domain cookie domain 123 | * @param string $path cookie path 124 | */ 125 | public function setRawCookie($key, $value, $expires = null, $domain = '-', $path = '/') 126 | { 127 | $domain = strtolower($domain); 128 | if (substr($domain, 0, 1) === '.') { 129 | $domain = substr($domain, 1); 130 | } 131 | if (!isset($this->_cookies[$domain])) { 132 | $this->_cookies[$domain] = []; 133 | } 134 | if (!isset($this->_cookies[$domain][$path])) { 135 | $this->_cookies[$domain][$path] = []; 136 | } 137 | $list = &$this->_cookies[$domain][$path]; 138 | if ($value === null || $value === '' || ($expires !== null && $expires < time())) { 139 | unset($list[$key]); 140 | } else { 141 | $list[$key] = ['value' => $value, 'expires' => $expires]; 142 | } 143 | } 144 | 145 | /** 146 | * Set a normal cookie 147 | * @param string $key cookie name 148 | * @param string $value cookie value 149 | */ 150 | public function setCookie($key, $value) 151 | { 152 | $this->setRawCookie($key, rawurlencode($value)); 153 | } 154 | 155 | /** 156 | * Clean all cookies 157 | * @param string $domain use null to clean all cookies, '-' to clean current cookies. 158 | * @param string $path 159 | */ 160 | public function clearCookie($domain = '-', $path = null) 161 | { 162 | if ($domain === null) { 163 | $this->_cookies = []; 164 | } else { 165 | $domain = strtolower($domain); 166 | if ($path === null) { 167 | unset($this->_cookies[$domain]); 168 | } else { 169 | if (isset($this->_cookies[$domain])) { 170 | unset($this->_cookies[$domain][$path]); 171 | } 172 | } 173 | } 174 | } 175 | 176 | /** 177 | * Get cookie value 178 | * @param string $key passing null to get all cookies 179 | * @param string $domain passing '-' to fetch from current session, 180 | * @return array|null|string 181 | */ 182 | public function getCookie($key, $domain = '-') 183 | { 184 | $domain = strtolower($domain); 185 | if ($key === null) { 186 | $cookies = []; 187 | } 188 | while (true) { 189 | if (isset($this->_cookies[$domain])) { 190 | foreach ($this->_cookies[$domain] as $path => $list) { 191 | if ($key === null) { 192 | $cookies = array_merge($list, $cookies); 193 | } else { 194 | if (isset($list[$key])) { 195 | return rawurldecode($list[$key]['value']); 196 | } 197 | } 198 | } 199 | } 200 | if (($pos = strpos($domain, '.', 1)) === false) { 201 | break; 202 | } 203 | $domain = substr($domain, $pos); 204 | } 205 | return $key === null ? $cookies : null; 206 | } 207 | 208 | /** 209 | * Apply cookies for request 210 | * @param Request $req 211 | */ 212 | public function applyCookie($req) 213 | { 214 | // fetch cookies 215 | $host = $req->getHeader('host'); 216 | $path = $req->getUrlParam('path'); 217 | $cookies = $this->fetchCookieToSend($host, $path); 218 | if ($this !== $req) { 219 | $cookies = array_merge($cookies, $req->fetchCookieToSend($host, $path)); 220 | } 221 | // add to header 222 | $req->setHeader('cookie', null); 223 | foreach (array_chunk(array_values($cookies), 3) as $chunk) { 224 | $req->addHeader('cookie', implode('; ', $chunk)); 225 | } 226 | } 227 | 228 | /** 229 | * Fetch cookies to be sent 230 | * @param string $host 231 | * @param string $path 232 | * @return array 233 | */ 234 | public function fetchCookieToSend($host, $path) 235 | { 236 | $now = time(); 237 | $host = strtolower($host); 238 | $cookies = []; 239 | $domains = ['-', $host]; 240 | while (strlen($host) > 1 && ($pos = strpos($host, '.', 1)) !== false) { 241 | $host = substr($host, $pos + 1); 242 | $domains[] = $host; 243 | } 244 | foreach ($domains as $domain) { 245 | if (!isset($this->_cookies[$domain])) { 246 | continue; 247 | } 248 | foreach ($this->_cookies[$domain] as $_path => $list) { 249 | if (!strncmp($_path, $path, strlen($_path)) 250 | && (substr($_path, -1, 1) === '/' || substr($path, strlen($_path), 1) === '/') 251 | ) { 252 | foreach ($list as $k => $v) { 253 | if (!isset($cookies[$k]) && ($v['expires'] === null || $v['expires'] > $now)) { 254 | $cookies[$k] = $k . '=' . $v['value']; 255 | } 256 | } 257 | } 258 | } 259 | } 260 | return $cookies; 261 | } 262 | 263 | protected function fetchCookieToSave() 264 | { 265 | $now = time(); 266 | $cookies = []; 267 | foreach ($this->_cookies as $domain => $_list1) { 268 | $list1 = []; 269 | foreach ($_list1 as $path => $_list2) { 270 | $list2 = []; 271 | foreach ($_list2 as $k => $v) { 272 | if ($v['expires'] === null || $v['expires'] < $now) { 273 | continue; 274 | } 275 | $list2[$k] = $v; 276 | } 277 | if (count($list2) > 0) { 278 | $list1[$path] = $list2; 279 | } 280 | } 281 | if (count($list1) > 0) { 282 | $cookies[$domain] = $list1; 283 | } 284 | } 285 | return $cookies; 286 | } 287 | 288 | protected function loadCookie($file) 289 | { 290 | if (file_exists($file)) { 291 | $this->_cookies = unserialize(file_get_contents($file)); 292 | } 293 | } 294 | 295 | protected function saveCookie($file) 296 | { 297 | file_put_contents($file, serialize($this->fetchCookieToSave())); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/ParseInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * @link http://hightman.cn 7 | * @copyright Copyright (c) 2015 Twomice Studio. 8 | */ 9 | 10 | namespace hightman\http; 11 | 12 | /** 13 | * Interface for classes that parse the HTTP response. 14 | * 15 | * @author hightman 16 | * @since 1.0 17 | */ 18 | interface ParseInterface 19 | { 20 | /** 21 | * Parse HTTP response 22 | * @param Response $res the resulting response 23 | * @param Request $req the request to be parsed 24 | * @param string $key the index key of request 25 | */ 26 | public function parse(Response $res, Request $req, $key); 27 | } 28 | -------------------------------------------------------------------------------- /src/Processor.php: -------------------------------------------------------------------------------- 1 | 6 | * @link http://hightman.cn 7 | * @copyright Copyright (c) 2015 Twomice Studio. 8 | */ 9 | 10 | namespace hightman\http; 11 | 12 | /** 13 | * Http processor 14 | * Handle requests 15 | * @author hightman 16 | * @since 1.0 17 | */ 18 | class Processor 19 | { 20 | /** 21 | * @var string request key 22 | */ 23 | public $key; 24 | 25 | /** 26 | * @var Client client object 27 | */ 28 | public $cli; 29 | 30 | /** 31 | * @var Request request object 32 | */ 33 | public $req; 34 | 35 | /** 36 | * @var Response response object 37 | */ 38 | public $res; 39 | 40 | /** 41 | * @var Connection 42 | */ 43 | public $conn = null; 44 | 45 | /** 46 | * @var boolean whether the process is completed 47 | */ 48 | public $finished; 49 | 50 | protected $headerOk, $timeBegin, $chunkLeft; 51 | 52 | /** 53 | * Constructor 54 | * @param Client $cli 55 | * @param Request $req 56 | * @param string|integer $key 57 | */ 58 | public function __construct($cli, $req, $key = null) 59 | { 60 | $this->cli = $cli; 61 | $this->req = $req; 62 | $this->key = $key; 63 | $this->res = new Response($req->getRawUrl()); 64 | $this->finished = $this->headerOk = false; 65 | $this->timeBegin = microtime(true); 66 | } 67 | 68 | /** 69 | * Destructor 70 | */ 71 | public function __destruct() 72 | { 73 | if ($this->conn) { 74 | $this->conn->close(); 75 | } 76 | $this->req = $this->cli = $this->res = $this->conn = null; 77 | } 78 | 79 | /** 80 | * Get connection 81 | * @return Connection the connection object, returns null if the connection fails or need to queue. 82 | */ 83 | public function getConn() 84 | { 85 | if ($this->conn === null) { 86 | $this->conn = Connection::connect($this->req->getUrlParam('conn'), $this); 87 | if ($this->conn === false) { 88 | $this->res->error = Connection::getLastError(); 89 | $this->finish(); 90 | } else { 91 | if ($this->conn !== null) { 92 | $this->conn->addWriteData($this->getRequestBuf()); 93 | } 94 | } 95 | } 96 | return $this->conn; 97 | } 98 | 99 | public function send() 100 | { 101 | if ($this->conn->write() === false) { 102 | $this->finish('BROKEN'); 103 | } 104 | } 105 | 106 | public function recv() 107 | { 108 | if ($this->conn->proxyState !== 0) { 109 | if ($this->conn->proxyRead() === false) { 110 | $this->finish('BROKEN'); 111 | } 112 | } else { 113 | return $this->headerOk ? $this->readBody() : $this->readHeader(); 114 | } 115 | } 116 | 117 | /** 118 | * Finish the processor 119 | * @param string $type finish type, supports: NORMAL, BROKEN, TIMEOUT 120 | */ 121 | public function finish($type = 'NORMAL') 122 | { 123 | $this->finished = true; 124 | if ($type === 'BROKEN') { 125 | $this->res->error = Connection::getLastError(); 126 | } else { 127 | if ($type !== 'NORMAL') { 128 | $this->res->error = ucfirst(strtolower($type)); 129 | } 130 | } 131 | // gzip decode 132 | $encoding = $this->res->getHeader('content-encoding'); 133 | if ($encoding !== null && strstr($encoding, 'gzip')) { 134 | $this->res->body = Client::gzdecode($this->res->body); 135 | } 136 | // parser 137 | $this->res->timeCost = microtime(true) - $this->timeBegin; 138 | $this->cli->runParser($this->res, $this->req, $this->key); 139 | // conn 140 | if ($this->conn) { 141 | // close conn 142 | $close = $this->res->getHeader('connection'); 143 | $this->conn->close($type !== 'NORMAL' || !strcasecmp($close, 'close')); 144 | $this->conn = null; 145 | // redirect 146 | if (($this->res->status === 301 || $this->res->status === 302) 147 | && $this->res->numRedirected < $this->req->getMaxRedirect() 148 | && ($location = $this->res->getHeader('location')) !== null 149 | ) { 150 | Client::debug('redirect to \'', $location, '\''); 151 | $req = $this->req; 152 | if (!preg_match('/^https?:\/\//i', $location)) { 153 | $pa = $req->getUrlParams(); 154 | $url = $pa['scheme'] . '://' . $pa['host']; 155 | if (isset($pa['port'])) { 156 | $url .= ':' . $pa['port']; 157 | } 158 | if (substr($location, 0, 1) == '/') { 159 | $url .= $location; 160 | } else { 161 | $url .= substr($pa['path'], 0, strrpos($pa['path'], '/') + 1) . $location; 162 | } 163 | $location = $url; /// FIXME: strip relative '../../' 164 | } 165 | // change new url 166 | $prevUrl = $req->getUrl(); 167 | $req->setUrl($location); 168 | if (!$req->getHeader('referer')) { 169 | $req->setHeader('referer', $prevUrl); 170 | } 171 | if ($req->getMethod() !== 'HEAD') { 172 | $req->setMethod('GET'); 173 | } 174 | $req->clearCookie(); 175 | $req->setHeader('host', null); 176 | $req->setHeader('x-server-ip', null); 177 | $req->setHeader('content-type', null); 178 | $req->setBody(null); 179 | // reset response 180 | $this->res->numRedirected++; 181 | $this->finished = $this->headerOk = false; 182 | return $this->res->reset(); 183 | } 184 | } 185 | Client::debug('finished', $this->res->hasError() ? ' (' . $this->res->error . ')' : ''); 186 | $this->req = $this->cli = null; 187 | } 188 | 189 | 190 | private function readHeader() 191 | { 192 | // read header 193 | while (($line = $this->conn->getLine()) !== null) { 194 | if ($line === false) { 195 | return $this->finish('BROKEN'); 196 | } 197 | if ($line === '') { 198 | $this->headerOk = true; 199 | $this->chunkLeft = 0; 200 | return $this->readBody(); 201 | } 202 | Client::debug('read header line: ', $line); 203 | if (!strncmp('HTTP/', $line, 5)) { 204 | $line = trim(substr($line, strpos($line, ' '))); 205 | list($this->res->status, $this->res->statusText) = explode(' ', $line, 2); 206 | $this->res->status = intval($this->res->status); 207 | } else { 208 | if (!strncasecmp('Set-Cookie: ', $line, 12)) { 209 | $cookie = $this->parseCookieLine($line); 210 | if ($cookie !== false) { 211 | $this->res->setRawCookie($cookie['name'], $cookie['value']); 212 | $this->cli->setRawCookie($cookie['name'], $cookie['value'], $cookie['expires'], $cookie['domain'], $cookie['path']); 213 | } 214 | } else { 215 | list($k, $v) = explode(':', $line, 2); 216 | $this->res->addHeader($k, trim($v)); 217 | } 218 | } 219 | } 220 | } 221 | 222 | private function readBody() 223 | { 224 | // head only 225 | if ($this->req->getMethod() === 'HEAD') { 226 | return $this->finish(); 227 | } 228 | // chunked 229 | $res = $this->res; 230 | $conn = $this->conn; 231 | $length = $res->getHeader('content-length'); 232 | $encoding = $res->getHeader('transfer-encoding'); 233 | if ($encoding !== null && !strcasecmp($encoding, 'chunked')) { 234 | // unfinished chunk 235 | if ($this->chunkLeft > 0) { 236 | $buf = $conn->read($this->chunkLeft); 237 | if ($buf === false) { 238 | return $this->finish('BROKEN'); 239 | } 240 | if (is_string($buf)) { 241 | Client::debug('read chunkLeft(', $this->chunkLeft, ')=', strlen($buf)); 242 | $res->body .= $buf; 243 | $this->chunkLeft -= strlen($buf); 244 | if ($this->chunkLeft === 0) { 245 | // strip CRLF 246 | $res->body = substr($res->body, 0, -2); 247 | } 248 | } 249 | if ($this->chunkLeft > 0) { 250 | return; 251 | } 252 | } 253 | // next chunk 254 | while (($line = $conn->getLine()) !== null) { 255 | if ($line === false) { 256 | return $this->finish('BROKEN'); 257 | } 258 | Client::debug('read chunk line: ', $line); 259 | if (($pos = strpos($line, ';')) !== false) { 260 | $line = substr($line, 0, $pos); 261 | } 262 | $size = intval(hexdec(trim($line))); 263 | if ($size <= 0) { 264 | while ($line = $conn->getLine()) // tail header 265 | { 266 | if ($line === '') { 267 | break; 268 | } 269 | Client::debug('read tailer line: ', $line); 270 | if (($pos = strpos($line, ':')) !== false) { 271 | $res->addHeader(substr($line, 0, $pos), trim(substr($line, $pos + 1))); 272 | } 273 | } 274 | return $this->finish(); 275 | } 276 | // add CRLF, save to chunkLeft for next loop 277 | $this->chunkLeft = $size + 2; // add CRLF 278 | return; 279 | } 280 | } else { 281 | if ($length !== null) { 282 | $size = intval($length) - strlen($res->body); 283 | if ($size > 0) { 284 | $buf = $conn->read($size); 285 | if ($buf === false) { 286 | return $this->finish('BROKEN'); 287 | } 288 | if (is_string($buf)) { 289 | Client::debug('read fixedBody(', $size, ')=', strlen($buf)); 290 | $res->body .= $buf; 291 | $size -= strlen($buf); 292 | } 293 | } 294 | if ($size === 0) { 295 | return $this->finish(); 296 | } 297 | } else { 298 | if ($res->body === '') { 299 | $res->setHeader('connection', 'close'); 300 | } 301 | if (($buf = $conn->read()) === false) { 302 | return $this->finish(); 303 | } 304 | if (is_string($buf)) { 305 | Client::debug('read streamBody()=', strlen($buf)); 306 | $res->body .= $buf; 307 | } 308 | } 309 | } 310 | } 311 | 312 | private function parseCookieLine($line) 313 | { 314 | $now = time(); 315 | $cookie = ['name' => '', 'value' => '', 'expires' => null, 'path' => '/']; 316 | $cookie['domain'] = $this->req->getHeader('host'); 317 | $parts = explode(';', substr($line, 12)); 318 | foreach ($parts as $part) { 319 | if (($pos = strpos($part, '=')) === false) { 320 | continue; 321 | } 322 | $k = trim(substr($part, 0, $pos)); 323 | $v = trim(substr($part, $pos + 1)); 324 | if ($cookie['name'] === '') { 325 | $cookie['name'] = $k; 326 | $cookie['value'] = $v; 327 | } else { 328 | $k = strtolower($k); 329 | if ($k === 'expires') { 330 | $cookie[$k] = strtotime($v); 331 | if ($cookie[$k] < $now) { 332 | $cookie['value'] = ''; 333 | } 334 | } else { 335 | if ($k === 'domain') { 336 | $pos = strpos($cookie['domain'], $v); 337 | if ($pos === 0 || substr($cookie['domain'], $pos, 1) === '.' || substr($cookie['domain'], $pos + 1, 1) === '.') { 338 | $cookie[$k] = $v; 339 | } 340 | } else { 341 | if (isset($cookie[$k])) { 342 | $cookie[$k] = $v; 343 | } 344 | } 345 | } 346 | } 347 | } 348 | if ($cookie['name'] !== '') { 349 | return $cookie; 350 | } 351 | return false; 352 | } 353 | 354 | private function getRequestBuf() 355 | { 356 | // request line 357 | $cli = $this->cli; 358 | $req = $this->req; 359 | $pa = $req->getUrlParams(); 360 | $header = $req->getMethod() . ' ' . $pa['path']; 361 | if (isset($pa['query'])) { 362 | $header .= '?' . $pa['query']; 363 | } 364 | $header .= ' HTTP/1.1' . Client::CRLF; 365 | // body (must call prior than headers) 366 | $body = $req->getBody(); 367 | Client::debug('request body(', strlen($body) . ')'); 368 | // header 369 | $cli->applyCookie($req); 370 | foreach (array_merge($cli->getHeader(null), $req->getHeader(null)) as $key => $value) { 371 | $header .= $this->formatHeaderLine($key, $value); 372 | } 373 | Client::debug('request header: ', Client::CRLF, $header); 374 | return $header . Client::CRLF . $body; 375 | } 376 | 377 | private function formatHeaderLine($key, $value) 378 | { 379 | if (is_array($value)) { 380 | $line = ''; 381 | foreach ($value as $val) { 382 | $line .= $this->formatHeaderLine($key, $val); 383 | } 384 | return $line; 385 | } 386 | if (strpos($key, '-') === false) { 387 | $line = ucfirst($key); 388 | } else { 389 | $parts = explode('-', $key); 390 | $line = ucfirst($parts[0]); 391 | for ($i = 1; $i < count($parts); $i++) { 392 | $line .= '-' . ucfirst($parts[$i]); 393 | } 394 | } 395 | $line .= ': ' . $value . Client::CRLF; 396 | return $line; 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /src/Request.php: -------------------------------------------------------------------------------- 1 | 6 | * @link http://hightman.cn 7 | * @copyright Copyright (c) 2015 Twomice Studio. 8 | */ 9 | 10 | namespace hightman\http; 11 | 12 | /** 13 | * Http request 14 | * 15 | * @author hightman 16 | * @since 1.0 17 | */ 18 | class Request 19 | { 20 | use HeaderTrait; 21 | 22 | /** 23 | * @var array context options of connection, now supports: ssl,socket 24 | * @see https://www.php.net/manual/en/context.php 25 | */ 26 | public $contextOptions; 27 | 28 | /** 29 | * @var bool disable proxy for this request 30 | */ 31 | public $disableProxy = false; 32 | 33 | private $_url, $_urlParams, $_rawUrl, $_body; 34 | private $_method = 'GET'; 35 | private $_maxRedirect = 5; 36 | private $_postFields = []; 37 | private $_postFiles = []; 38 | private static $_dns = []; 39 | private static $_mimes = [ 40 | 'gif' => 'image/gif', 'png' => 'image/png', 'bmp' => 'image/bmp', 41 | 'jpeg' => 'image/jpeg', 'pjpg' => 'image/pjpg', 'jpg' => 'image/jpeg', 42 | 'tif' => 'image/tiff', 'htm' => 'text/html', 'css' => 'text/css', 43 | 'html' => 'text/html', 'txt' => 'text/plain', 'gz' => 'application/x-gzip', 44 | 'tgz' => 'application/x-gzip', 'tar' => 'application/x-tar', 45 | 'zip' => 'application/zip', 'hqx' => 'application/mac-binhex40', 46 | 'doc' => 'application/msword', 'pdf' => 'application/pdf', 47 | 'ps' => 'application/postcript', 'rtf' => 'application/rtf', 48 | 'dvi' => 'application/x-dvi', 'latex' => 'application/x-latex', 49 | 'swf' => 'application/x-shockwave-flash', 'tex' => 'application/x-tex', 50 | 'mid' => 'audio/midi', 'au' => 'audio/basic', 'mp3' => 'audio/mpeg', 51 | 'ram' => 'audio/x-pn-realaudio', 'ra' => 'audio/x-realaudio', 52 | 'rm' => 'audio/x-pn-realaudio', 'wav' => 'audio/x-wav', 'wma' => 'audio/x-ms-media', 53 | 'wmv' => 'video/x-ms-media', 'mpg' => 'video/mpeg', 'mpga' => 'video/mpeg', 54 | 'wrl' => 'model/vrml', 'mov' => 'video/quicktime', 'avi' => 'video/x-msvideo', 55 | ]; 56 | 57 | /** 58 | * Constructor 59 | * @param string $url the request URL. 60 | * @param string $method the request method. 61 | */ 62 | public function __construct($url = null, $method = null) 63 | { 64 | if ($url !== null) { 65 | $this->setUrl($url); 66 | } 67 | if ($method !== null) { 68 | $this->setMethod($method); 69 | } 70 | } 71 | 72 | /** 73 | * Convert to string 74 | * @return string url 75 | */ 76 | public function __toString() 77 | { 78 | return $this->getUrl(); 79 | } 80 | 81 | /** 82 | * Get max redirects 83 | * @return integer the max redirects. 84 | */ 85 | public function getMaxRedirect() 86 | { 87 | return $this->_maxRedirect; 88 | } 89 | 90 | /** 91 | * Set max redirects 92 | * @param integer $num max redirects to be set. 93 | */ 94 | public function setMaxRedirect($num) 95 | { 96 | $this->_maxRedirect = intval($num); 97 | } 98 | 99 | /** 100 | * @return string raw url 101 | */ 102 | public function getRawUrl() 103 | { 104 | return $this->_rawUrl; 105 | } 106 | 107 | /** 108 | * Get request URL 109 | * @return string request url after handling 110 | */ 111 | public function getUrl() 112 | { 113 | return $this->_url; 114 | } 115 | 116 | /** 117 | * Set request URL 118 | * Relative url will be converted to full url by adding host and protocol. 119 | * @param string $url raw url 120 | */ 121 | public function setUrl($url) 122 | { 123 | $this->_rawUrl = $url; 124 | if (strncasecmp($url, 'http://', 7) && strncasecmp($url, 'https://', 8) && isset($_SERVER['HTTP_HOST'])) { 125 | if (substr($url, 0, 1) != '/') { 126 | $url = substr($_SERVER['SCRIPT_NAME'], 0, strrpos($_SERVER['SCRIPT_NAME'], '/') + 1) . $url; 127 | } 128 | $url = 'http://' . $_SERVER['HTTP_HOST'] . $url; 129 | } 130 | $this->_url = str_replace('&', '&', $url); 131 | $this->_urlParams = null; 132 | } 133 | 134 | /** 135 | * Get url parameters 136 | * @return array the parameters parsed from URL, or false on error 137 | */ 138 | public function getUrlParams() 139 | { 140 | if ($this->_urlParams === null) { 141 | $pa = @parse_url($this->getUrl()); 142 | $pa['scheme'] = isset($pa['scheme']) ? strtolower($pa['scheme']) : 'http'; 143 | if ($pa['scheme'] !== 'http' && $pa['scheme'] !== 'https') { 144 | return false; 145 | } 146 | if (!isset($pa['host'])) { 147 | return false; 148 | } 149 | if (!isset($pa['path'])) { 150 | $pa['path'] = '/'; 151 | } 152 | // basic auth 153 | if (isset($pa['user']) && isset($pa['pass'])) { 154 | $this->applyBasicAuth($pa['user'], $pa['pass']); 155 | } 156 | // convert host to IP address 157 | $port = isset($pa['port']) ? intval($pa['port']) : ($pa['scheme'] === 'https' ? 443 : 80); 158 | $pa['ip'] = $this->hasHeader('x-server-ip') ? 159 | $this->getHeader('x-server-ip') : self::getIp($pa['host']); 160 | $pa['conn'] = ($pa['scheme'] === 'https' ? 'ssl' : 'tcp') . '://' . $pa['ip'] . ':' . $port; 161 | // host header 162 | if (!$this->hasHeader('host')) { 163 | $this->setHeader('host', strtolower($pa['host'])); 164 | } else { 165 | $pa['host'] = $this->getHeader('host'); 166 | } 167 | $this->_urlParams = $pa; 168 | } 169 | return $this->_urlParams; 170 | } 171 | 172 | /** 173 | * Get url parameter by key 174 | * @param string $key parameter name 175 | * @return string the parameter value or null if non-exists. 176 | */ 177 | public function getUrlParam($key) 178 | { 179 | $pa = $this->getUrlParams(); 180 | return isset($pa[$key]) ? $pa[$key] : null; 181 | } 182 | 183 | /** 184 | * Get http request method 185 | * @return string the request method 186 | */ 187 | public function getMethod() 188 | { 189 | return $this->_method; 190 | } 191 | 192 | /** 193 | * Set http request method 194 | * @param string $method request method 195 | */ 196 | public function setMethod($method) 197 | { 198 | $this->_method = strtoupper($method); 199 | } 200 | 201 | /** 202 | * Get http request body 203 | * Appending post fields and files. 204 | * @return string request body 205 | */ 206 | public function getBody() 207 | { 208 | $body = ''; 209 | if ($this->_method === 'POST' || $this->_method === 'PUT') { 210 | if ($this->_body === null) { 211 | $this->_body = $this->getPostBody(); 212 | } 213 | $this->setHeader('content-length', strlen($this->_body)); 214 | $body = $this->_body . Client::CRLF; 215 | } 216 | return $body; 217 | } 218 | 219 | /** 220 | * Set http request body 221 | * @param string $body content string. 222 | */ 223 | public function setBody($body) 224 | { 225 | $this->_body = $body; 226 | $this->setHeader('content-length', $body === null ? null : strlen($body)); 227 | } 228 | 229 | /** 230 | * Set http request body as Json 231 | * @param mixed $data json data 232 | */ 233 | public function setJsonBody($data) 234 | { 235 | $body = json_encode($data, JSON_UNESCAPED_UNICODE); 236 | $this->setHeader('content-type', 'application/json'); 237 | $this->setBody($body); 238 | } 239 | 240 | /** 241 | * Add field for the request use POST 242 | * @param string $key field name. 243 | * @param mixed $value field value, array supported. 244 | */ 245 | public function addPostField($key, $value) 246 | { 247 | $this->setMethod('POST'); 248 | $this->setBody(null); 249 | if (!is_array($value)) { 250 | $this->_postFields[$key] = strval($value); 251 | } else { 252 | $value = $this->formatArrayField($value); 253 | foreach ($value as $k => $v) { 254 | $k = $key . '[' . $k . ']'; 255 | $this->_postFields[$k] = $v; 256 | } 257 | } 258 | } 259 | 260 | /** 261 | * Add file to be uploaded for request use POST 262 | * @param string $key field name. 263 | * @param string $file file path to be uploaded. 264 | * @param string $content file content, default to null and read from file. 265 | */ 266 | public function addPostFile($key, $file, $content = null) 267 | { 268 | $this->setMethod('POST'); 269 | $this->setBody(null); 270 | if ($content === null && is_file($file)) { 271 | $content = @file_get_contents($file); 272 | } 273 | $this->_postFiles[$key] = [basename($file), $content]; 274 | } 275 | 276 | /** 277 | * Combine request body from post fields & files 278 | * @return string request body content 279 | */ 280 | protected function getPostBody() 281 | { 282 | $data = ''; 283 | if (count($this->_postFiles) > 0) { 284 | $boundary = md5($this->_rawUrl . microtime()); 285 | foreach ($this->_postFields as $k => $v) { 286 | $data .= '--' . $boundary . Client::CRLF . 'Content-Disposition: form-data; name="' . $k . '"' 287 | . Client::CRLF . Client::CRLF . $v . Client::CRLF; 288 | } 289 | foreach ($this->_postFiles as $k => $v) { 290 | $ext = strtolower(substr($v[0], strrpos($v[0], '.') + 1)); 291 | $type = isset(self::$_mimes[$ext]) ? self::$_mimes[$ext] : 'application/octet-stream'; 292 | $data .= '--' . $boundary . Client::CRLF . 'Content-Disposition: form-data; name="' . $k . '"; filename="' . $v[0] . '"' 293 | . Client::CRLF . 'Content-Type: ' . $type . Client::CRLF . 'Content-Transfer-Encoding: binary' 294 | . Client::CRLF . Client::CRLF . $v[1] . Client::CRLF; 295 | } 296 | $data .= '--' . $boundary . '--' . Client::CRLF; 297 | $this->setHeader('content-type', 'multipart/form-data; boundary=' . $boundary); 298 | } else { 299 | if (count($this->_postFields) > 0) { 300 | foreach ($this->_postFields as $k => $v) { 301 | $data .= '&' . rawurlencode($k) . '=' . rawurlencode($v); 302 | } 303 | $data = substr($data, 1); 304 | $this->setHeader('content-type', 'application/x-www-form-urlencoded'); 305 | } 306 | } 307 | return $data; 308 | } 309 | 310 | // get ip address 311 | protected static function getIp($host) 312 | { 313 | if (!isset(self::$_dns[$host])) { 314 | self::$_dns[$host] = gethostbyname($host); 315 | } 316 | return self::$_dns[$host]; 317 | } 318 | 319 | // format array field (convert N-DIM(n>=2) array => 2-DIM array) 320 | private function formatArrayField($arr, $pk = null) 321 | { 322 | $ret = []; 323 | foreach ($arr as $k => $v) { 324 | if ($pk !== null) { 325 | $k = $pk . $k; 326 | } 327 | if (is_array($v)) { 328 | $ret = array_merge($ret, $this->formatArrayField($v, $k . '][')); 329 | } else { 330 | $ret[$k] = $v; 331 | } 332 | } 333 | return $ret; 334 | } 335 | 336 | // apply basic auth 337 | private function applyBasicAuth($user, $pass) 338 | { 339 | $this->setHeader('authorization', 'Basic ' . base64_encode($user . ':' . $pass)); 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | 6 | * @link http://hightman.cn 7 | * @copyright Copyright (c) 2015 Twomice Studio. 8 | */ 9 | 10 | namespace hightman\http; 11 | 12 | /** 13 | * Http response 14 | * 15 | * @author hightman 16 | * @since 1.0 17 | */ 18 | class Response 19 | { 20 | use HeaderTrait; 21 | /** 22 | * @var integer response status code 23 | */ 24 | public $status; 25 | /** 26 | * @var string response status line, such as 'Not Found', 'Forbidden' ... 27 | */ 28 | public $statusText; 29 | /** 30 | * @var string error message, null if has not error. 31 | */ 32 | public $error; 33 | /** 34 | * @var string response body content. 35 | */ 36 | public $body; 37 | /** 38 | * @var float time cost to complete this request. 39 | */ 40 | public $timeCost; 41 | /** 42 | * @var string raw request url. 43 | */ 44 | public $url; 45 | /** 46 | * @var integer number of redirects 47 | */ 48 | public $numRedirected = 0; 49 | 50 | /** 51 | * Constructor 52 | * @param string $url 53 | */ 54 | public function __construct($url) 55 | { 56 | $this->reset(); 57 | $this->url = $url; 58 | } 59 | 60 | /** 61 | * Convert to string (body) 62 | * @return string 63 | */ 64 | public function __toString() 65 | { 66 | return $this->body; 67 | } 68 | 69 | /** 70 | * Check has error or not 71 | * @return bool 72 | */ 73 | public function hasError() 74 | { 75 | return $this->error !== null; 76 | } 77 | 78 | /** 79 | * Reset object 80 | */ 81 | public function reset() 82 | { 83 | $this->status = 400; 84 | $this->statusText = 'Bad Request'; 85 | $this->body = ''; 86 | $this->error = null; 87 | $this->timeCost = 0; 88 | $this->clearHeader(); 89 | $this->clearCookie(); 90 | } 91 | 92 | /** 93 | * Redirect to another url 94 | * This method can be used in request callback. 95 | * @param string $url target url to be redirected to. 96 | */ 97 | public function redirect($url) 98 | { 99 | // has redirect from upstream 100 | if (($this->status === 301 || $this->status === 302) && ($this->hasHeader('location'))) { 101 | return; 102 | } 103 | // @fixme need a better solution 104 | $this->numRedirected--; 105 | $this->status = 302; 106 | $this->setHeader('location', $url); 107 | } 108 | 109 | /** 110 | * Get json response data 111 | * @return mixed 112 | */ 113 | public function getJson() 114 | { 115 | if (stripos($this->getHeader('content-type'), '/json') !== false) { 116 | return json_decode($this->body, true); 117 | } else { 118 | return false; 119 | } 120 | } 121 | } 122 | --------------------------------------------------------------------------------