1<?php 2namespace GuzzleHttp\Ring\Client; 3 4use GuzzleHttp\Ring\Core; 5use GuzzleHttp\Ring\Exception\ConnectException; 6use GuzzleHttp\Ring\Exception\RingException; 7use GuzzleHttp\Stream\LazyOpenStream; 8use GuzzleHttp\Stream\StreamInterface; 9 10/** 11 * Creates curl resources from a request 12 */ 13class CurlFactory 14{ 15 /** 16 * Creates a cURL handle, header resource, and body resource based on a 17 * transaction. 18 * 19 * @param array $request Request hash 20 * @param null|resource $handle Optionally provide a curl handle to modify 21 * 22 * @return array Returns an array of the curl handle, headers array, and 23 * response body handle. 24 * @throws \RuntimeException when an option cannot be applied 25 */ 26 public function __invoke(array $request, $handle = null) 27 { 28 $headers = []; 29 $options = $this->getDefaultOptions($request, $headers); 30 $this->applyMethod($request, $options); 31 32 if (isset($request['client'])) { 33 $this->applyHandlerOptions($request, $options); 34 } 35 36 $this->applyHeaders($request, $options); 37 unset($options['_headers']); 38 39 // Add handler options from the request's configuration options 40 if (isset($request['client']['curl'])) { 41 $options = $this->applyCustomCurlOptions( 42 $request['client']['curl'], 43 $options 44 ); 45 } 46 47 if (!$handle) { 48 $handle = curl_init(); 49 } 50 51 $body = $this->getOutputBody($request, $options); 52 curl_setopt_array($handle, $options); 53 54 return [$handle, &$headers, $body]; 55 } 56 57 /** 58 * Creates a response hash from a cURL result. 59 * 60 * @param callable $handler Handler that was used. 61 * @param array $request Request that sent. 62 * @param array $response Response hash to update. 63 * @param array $headers Headers received during transfer. 64 * @param resource $body Body fopen response. 65 * 66 * @return array 67 */ 68 public static function createResponse( 69 callable $handler, 70 array $request, 71 array $response, 72 array $headers, 73 $body 74 ) { 75 if (isset($response['transfer_stats']['url'])) { 76 $response['effective_url'] = $response['transfer_stats']['url']; 77 } 78 79 if (!empty($headers)) { 80 $startLine = explode(' ', array_shift($headers), 3); 81 $headerList = Core::headersFromLines($headers); 82 $response['headers'] = $headerList; 83 $response['version'] = isset($startLine[0]) ? substr($startLine[0], 5) : null; 84 $response['status'] = isset($startLine[1]) ? (int) $startLine[1] : null; 85 $response['reason'] = isset($startLine[2]) ? $startLine[2] : null; 86 $response['body'] = $body; 87 Core::rewindBody($response); 88 } 89 90 return !empty($response['curl']['errno']) || !isset($response['status']) 91 ? self::createErrorResponse($handler, $request, $response) 92 : $response; 93 } 94 95 private static function createErrorResponse( 96 callable $handler, 97 array $request, 98 array $response 99 ) { 100 static $connectionErrors = [ 101 CURLE_OPERATION_TIMEOUTED => true, 102 CURLE_COULDNT_RESOLVE_HOST => true, 103 CURLE_COULDNT_CONNECT => true, 104 CURLE_SSL_CONNECT_ERROR => true, 105 CURLE_GOT_NOTHING => true, 106 ]; 107 108 // Retry when nothing is present or when curl failed to rewind. 109 if (!isset($response['err_message']) 110 && (empty($response['curl']['errno']) 111 || $response['curl']['errno'] == 65) 112 ) { 113 return self::retryFailedRewind($handler, $request, $response); 114 } 115 116 $message = isset($response['err_message']) 117 ? $response['err_message'] 118 : sprintf('cURL error %s: %s', 119 $response['curl']['errno'], 120 isset($response['curl']['error']) 121 ? $response['curl']['error'] 122 : 'See http://curl.haxx.se/libcurl/c/libcurl-errors.html'); 123 124 $error = isset($response['curl']['errno']) 125 && isset($connectionErrors[$response['curl']['errno']]) 126 ? new ConnectException($message) 127 : new RingException($message); 128 129 return $response + [ 130 'status' => null, 131 'reason' => null, 132 'body' => null, 133 'headers' => [], 134 'error' => $error, 135 ]; 136 } 137 138 private function getOutputBody(array $request, array &$options) 139 { 140 // Determine where the body of the response (if any) will be streamed. 141 if (isset($options[CURLOPT_WRITEFUNCTION])) { 142 return $request['client']['save_to']; 143 } 144 145 if (isset($options[CURLOPT_FILE])) { 146 return $options[CURLOPT_FILE]; 147 } 148 149 if ($request['http_method'] != 'HEAD') { 150 // Create a default body if one was not provided 151 return $options[CURLOPT_FILE] = fopen('php://temp', 'w+'); 152 } 153 154 return null; 155 } 156 157 private function getDefaultOptions(array $request, array &$headers) 158 { 159 $url = Core::url($request); 160 $startingResponse = false; 161 162 $options = [ 163 '_headers' => $request['headers'], 164 CURLOPT_CUSTOMREQUEST => $request['http_method'], 165 CURLOPT_URL => $url, 166 CURLOPT_RETURNTRANSFER => false, 167 CURLOPT_HEADER => false, 168 CURLOPT_CONNECTTIMEOUT => 150, 169 CURLOPT_HEADERFUNCTION => function ($ch, $h) use (&$headers, &$startingResponse) { 170 $value = trim($h); 171 if ($value === '') { 172 $startingResponse = true; 173 } elseif ($startingResponse) { 174 $startingResponse = false; 175 $headers = [$value]; 176 } else { 177 $headers[] = $value; 178 } 179 return strlen($h); 180 }, 181 ]; 182 183 if (isset($request['version'])) { 184 if ($request['version'] == 2.0) { 185 $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0; 186 } else if ($request['version'] == 1.1) { 187 $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1; 188 } else { 189 $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0; 190 } 191 } 192 193 if (defined('CURLOPT_PROTOCOLS')) { 194 $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS; 195 } 196 197 return $options; 198 } 199 200 private function applyMethod(array $request, array &$options) 201 { 202 if (isset($request['body'])) { 203 $this->applyBody($request, $options); 204 return; 205 } 206 207 switch ($request['http_method']) { 208 case 'PUT': 209 case 'POST': 210 // See http://tools.ietf.org/html/rfc7230#section-3.3.2 211 if (!Core::hasHeader($request, 'Content-Length')) { 212 $options[CURLOPT_HTTPHEADER][] = 'Content-Length: 0'; 213 } 214 break; 215 case 'HEAD': 216 $options[CURLOPT_NOBODY] = true; 217 unset( 218 $options[CURLOPT_WRITEFUNCTION], 219 $options[CURLOPT_READFUNCTION], 220 $options[CURLOPT_FILE], 221 $options[CURLOPT_INFILE] 222 ); 223 } 224 } 225 226 private function applyBody(array $request, array &$options) 227 { 228 $contentLength = Core::firstHeader($request, 'Content-Length'); 229 $size = $contentLength !== null ? (int) $contentLength : null; 230 231 // Send the body as a string if the size is less than 1MB OR if the 232 // [client][curl][body_as_string] request value is set. 233 if (($size !== null && $size < 1000000) || 234 isset($request['client']['curl']['body_as_string']) || 235 is_string($request['body']) 236 ) { 237 $options[CURLOPT_POSTFIELDS] = Core::body($request); 238 // Don't duplicate the Content-Length header 239 $this->removeHeader('Content-Length', $options); 240 $this->removeHeader('Transfer-Encoding', $options); 241 } else { 242 $options[CURLOPT_UPLOAD] = true; 243 if ($size !== null) { 244 // Let cURL handle setting the Content-Length header 245 $options[CURLOPT_INFILESIZE] = $size; 246 $this->removeHeader('Content-Length', $options); 247 } 248 $this->addStreamingBody($request, $options); 249 } 250 251 // If the Expect header is not present, prevent curl from adding it 252 if (!Core::hasHeader($request, 'Expect')) { 253 $options[CURLOPT_HTTPHEADER][] = 'Expect:'; 254 } 255 256 // cURL sometimes adds a content-type by default. Prevent this. 257 if (!Core::hasHeader($request, 'Content-Type')) { 258 $options[CURLOPT_HTTPHEADER][] = 'Content-Type:'; 259 } 260 } 261 262 private function addStreamingBody(array $request, array &$options) 263 { 264 $body = $request['body']; 265 266 if ($body instanceof StreamInterface) { 267 $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body) { 268 return (string) $body->read($length); 269 }; 270 if (!isset($options[CURLOPT_INFILESIZE])) { 271 if ($size = $body->getSize()) { 272 $options[CURLOPT_INFILESIZE] = $size; 273 } 274 } 275 } elseif (is_resource($body)) { 276 $options[CURLOPT_INFILE] = $body; 277 } elseif ($body instanceof \Iterator) { 278 $buf = ''; 279 $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body, &$buf) { 280 if ($body->valid()) { 281 $buf .= $body->current(); 282 $body->next(); 283 } 284 $result = (string) substr($buf, 0, $length); 285 $buf = substr($buf, $length); 286 return $result; 287 }; 288 } else { 289 throw new \InvalidArgumentException('Invalid request body provided'); 290 } 291 } 292 293 private function applyHeaders(array $request, array &$options) 294 { 295 foreach ($options['_headers'] as $name => $values) { 296 foreach ($values as $value) { 297 $options[CURLOPT_HTTPHEADER][] = "$name: $value"; 298 } 299 } 300 301 // Remove the Accept header if one was not set 302 if (!Core::hasHeader($request, 'Accept')) { 303 $options[CURLOPT_HTTPHEADER][] = 'Accept:'; 304 } 305 } 306 307 /** 308 * Takes an array of curl options specified in the 'curl' option of a 309 * request's configuration array and maps them to CURLOPT_* options. 310 * 311 * This method is only called when a request has a 'curl' config setting. 312 * 313 * @param array $config Configuration array of custom curl option 314 * @param array $options Array of existing curl options 315 * 316 * @return array Returns a new array of curl options 317 */ 318 private function applyCustomCurlOptions(array $config, array $options) 319 { 320 $curlOptions = []; 321 foreach ($config as $key => $value) { 322 if (is_int($key)) { 323 $curlOptions[$key] = $value; 324 } 325 } 326 327 return $curlOptions + $options; 328 } 329 330 /** 331 * Remove a header from the options array. 332 * 333 * @param string $name Case-insensitive header to remove 334 * @param array $options Array of options to modify 335 */ 336 private function removeHeader($name, array &$options) 337 { 338 foreach (array_keys($options['_headers']) as $key) { 339 if (!strcasecmp($key, $name)) { 340 unset($options['_headers'][$key]); 341 return; 342 } 343 } 344 } 345 346 /** 347 * Applies an array of request client options to a the options array. 348 * 349 * This method uses a large switch rather than double-dispatch to save on 350 * high overhead of calling functions in PHP. 351 */ 352 private function applyHandlerOptions(array $request, array &$options) 353 { 354 foreach ($request['client'] as $key => $value) { 355 switch ($key) { 356 // Violating PSR-4 to provide more room. 357 case 'verify': 358 359 if ($value === false) { 360 unset($options[CURLOPT_CAINFO]); 361 $options[CURLOPT_SSL_VERIFYHOST] = 0; 362 $options[CURLOPT_SSL_VERIFYPEER] = false; 363 continue 2; 364 } 365 366 $options[CURLOPT_SSL_VERIFYHOST] = 2; 367 $options[CURLOPT_SSL_VERIFYPEER] = true; 368 369 if (is_string($value)) { 370 $options[CURLOPT_CAINFO] = $value; 371 if (!file_exists($value)) { 372 throw new \InvalidArgumentException( 373 "SSL CA bundle not found: $value" 374 ); 375 } 376 } 377 break; 378 379 case 'decode_content': 380 381 if ($value === false) { 382 continue 2; 383 } 384 385 $accept = Core::firstHeader($request, 'Accept-Encoding'); 386 if ($accept) { 387 $options[CURLOPT_ENCODING] = $accept; 388 } else { 389 $options[CURLOPT_ENCODING] = ''; 390 // Don't let curl send the header over the wire 391 $options[CURLOPT_HTTPHEADER][] = 'Accept-Encoding:'; 392 } 393 break; 394 395 case 'save_to': 396 397 if (is_string($value)) { 398 if (!is_dir(dirname($value))) { 399 throw new \RuntimeException(sprintf( 400 'Directory %s does not exist for save_to value of %s', 401 dirname($value), 402 $value 403 )); 404 } 405 $value = new LazyOpenStream($value, 'w+'); 406 } 407 408 if ($value instanceof StreamInterface) { 409 $options[CURLOPT_WRITEFUNCTION] = 410 function ($ch, $write) use ($value) { 411 return $value->write($write); 412 }; 413 } elseif (is_resource($value)) { 414 $options[CURLOPT_FILE] = $value; 415 } else { 416 throw new \InvalidArgumentException('save_to must be a ' 417 . 'GuzzleHttp\Stream\StreamInterface or resource'); 418 } 419 break; 420 421 case 'timeout': 422 423 if (defined('CURLOPT_TIMEOUT_MS')) { 424 $options[CURLOPT_TIMEOUT_MS] = $value * 1000; 425 } else { 426 $options[CURLOPT_TIMEOUT] = $value; 427 } 428 break; 429 430 case 'connect_timeout': 431 432 if (defined('CURLOPT_CONNECTTIMEOUT_MS')) { 433 $options[CURLOPT_CONNECTTIMEOUT_MS] = $value * 1000; 434 } else { 435 $options[CURLOPT_CONNECTTIMEOUT] = $value; 436 } 437 break; 438 439 case 'proxy': 440 441 if (!is_array($value)) { 442 $options[CURLOPT_PROXY] = $value; 443 } elseif (isset($request['scheme'])) { 444 $scheme = $request['scheme']; 445 if (isset($value[$scheme])) { 446 $options[CURLOPT_PROXY] = $value[$scheme]; 447 } 448 } 449 break; 450 451 case 'cert': 452 453 if (is_array($value)) { 454 $options[CURLOPT_SSLCERTPASSWD] = $value[1]; 455 $value = $value[0]; 456 } 457 458 if (!file_exists($value)) { 459 throw new \InvalidArgumentException( 460 "SSL certificate not found: {$value}" 461 ); 462 } 463 464 $options[CURLOPT_SSLCERT] = $value; 465 break; 466 467 case 'ssl_key': 468 469 if (is_array($value)) { 470 $options[CURLOPT_SSLKEYPASSWD] = $value[1]; 471 $value = $value[0]; 472 } 473 474 if (!file_exists($value)) { 475 throw new \InvalidArgumentException( 476 "SSL private key not found: {$value}" 477 ); 478 } 479 480 $options[CURLOPT_SSLKEY] = $value; 481 break; 482 483 case 'progress': 484 485 if (!is_callable($value)) { 486 throw new \InvalidArgumentException( 487 'progress client option must be callable' 488 ); 489 } 490 491 $options[CURLOPT_NOPROGRESS] = false; 492 $options[CURLOPT_PROGRESSFUNCTION] = 493 function () use ($value) { 494 $args = func_get_args(); 495 // PHP 5.5 pushed the handle onto the start of the args 496 if (is_resource($args[0])) { 497 array_shift($args); 498 } 499 call_user_func_array($value, $args); 500 }; 501 break; 502 503 case 'debug': 504 505 if ($value) { 506 $options[CURLOPT_STDERR] = Core::getDebugResource($value); 507 $options[CURLOPT_VERBOSE] = true; 508 } 509 break; 510 } 511 } 512 } 513 514 /** 515 * This function ensures that a response was set on a transaction. If one 516 * was not set, then the request is retried if possible. This error 517 * typically means you are sending a payload, curl encountered a 518 * "Connection died, retrying a fresh connect" error, tried to rewind the 519 * stream, and then encountered a "necessary data rewind wasn't possible" 520 * error, causing the request to be sent through curl_multi_info_read() 521 * without an error status. 522 */ 523 private static function retryFailedRewind( 524 callable $handler, 525 array $request, 526 array $response 527 ) { 528 // If there is no body, then there is some other kind of issue. This 529 // is weird and should probably never happen. 530 if (!isset($request['body'])) { 531 $response['err_message'] = 'No response was received for a request ' 532 . 'with no body. This could mean that you are saturating your ' 533 . 'network.'; 534 return self::createErrorResponse($handler, $request, $response); 535 } 536 537 if (!Core::rewindBody($request)) { 538 $response['err_message'] = 'The connection unexpectedly failed ' 539 . 'without providing an error. The request would have been ' 540 . 'retried, but attempting to rewind the request body failed.'; 541 return self::createErrorResponse($handler, $request, $response); 542 } 543 544 // Retry no more than 3 times before giving up. 545 if (!isset($request['curl']['retries'])) { 546 $request['curl']['retries'] = 1; 547 } elseif ($request['curl']['retries'] == 2) { 548 $response['err_message'] = 'The cURL request was retried 3 times ' 549 . 'and did no succeed. cURL was unable to rewind the body of ' 550 . 'the request and subsequent retries resulted in the same ' 551 . 'error. Turn on the debug option to see what went wrong. ' 552 . 'See https://bugs.php.net/bug.php?id=47204 for more information.'; 553 return self::createErrorResponse($handler, $request, $response); 554 } else { 555 $request['curl']['retries']++; 556 } 557 558 return $handler($request); 559 } 560} 561