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