Client.php 20 KB

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