1<?php 2 3namespace Sabre\HTTP; 4 5use Sabre\Event\EventEmitter; 6use Sabre\Uri; 7 8/** 9 * A rudimentary HTTP client. 10 * 11 * This object wraps PHP's curl extension and provides an easy way to send it a 12 * Request object, and return a Response object. 13 * 14 * This is by no means intended as the next best HTTP client, but it does the 15 * job and provides a simple integration with the rest of sabre/http. 16 * 17 * This client emits the following events: 18 * beforeRequest(RequestInterface $request) 19 * afterRequest(RequestInterface $request, ResponseInterface $response) 20 * error(RequestInterface $request, ResponseInterface $response, bool &$retry, int $retryCount) 21 * 22 * The beforeRequest event allows you to do some last minute changes to the 23 * request before it's done, such as adding authentication headers. 24 * 25 * The afterRequest event will be emitted after the request is completed 26 * succesfully. 27 * 28 * If a HTTP error is returned (status code higher than 399) the error event is 29 * triggered. It's possible using this event to retry the request, by setting 30 * retry to true. 31 * 32 * The amount of times a request has retried is passed as $retryCount, which 33 * can be used to avoid retrying indefinitely. The first time the event is 34 * called, this will be 0. 35 * 36 * It's also possible to intercept specific http errors, by subscribing to for 37 * example 'error:401'. 38 * 39 * @copyright Copyright (C) 2009-2015 fruux GmbH (https://fruux.com/). 40 * @author Evert Pot (http://evertpot.com/) 41 * @license http://sabre.io/license/ Modified BSD License 42 */ 43class Client extends EventEmitter { 44 45 /** 46 * List of curl settings 47 * 48 * @var array 49 */ 50 protected $curlSettings = []; 51 52 /** 53 * Wether or not exceptions should be thrown when a HTTP error is returned. 54 * 55 * @var bool 56 */ 57 protected $throwExceptions = false; 58 59 /** 60 * The maximum number of times we'll follow a redirect. 61 * 62 * @var int 63 */ 64 protected $maxRedirects = 5; 65 66 /** 67 * Initializes the client. 68 * 69 * @return void 70 */ 71 function __construct() { 72 73 $this->curlSettings = [ 74 CURLOPT_RETURNTRANSFER => true, 75 CURLOPT_HEADER => true, 76 CURLOPT_NOBODY => false, 77 ]; 78 79 } 80 81 /** 82 * Sends a request to a HTTP server, and returns a response. 83 * 84 * @param RequestInterface $request 85 * @return ResponseInterface 86 */ 87 function send(RequestInterface $request) { 88 89 $this->emit('beforeRequest', [$request]); 90 91 $retryCount = 0; 92 $redirects = 0; 93 94 do { 95 96 $doRedirect = false; 97 $retry = false; 98 99 try { 100 101 $response = $this->doRequest($request); 102 103 $code = (int)$response->getStatus(); 104 105 // We are doing in-PHP redirects, because curl's 106 // FOLLOW_LOCATION throws errors when PHP is configured with 107 // open_basedir. 108 // 109 // https://github.com/fruux/sabre-http/issues/12 110 if (in_array($code, [301, 302, 307, 308]) && $redirects < $this->maxRedirects) { 111 112 $oldLocation = $request->getUrl(); 113 114 // Creating a new instance of the request object. 115 $request = clone $request; 116 117 // Setting the new location 118 $request->setUrl(Uri\resolve( 119 $oldLocation, 120 $response->getHeader('Location') 121 )); 122 123 $doRedirect = true; 124 $redirects++; 125 126 } 127 128 // This was a HTTP error 129 if ($code >= 400) { 130 131 $this->emit('error', [$request, $response, &$retry, $retryCount]); 132 $this->emit('error:' . $code, [$request, $response, &$retry, $retryCount]); 133 134 } 135 136 } catch (ClientException $e) { 137 138 $this->emit('exception', [$request, $e, &$retry, $retryCount]); 139 140 // If retry was still set to false, it means no event handler 141 // dealt with the problem. In this case we just re-throw the 142 // exception. 143 if (!$retry) { 144 throw $e; 145 } 146 147 } 148 149 if ($retry) { 150 $retryCount++; 151 } 152 153 } while ($retry || $doRedirect); 154 155 $this->emit('afterRequest', [$request, $response]); 156 157 if ($this->throwExceptions && $code >= 400) { 158 throw new ClientHttpException($response); 159 } 160 161 return $response; 162 163 } 164 165 /** 166 * Sends a HTTP request asynchronously. 167 * 168 * Due to the nature of PHP, you must from time to time poll to see if any 169 * new responses came in. 170 * 171 * After calling sendAsync, you must therefore occasionally call the poll() 172 * method, or wait(). 173 * 174 * @param RequestInterface $request 175 * @param callable $success 176 * @param callable $error 177 * @return void 178 */ 179 function sendAsync(RequestInterface $request, callable $success = null, callable $error = null) { 180 181 $this->emit('beforeRequest', [$request]); 182 $this->sendAsyncInternal($request, $success, $error); 183 $this->poll(); 184 185 } 186 187 188 /** 189 * This method checks if any http requests have gotten results, and if so, 190 * call the appropriate success or error handlers. 191 * 192 * This method will return true if there are still requests waiting to 193 * return, and false if all the work is done. 194 * 195 * @return bool 196 */ 197 function poll() { 198 199 // nothing to do? 200 if (!$this->curlMultiMap) { 201 return false; 202 } 203 204 do { 205 $r = curl_multi_exec( 206 $this->curlMultiHandle, 207 $stillRunning 208 ); 209 } while ($r === CURLM_CALL_MULTI_PERFORM); 210 211 do { 212 213 messageQueue: 214 215 $status = curl_multi_info_read( 216 $this->curlMultiHandle, 217 $messagesInQueue 218 ); 219 220 if ($status && $status['msg'] === CURLMSG_DONE) { 221 222 $resourceId = intval($status['handle']); 223 list( 224 $request, 225 $successCallback, 226 $errorCallback, 227 $retryCount, 228 ) = $this->curlMultiMap[$resourceId]; 229 unset($this->curlMultiMap[$resourceId]); 230 $curlResult = $this->parseCurlResult(curl_multi_getcontent($status['handle']), $status['handle']); 231 $retry = false; 232 233 if ($curlResult['status'] === self::STATUS_CURLERROR) { 234 235 $e = new ClientException($curlResult['curl_errmsg'], $curlResult['curl_errno']); 236 $this->emit('exception', [$request, $e, &$retry, $retryCount]); 237 238 if ($retry) { 239 $retryCount++; 240 $this->sendASyncInternal($request, $successCallback, $errorCallback, $retryCount); 241 goto messageQueue; 242 } 243 244 $curlResult['request'] = $request; 245 246 if ($errorCallback) { 247 $errorCallback($curlResult); 248 } 249 250 } elseif ($curlResult['status'] === self::STATUS_HTTPERROR) { 251 252 $this->emit('error', [$request, $curlResult['response'], &$retry, $retryCount]); 253 $this->emit('error:' . $curlResult['http_code'], [$request, $curlResult['response'], &$retry, $retryCount]); 254 255 if ($retry) { 256 257 $retryCount++; 258 $this->sendASyncInternal($request, $successCallback, $errorCallback, $retryCount); 259 goto messageQueue; 260 261 } 262 263 $curlResult['request'] = $request; 264 265 if ($errorCallback) { 266 $errorCallback($curlResult); 267 } 268 269 } else { 270 271 $this->emit('afterRequest', [$request, $curlResult['response']]); 272 273 if ($successCallback) { 274 $successCallback($curlResult['response']); 275 } 276 277 } 278 } 279 280 } while ($messagesInQueue > 0); 281 282 return count($this->curlMultiMap) > 0; 283 284 } 285 286 /** 287 * Processes every HTTP request in the queue, and waits till they are all 288 * completed. 289 * 290 * @return void 291 */ 292 function wait() { 293 294 do { 295 curl_multi_select($this->curlMultiHandle); 296 $stillRunning = $this->poll(); 297 } while ($stillRunning); 298 299 } 300 301 /** 302 * If this is set to true, the Client will automatically throw exceptions 303 * upon HTTP errors. 304 * 305 * This means that if a response came back with a status code greater than 306 * or equal to 400, we will throw a ClientHttpException. 307 * 308 * This only works for the send() method. Throwing exceptions for 309 * sendAsync() is not supported. 310 * 311 * @param bool $throwExceptions 312 * @return void 313 */ 314 function setThrowExceptions($throwExceptions) { 315 316 $this->throwExceptions = $throwExceptions; 317 318 } 319 320 /** 321 * Adds a CURL setting. 322 * 323 * These settings will be included in every HTTP request. 324 * 325 * @param int $name 326 * @param mixed $value 327 * @return void 328 */ 329 function addCurlSetting($name, $value) { 330 331 $this->curlSettings[$name] = $value; 332 333 } 334 335 /** 336 * This method is responsible for performing a single request. 337 * 338 * @param RequestInterface $request 339 * @return ResponseInterface 340 */ 341 protected function doRequest(RequestInterface $request) { 342 343 $settings = $this->createCurlSettingsArray($request); 344 345 if (!$this->curlHandle) { 346 $this->curlHandle = curl_init(); 347 } 348 349 curl_setopt_array($this->curlHandle, $settings); 350 $response = $this->curlExec($this->curlHandle); 351 $response = $this->parseCurlResult($response, $this->curlHandle); 352 353 if ($response['status'] === self::STATUS_CURLERROR) { 354 throw new ClientException($response['curl_errmsg'], $response['curl_errno']); 355 } 356 357 return $response['response']; 358 359 } 360 361 /** 362 * Cached curl handle. 363 * 364 * By keeping this resource around for the lifetime of this object, things 365 * like persistent connections are possible. 366 * 367 * @var resource 368 */ 369 private $curlHandle; 370 371 /** 372 * Handler for curl_multi requests. 373 * 374 * The first time sendAsync is used, this will be created. 375 * 376 * @var resource 377 */ 378 private $curlMultiHandle; 379 380 /** 381 * Has a list of curl handles, as well as their associated success and 382 * error callbacks. 383 * 384 * @var array 385 */ 386 private $curlMultiMap = []; 387 388 /** 389 * Turns a RequestInterface object into an array with settings that can be 390 * fed to curl_setopt 391 * 392 * @param RequestInterface $request 393 * @return array 394 */ 395 protected function createCurlSettingsArray(RequestInterface $request) { 396 397 $settings = $this->curlSettings; 398 399 switch ($request->getMethod()) { 400 case 'HEAD' : 401 $settings[CURLOPT_NOBODY] = true; 402 $settings[CURLOPT_CUSTOMREQUEST] = 'HEAD'; 403 $settings[CURLOPT_POSTFIELDS] = ''; 404 $settings[CURLOPT_PUT] = false; 405 break; 406 case 'GET' : 407 $settings[CURLOPT_CUSTOMREQUEST] = 'GET'; 408 $settings[CURLOPT_POSTFIELDS] = ''; 409 $settings[CURLOPT_PUT] = false; 410 break; 411 default : 412 $body = $request->getBody(); 413 if (is_resource($body)) { 414 // This needs to be set to PUT, regardless of the actual 415 // method used. Without it, INFILE will be ignored for some 416 // reason. 417 $settings[CURLOPT_PUT] = true; 418 $settings[CURLOPT_INFILE] = $request->getBody(); 419 } else { 420 // For security we cast this to a string. If somehow an array could 421 // be passed here, it would be possible for an attacker to use @ to 422 // post local files. 423 $settings[CURLOPT_POSTFIELDS] = (string)$body; 424 } 425 $settings[CURLOPT_CUSTOMREQUEST] = $request->getMethod(); 426 break; 427 428 } 429 430 $nHeaders = []; 431 foreach ($request->getHeaders() as $key => $values) { 432 433 foreach ($values as $value) { 434 $nHeaders[] = $key . ': ' . $value; 435 } 436 437 } 438 $settings[CURLOPT_HTTPHEADER] = $nHeaders; 439 $settings[CURLOPT_URL] = $request->getUrl(); 440 // FIXME: CURLOPT_PROTOCOLS is currently unsupported by HHVM 441 if (defined('CURLOPT_PROTOCOLS')) { 442 $settings[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS; 443 } 444 // FIXME: CURLOPT_REDIR_PROTOCOLS is currently unsupported by HHVM 445 if (defined('CURLOPT_REDIR_PROTOCOLS')) { 446 $settings[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS; 447 } 448 449 return $settings; 450 451 } 452 453 const STATUS_SUCCESS = 0; 454 const STATUS_CURLERROR = 1; 455 const STATUS_HTTPERROR = 2; 456 457 /** 458 * Parses the result of a curl call in a format that's a bit more 459 * convenient to work with. 460 * 461 * The method returns an array with the following elements: 462 * * status - one of the 3 STATUS constants. 463 * * curl_errno - A curl error number. Only set if status is 464 * STATUS_CURLERROR. 465 * * curl_errmsg - A current error message. Only set if status is 466 * STATUS_CURLERROR. 467 * * response - Response object. Only set if status is STATUS_SUCCESS, or 468 * STATUS_HTTPERROR. 469 * * http_code - HTTP status code, as an int. Only set if Only set if 470 * status is STATUS_SUCCESS, or STATUS_HTTPERROR 471 * 472 * @param string $response 473 * @param resource $curlHandle 474 * @return Response 475 */ 476 protected function parseCurlResult($response, $curlHandle) { 477 478 list( 479 $curlInfo, 480 $curlErrNo, 481 $curlErrMsg 482 ) = $this->curlStuff($curlHandle); 483 484 if ($curlErrNo) { 485 return [ 486 'status' => self::STATUS_CURLERROR, 487 'curl_errno' => $curlErrNo, 488 'curl_errmsg' => $curlErrMsg, 489 ]; 490 } 491 492 $headerBlob = substr($response, 0, $curlInfo['header_size']); 493 // In the case of 204 No Content, strlen($response) == $curlInfo['header_size]. 494 // This will cause substr($response, $curlInfo['header_size']) return FALSE instead of NULL 495 // An exception will be thrown when calling getBodyAsString then 496 $responseBody = substr($response, $curlInfo['header_size']) ?: null; 497 498 unset($response); 499 500 // In the case of 100 Continue, or redirects we'll have multiple lists 501 // of headers for each separate HTTP response. We can easily split this 502 // because they are separated by \r\n\r\n 503 $headerBlob = explode("\r\n\r\n", trim($headerBlob, "\r\n")); 504 505 // We only care about the last set of headers 506 $headerBlob = $headerBlob[count($headerBlob) - 1]; 507 508 // Splitting headers 509 $headerBlob = explode("\r\n", $headerBlob); 510 511 $response = new Response(); 512 $response->setStatus($curlInfo['http_code']); 513 514 foreach ($headerBlob as $header) { 515 $parts = explode(':', $header, 2); 516 if (count($parts) == 2) { 517 $response->addHeader(trim($parts[0]), trim($parts[1])); 518 } 519 } 520 521 $response->setBody($responseBody); 522 523 $httpCode = intval($response->getStatus()); 524 525 return [ 526 'status' => $httpCode >= 400 ? self::STATUS_HTTPERROR : self::STATUS_SUCCESS, 527 'response' => $response, 528 'http_code' => $httpCode, 529 ]; 530 531 } 532 533 /** 534 * Sends an asynchronous HTTP request. 535 * 536 * We keep this in a separate method, so we can call it without triggering 537 * the beforeRequest event and don't do the poll(). 538 * 539 * @param RequestInterface $request 540 * @param callable $success 541 * @param callable $error 542 * @param int $retryCount 543 */ 544 protected function sendAsyncInternal(RequestInterface $request, callable $success, callable $error, $retryCount = 0) { 545 546 if (!$this->curlMultiHandle) { 547 $this->curlMultiHandle = curl_multi_init(); 548 } 549 $curl = curl_init(); 550 curl_setopt_array( 551 $curl, 552 $this->createCurlSettingsArray($request) 553 ); 554 curl_multi_add_handle($this->curlMultiHandle, $curl); 555 $this->curlMultiMap[intval($curl)] = [ 556 $request, 557 $success, 558 $error, 559 $retryCount 560 ]; 561 562 } 563 564 // @codeCoverageIgnoreStart 565 566 /** 567 * Calls curl_exec 568 * 569 * This method exists so it can easily be overridden and mocked. 570 * 571 * @param resource $curlHandle 572 * @return string 573 */ 574 protected function curlExec($curlHandle) { 575 576 return curl_exec($curlHandle); 577 578 } 579 580 /** 581 * Returns a bunch of information about a curl request. 582 * 583 * This method exists so it can easily be overridden and mocked. 584 * 585 * @param resource $curlHandle 586 * @return array 587 */ 588 protected function curlStuff($curlHandle) { 589 590 return [ 591 curl_getinfo($curlHandle), 592 curl_errno($curlHandle), 593 curl_error($curlHandle), 594 ]; 595 596 } 597 // @codeCoverageIgnoreEnd 598 599} 600