1<?php 2namespace GuzzleHttp\Ring\Client; 3 4use GuzzleHttp\Ring\Core; 5use GuzzleHttp\Ring\Exception\ConnectException; 6use GuzzleHttp\Ring\Exception\RingException; 7use GuzzleHttp\Ring\Future\CompletedFutureArray; 8use GuzzleHttp\Stream\InflateStream; 9use GuzzleHttp\Stream\StreamInterface; 10use GuzzleHttp\Stream\Stream; 11use GuzzleHttp\Stream\Utils; 12 13/** 14 * RingPHP client handler that uses PHP's HTTP stream wrapper. 15 */ 16class StreamHandler 17{ 18 private $options; 19 private $lastHeaders; 20 21 public function __construct(array $options = []) 22 { 23 $this->options = $options; 24 } 25 26 public function __invoke(array $request) 27 { 28 $url = Core::url($request); 29 Core::doSleep($request); 30 31 try { 32 // Does not support the expect header. 33 $request = Core::removeHeader($request, 'Expect'); 34 $stream = $this->createStream($url, $request); 35 return $this->createResponse($request, $url, $stream); 36 } catch (RingException $e) { 37 return $this->createErrorResponse($url, $e); 38 } 39 } 40 41 private function createResponse(array $request, $url, $stream) 42 { 43 $hdrs = $this->lastHeaders; 44 $this->lastHeaders = null; 45 $parts = explode(' ', array_shift($hdrs), 3); 46 $response = [ 47 'version' => substr($parts[0], 5), 48 'status' => $parts[1], 49 'reason' => isset($parts[2]) ? $parts[2] : null, 50 'headers' => Core::headersFromLines($hdrs), 51 'effective_url' => $url, 52 ]; 53 54 $stream = $this->checkDecode($request, $response, $stream); 55 56 // If not streaming, then drain the response into a stream. 57 if (empty($request['client']['stream'])) { 58 $dest = isset($request['client']['save_to']) 59 ? $request['client']['save_to'] 60 : fopen('php://temp', 'r+'); 61 $stream = $this->drain($stream, $dest); 62 } 63 64 $response['body'] = $stream; 65 66 return new CompletedFutureArray($response); 67 } 68 69 private function checkDecode(array $request, array $response, $stream) 70 { 71 // Automatically decode responses when instructed. 72 if (!empty($request['client']['decode_content'])) { 73 switch (Core::firstHeader($response, 'Content-Encoding', true)) { 74 case 'gzip': 75 case 'deflate': 76 $stream = new InflateStream(Stream::factory($stream)); 77 break; 78 } 79 } 80 81 return $stream; 82 } 83 84 /** 85 * Drains the stream into the "save_to" client option. 86 * 87 * @param resource $stream 88 * @param string|resource|StreamInterface $dest 89 * 90 * @return Stream 91 * @throws \RuntimeException when the save_to option is invalid. 92 */ 93 private function drain($stream, $dest) 94 { 95 if (is_resource($stream)) { 96 if (!is_resource($dest)) { 97 $stream = Stream::factory($stream); 98 } else { 99 stream_copy_to_stream($stream, $dest); 100 fclose($stream); 101 rewind($dest); 102 return $dest; 103 } 104 } 105 106 // Stream the response into the destination stream 107 $dest = is_string($dest) 108 ? new Stream(Utils::open($dest, 'r+')) 109 : Stream::factory($dest); 110 111 Utils::copyToStream($stream, $dest); 112 $dest->seek(0); 113 $stream->close(); 114 115 return $dest; 116 } 117 118 /** 119 * Creates an error response for the given stream. 120 * 121 * @param string $url 122 * @param RingException $e 123 * 124 * @return array 125 */ 126 private function createErrorResponse($url, RingException $e) 127 { 128 // Determine if the error was a networking error. 129 $message = $e->getMessage(); 130 131 // This list can probably get more comprehensive. 132 if (strpos($message, 'getaddrinfo') // DNS lookup failed 133 || strpos($message, 'Connection refused') 134 ) { 135 $e = new ConnectException($e->getMessage(), 0, $e); 136 } 137 138 return new CompletedFutureArray([ 139 'status' => null, 140 'body' => null, 141 'headers' => [], 142 'effective_url' => $url, 143 'error' => $e 144 ]); 145 } 146 147 /** 148 * Create a resource and check to ensure it was created successfully 149 * 150 * @param callable $callback Callable that returns stream resource 151 * 152 * @return resource 153 * @throws \RuntimeException on error 154 */ 155 private function createResource(callable $callback) 156 { 157 $errors = null; 158 set_error_handler(function ($_, $msg, $file, $line) use (&$errors) { 159 $errors[] = [ 160 'message' => $msg, 161 'file' => $file, 162 'line' => $line 163 ]; 164 return true; 165 }); 166 167 $resource = $callback(); 168 restore_error_handler(); 169 170 if (!$resource) { 171 $message = 'Error creating resource: '; 172 foreach ($errors as $err) { 173 foreach ($err as $key => $value) { 174 $message .= "[$key] $value" . PHP_EOL; 175 } 176 } 177 throw new RingException(trim($message)); 178 } 179 180 return $resource; 181 } 182 183 private function createStream($url, array $request) 184 { 185 static $methods; 186 if (!$methods) { 187 $methods = array_flip(get_class_methods(__CLASS__)); 188 } 189 190 // HTTP/1.1 streams using the PHP stream wrapper require a 191 // Connection: close header 192 if ((!isset($request['version']) || $request['version'] == '1.1') 193 && !Core::hasHeader($request, 'Connection') 194 ) { 195 $request['headers']['Connection'] = ['close']; 196 } 197 198 // Ensure SSL is verified by default 199 if (!isset($request['client']['verify'])) { 200 $request['client']['verify'] = true; 201 } 202 203 $params = []; 204 $options = $this->getDefaultOptions($request); 205 206 if (isset($request['client'])) { 207 foreach ($request['client'] as $key => $value) { 208 $method = "add_{$key}"; 209 if (isset($methods[$method])) { 210 $this->{$method}($request, $options, $value, $params); 211 } 212 } 213 } 214 215 return $this->createStreamResource( 216 $url, 217 $request, 218 $options, 219 $this->createContext($request, $options, $params) 220 ); 221 } 222 223 private function getDefaultOptions(array $request) 224 { 225 $headers = ""; 226 foreach ($request['headers'] as $name => $value) { 227 foreach ((array) $value as $val) { 228 $headers .= "$name: $val\r\n"; 229 } 230 } 231 232 $context = [ 233 'http' => [ 234 'method' => $request['http_method'], 235 'header' => $headers, 236 'protocol_version' => isset($request['version']) ? $request['version'] : 1.1, 237 'ignore_errors' => true, 238 'follow_location' => 0, 239 ], 240 ]; 241 242 $body = Core::body($request); 243 if (isset($body)) { 244 $context['http']['content'] = $body; 245 // Prevent the HTTP handler from adding a Content-Type header. 246 if (!Core::hasHeader($request, 'Content-Type')) { 247 $context['http']['header'] .= "Content-Type:\r\n"; 248 } 249 } 250 251 $context['http']['header'] = rtrim($context['http']['header']); 252 253 return $context; 254 } 255 256 private function add_proxy(array $request, &$options, $value, &$params) 257 { 258 if (!is_array($value)) { 259 $options['http']['proxy'] = $value; 260 } else { 261 $scheme = isset($request['scheme']) ? $request['scheme'] : 'http'; 262 if (isset($value[$scheme])) { 263 $options['http']['proxy'] = $value[$scheme]; 264 } 265 } 266 } 267 268 private function add_timeout(array $request, &$options, $value, &$params) 269 { 270 $options['http']['timeout'] = $value; 271 } 272 273 private function add_verify(array $request, &$options, $value, &$params) 274 { 275 if ($value === true) { 276 // PHP 5.6 or greater will find the system cert by default. When 277 // < 5.6, use the Guzzle bundled cacert. 278 if (PHP_VERSION_ID < 50600) { 279 $options['ssl']['cafile'] = ClientUtils::getDefaultCaBundle(); 280 } 281 } elseif (is_string($value)) { 282 $options['ssl']['cafile'] = $value; 283 if (!file_exists($value)) { 284 throw new RingException("SSL CA bundle not found: $value"); 285 } 286 } elseif ($value === false) { 287 $options['ssl']['verify_peer'] = false; 288 $options['ssl']['allow_self_signed'] = true; 289 return; 290 } else { 291 throw new RingException('Invalid verify request option'); 292 } 293 294 $options['ssl']['verify_peer'] = true; 295 $options['ssl']['allow_self_signed'] = false; 296 } 297 298 private function add_cert(array $request, &$options, $value, &$params) 299 { 300 if (is_array($value)) { 301 $options['ssl']['passphrase'] = $value[1]; 302 $value = $value[0]; 303 } 304 305 if (!file_exists($value)) { 306 throw new RingException("SSL certificate not found: {$value}"); 307 } 308 309 $options['ssl']['local_cert'] = $value; 310 } 311 312 private function add_progress(array $request, &$options, $value, &$params) 313 { 314 $fn = function ($code, $_1, $_2, $_3, $transferred, $total) use ($value) { 315 if ($code == STREAM_NOTIFY_PROGRESS) { 316 $value($total, $transferred, null, null); 317 } 318 }; 319 320 // Wrap the existing function if needed. 321 $params['notification'] = isset($params['notification']) 322 ? Core::callArray([$params['notification'], $fn]) 323 : $fn; 324 } 325 326 private function add_debug(array $request, &$options, $value, &$params) 327 { 328 if ($value === false) { 329 return; 330 } 331 332 static $map = [ 333 STREAM_NOTIFY_CONNECT => 'CONNECT', 334 STREAM_NOTIFY_AUTH_REQUIRED => 'AUTH_REQUIRED', 335 STREAM_NOTIFY_AUTH_RESULT => 'AUTH_RESULT', 336 STREAM_NOTIFY_MIME_TYPE_IS => 'MIME_TYPE_IS', 337 STREAM_NOTIFY_FILE_SIZE_IS => 'FILE_SIZE_IS', 338 STREAM_NOTIFY_REDIRECTED => 'REDIRECTED', 339 STREAM_NOTIFY_PROGRESS => 'PROGRESS', 340 STREAM_NOTIFY_FAILURE => 'FAILURE', 341 STREAM_NOTIFY_COMPLETED => 'COMPLETED', 342 STREAM_NOTIFY_RESOLVE => 'RESOLVE', 343 ]; 344 345 static $args = ['severity', 'message', 'message_code', 346 'bytes_transferred', 'bytes_max']; 347 348 $value = Core::getDebugResource($value); 349 $ident = $request['http_method'] . ' ' . Core::url($request); 350 $fn = function () use ($ident, $value, $map, $args) { 351 $passed = func_get_args(); 352 $code = array_shift($passed); 353 fprintf($value, '<%s> [%s] ', $ident, $map[$code]); 354 foreach (array_filter($passed) as $i => $v) { 355 fwrite($value, $args[$i] . ': "' . $v . '" '); 356 } 357 fwrite($value, "\n"); 358 }; 359 360 // Wrap the existing function if needed. 361 $params['notification'] = isset($params['notification']) 362 ? Core::callArray([$params['notification'], $fn]) 363 : $fn; 364 } 365 366 private function applyCustomOptions(array $request, array &$options) 367 { 368 if (!isset($request['client']['stream_context'])) { 369 return; 370 } 371 372 if (!is_array($request['client']['stream_context'])) { 373 throw new RingException('stream_context must be an array'); 374 } 375 376 $options = array_replace_recursive( 377 $options, 378 $request['client']['stream_context'] 379 ); 380 } 381 382 private function createContext(array $request, array $options, array $params) 383 { 384 $this->applyCustomOptions($request, $options); 385 return $this->createResource( 386 function () use ($request, $options, $params) { 387 return stream_context_create($options, $params); 388 }, 389 $request, 390 $options 391 ); 392 } 393 394 private function createStreamResource( 395 $url, 396 array $request, 397 array $options, 398 $context 399 ) { 400 return $this->createResource( 401 function () use ($url, $context) { 402 if (false === strpos($url, 'http')) { 403 trigger_error("URL is invalid: {$url}", E_USER_WARNING); 404 return null; 405 } 406 $resource = fopen($url, 'r', null, $context); 407 $this->lastHeaders = $http_response_header; 408 return $resource; 409 }, 410 $request, 411 $options 412 ); 413 } 414} 415