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