StreamHandler   F
last analyzed

Complexity

Total Complexity 102

Size/Duplication

Total Lines 559
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 14

Importance

Changes 0
Metric Value
dl 0
loc 559
rs 2
c 0
b 0
f 0
wmc 102
lcom 1
cbo 14

19 Methods

Rating   Name   Duplication   Size   Complexity  
B __invoke() 0 45 10
A invokeStats() 0 12 2
B createResponse() 0 49 7
A createSink() 0 10 3
B checkDecode() 0 30 7
A drain() 0 17 3
A createResource() 0 27 4
C createStream() 0 76 16
B resolveHost() 0 23 9
A getDefaultContext() 0 33 5
B add_proxy() 0 29 8
B parse_proxy() 0 24 9
A add_timeout() 0 6 2
A add_verify() 0 22 5
A add_cert() 0 13 3
A add_progress() 0 11 2
A add_debug() 0 33 3
A addNotification() 0 12 2
A callArray() 0 8 2

How to fix   Complexity   

Complex Class

Complex classes like StreamHandler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use StreamHandler, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace GuzzleHttp\Handler;
4
5
use GuzzleHttp\Exception\ConnectException;
6
use GuzzleHttp\Exception\RequestException;
7
use GuzzleHttp\Promise as P;
8
use GuzzleHttp\Promise\FulfilledPromise;
9
use GuzzleHttp\Promise\PromiseInterface;
10
use GuzzleHttp\Psr7;
11
use GuzzleHttp\TransferStats;
12
use GuzzleHttp\Utils;
13
use Psr\Http\Message\RequestInterface;
14
use Psr\Http\Message\ResponseInterface;
15
use Psr\Http\Message\StreamInterface;
16
use Psr\Http\Message\UriInterface;
17
18
/**
19
 * HTTP handler that uses PHP's HTTP stream wrapper.
20
 *
21
 * @final
22
 */
23
class StreamHandler
24
{
25
    /**
26
     * @var array
27
     */
28
    private $lastHeaders = [];
29
30
    /**
31
     * Sends an HTTP request.
32
     *
33
     * @param RequestInterface $request Request to send.
34
     * @param array            $options Request transfer options.
35
     */
36
    public function __invoke(RequestInterface $request, array $options): PromiseInterface
37
    {
38
        // Sleep if there is a delay specified.
39
        if (isset($options['delay'])) {
40
            \usleep($options['delay'] * 1000);
41
        }
42
43
        $startTime = isset($options['on_stats']) ? Utils::currentTime() : null;
44
45
        try {
46
            // Does not support the expect header.
47
            $request = $request->withoutHeader('Expect');
48
49
            // Append a content-length header if body size is zero to match
50
            // cURL's behavior.
51
            if (0 === $request->getBody()->getSize()) {
52
                $request = $request->withHeader('Content-Length', '0');
53
            }
54
55
            return $this->createResponse(
56
                $request,
57
                $options,
58
                $this->createStream($request, $options),
59
                $startTime
60
            );
61
        } catch (\InvalidArgumentException $e) {
62
            throw $e;
63
        } catch (\Exception $e) {
64
            // Determine if the error was a networking error.
65
            $message = $e->getMessage();
66
            // This list can probably get more comprehensive.
67
            if (false !== \strpos($message, 'getaddrinfo') // DNS lookup failed
68
                || false !== \strpos($message, 'Connection refused')
69
                || false !== \strpos($message, "couldn't connect to host") // error on HHVM
70
                || false !== \strpos($message, "connection attempt failed")
71
            ) {
72
                $e = new ConnectException($e->getMessage(), $request, $e);
73
            } else {
74
                $e = RequestException::wrapException($request, $e);
75
            }
76
            $this->invokeStats($options, $request, $startTime, null, $e);
77
78
            return P\Create::rejectionFor($e);
79
        }
80
    }
81
82
    private function invokeStats(
83
        array $options,
84
        RequestInterface $request,
85
        ?float $startTime,
86
        ResponseInterface $response = null,
87
        \Throwable $error = null
88
    ): void {
89
        if (isset($options['on_stats'])) {
90
            $stats = new TransferStats($request, $response, Utils::currentTime() - $startTime, $error, []);
91
            ($options['on_stats'])($stats);
92
        }
93
    }
94
95
    /**
96
     * @param resource $stream
97
     */
98
    private function createResponse(RequestInterface $request, array $options, $stream, ?float $startTime): PromiseInterface
99
    {
100
        $hdrs = $this->lastHeaders;
101
        $this->lastHeaders = [];
102
103
        try {
104
            [$ver, $status, $reason, $headers] = HeaderProcessor::parseHeaders($hdrs);
0 ignored issues
show
Bug introduced by
The variable $headers does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $status does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $reason does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $ver does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
105
        } catch (\Exception $e) {
106
            return P\Create::rejectionFor(
107
                new RequestException('An error was encountered while creating the response', $request, null, $e)
108
            );
109
        }
110
111
        [$stream, $headers] = $this->checkDecode($options, $headers, $stream);
112
        $stream = Psr7\Utils::streamFor($stream);
113
        $sink = $stream;
114
115
        if (\strcasecmp('HEAD', $request->getMethod())) {
116
            $sink = $this->createSink($stream, $options);
117
        }
118
119
        try {
120
            $response = new Psr7\Response($status, $headers, $sink, $ver, $reason);
121
        } catch (\Exception $e) {
122
            return P\Create::rejectionFor(
123
                new RequestException('An error was encountered while creating the response', $request, null, $e)
124
            );
125
        }
126
127
        if (isset($options['on_headers'])) {
128
            try {
129
                $options['on_headers']($response);
130
            } catch (\Exception $e) {
131
                return P\Create::rejectionFor(
132
                    new RequestException('An error was encountered during the on_headers event', $request, $response, $e)
133
                );
134
            }
135
        }
136
137
        // Do not drain when the request is a HEAD request because they have
138
        // no body.
139
        if ($sink !== $stream) {
140
            $this->drain($stream, $sink, $response->getHeaderLine('Content-Length'));
141
        }
142
143
        $this->invokeStats($options, $request, $startTime, $response, null);
144
145
        return new FulfilledPromise($response);
146
    }
147
148
    private function createSink(StreamInterface $stream, array $options): StreamInterface
149
    {
150
        if (!empty($options['stream'])) {
151
            return $stream;
152
        }
153
154
        $sink = $options['sink'] ?? Psr7\Utils::tryFopen('php://temp', 'r+');
155
156
        return \is_string($sink) ? new Psr7\LazyOpenStream($sink, 'w+') : Psr7\Utils::streamFor($sink);
157
    }
158
159
    /**
160
     * @param resource $stream
161
     */
162
    private function checkDecode(array $options, array $headers, $stream): array
163
    {
164
        // Automatically decode responses when instructed.
165
        if (!empty($options['decode_content'])) {
166
            $normalizedKeys = Utils::normalizeHeaderKeys($headers);
167
            if (isset($normalizedKeys['content-encoding'])) {
168
                $encoding = $headers[$normalizedKeys['content-encoding']];
169
                if ($encoding[0] === 'gzip' || $encoding[0] === 'deflate') {
170
                    $stream = new Psr7\InflateStream(Psr7\Utils::streamFor($stream));
171
                    $headers['x-encoded-content-encoding'] = $headers[$normalizedKeys['content-encoding']];
172
173
                    // Remove content-encoding header
174
                    unset($headers[$normalizedKeys['content-encoding']]);
175
176
                    // Fix content-length header
177
                    if (isset($normalizedKeys['content-length'])) {
178
                        $headers['x-encoded-content-length'] = $headers[$normalizedKeys['content-length']];
179
                        $length = (int) $stream->getSize();
180
                        if ($length === 0) {
181
                            unset($headers[$normalizedKeys['content-length']]);
182
                        } else {
183
                            $headers[$normalizedKeys['content-length']] = [$length];
184
                        }
185
                    }
186
                }
187
            }
188
        }
189
190
        return [$stream, $headers];
191
    }
192
193
    /**
194
     * Drains the source stream into the "sink" client option.
195
     *
196
     * @param string $contentLength Header specifying the amount of
197
     *                              data to read.
198
     *
199
     * @throws \RuntimeException when the sink option is invalid.
200
     */
201
    private function drain(StreamInterface $source, StreamInterface $sink, string $contentLength): StreamInterface
202
    {
203
        // If a content-length header is provided, then stop reading once
204
        // that number of bytes has been read. This can prevent infinitely
205
        // reading from a stream when dealing with servers that do not honor
206
        // Connection: Close headers.
207
        Psr7\Utils::copyToStream(
208
            $source,
209
            $sink,
210
            (\strlen($contentLength) > 0 && (int) $contentLength > 0) ? (int) $contentLength : -1
211
        );
212
213
        $sink->seek(0);
214
        $source->close();
215
216
        return $sink;
217
    }
218
219
    /**
220
     * Create a resource and check to ensure it was created successfully
221
     *
222
     * @param callable $callback Callable that returns stream resource
223
     *
224
     * @return resource
225
     *
226
     * @throws \RuntimeException on error
227
     */
228
    private function createResource(callable $callback)
229
    {
230
        $errors = [];
231
        \set_error_handler(static function ($_, $msg, $file, $line) use (&$errors): bool {
232
            $errors[] = [
233
                'message' => $msg,
234
                'file'    => $file,
235
                'line'    => $line
236
            ];
237
            return true;
238
        });
239
240
        $resource = $callback();
241
        \restore_error_handler();
242
243
        if (!$resource) {
244
            $message = 'Error creating resource: ';
245
            foreach ($errors as $err) {
246
                foreach ($err as $key => $value) {
247
                    $message .= "[$key] $value" . \PHP_EOL;
248
                }
249
            }
250
            throw new \RuntimeException(\trim($message));
251
        }
252
253
        return $resource;
254
    }
255
256
    /**
257
     * @return resource
258
     */
259
    private function createStream(RequestInterface $request, array $options)
260
    {
261
        static $methods;
262
        if (!$methods) {
263
            $methods = \array_flip(\get_class_methods(__CLASS__));
264
        }
265
266
        // HTTP/1.1 streams using the PHP stream wrapper require a
267
        // Connection: close header
268
        if ($request->getProtocolVersion() == '1.1'
269
            && !$request->hasHeader('Connection')
270
        ) {
271
            $request = $request->withHeader('Connection', 'close');
272
        }
273
274
        // Ensure SSL is verified by default
275
        if (!isset($options['verify'])) {
276
            $options['verify'] = true;
277
        }
278
279
        $params = [];
280
        $context = $this->getDefaultContext($request);
281
282
        if (isset($options['on_headers']) && !\is_callable($options['on_headers'])) {
283
            throw new \InvalidArgumentException('on_headers must be callable');
284
        }
285
286
        if (!empty($options)) {
287
            foreach ($options as $key => $value) {
288
                $method = "add_{$key}";
289
                if (isset($methods[$method])) {
290
                    $this->{$method}($request, $context, $value, $params);
291
                }
292
            }
293
        }
294
295
        if (isset($options['stream_context'])) {
296
            if (!\is_array($options['stream_context'])) {
297
                throw new \InvalidArgumentException('stream_context must be an array');
298
            }
299
            $context = \array_replace_recursive($context, $options['stream_context']);
300
        }
301
302
        // Microsoft NTLM authentication only supported with curl handler
303
        if (isset($options['auth'][2]) && 'ntlm' === $options['auth'][2]) {
304
            throw new \InvalidArgumentException('Microsoft NTLM authentication only supported with curl handler');
305
        }
306
307
        $uri = $this->resolveHost($request, $options);
308
309
        $contextResource = $this->createResource(
310
            static function () use ($context, $params) {
311
                return \stream_context_create($context, $params);
312
            }
313
        );
314
315
        return $this->createResource(
316
            function () use ($uri, &$http_response_header, $contextResource, $context, $options, $request) {
317
                $resource = @\fopen((string) $uri, 'r', false, $contextResource);
318
                $this->lastHeaders = $http_response_header;
319
320
                if (false === $resource) {
321
                    throw new ConnectException(sprintf('Connection refused for URI %s', $uri), $request, null, $context);
322
                }
323
324
                if (isset($options['read_timeout'])) {
325
                    $readTimeout = $options['read_timeout'];
326
                    $sec = (int) $readTimeout;
327
                    $usec = ($readTimeout - $sec) * 100000;
328
                    \stream_set_timeout($resource, $sec, $usec);
329
                }
330
331
                return $resource;
332
            }
333
        );
334
    }
335
336
    private function resolveHost(RequestInterface $request, array $options): UriInterface
337
    {
338
        $uri = $request->getUri();
339
340
        if (isset($options['force_ip_resolve']) && !\filter_var($uri->getHost(), \FILTER_VALIDATE_IP)) {
341
            if ('v4' === $options['force_ip_resolve']) {
342
                $records = \dns_get_record($uri->getHost(), \DNS_A);
343
                if (false === $records || !isset($records[0]['ip'])) {
344
                    throw new ConnectException(\sprintf("Could not resolve IPv4 address for host '%s'", $uri->getHost()), $request);
345
                }
346
                return $uri->withHost($records[0]['ip']);
347
            }
348
            if ('v6' === $options['force_ip_resolve']) {
349
                $records = \dns_get_record($uri->getHost(), \DNS_AAAA);
350
                if (false === $records || !isset($records[0]['ipv6'])) {
351
                    throw new ConnectException(\sprintf("Could not resolve IPv6 address for host '%s'", $uri->getHost()), $request);
352
                }
353
                return $uri->withHost('[' . $records[0]['ipv6'] . ']');
354
            }
355
        }
356
357
        return $uri;
358
    }
359
360
    private function getDefaultContext(RequestInterface $request): array
361
    {
362
        $headers = '';
363
        foreach ($request->getHeaders() as $name => $value) {
364
            foreach ($value as $val) {
365
                $headers .= "$name: $val\r\n";
366
            }
367
        }
368
369
        $context = [
370
            'http' => [
371
                'method'           => $request->getMethod(),
372
                'header'           => $headers,
373
                'protocol_version' => $request->getProtocolVersion(),
374
                'ignore_errors'    => true,
375
                'follow_location'  => 0,
376
            ],
377
        ];
378
379
        $body = (string) $request->getBody();
380
381
        if (!empty($body)) {
382
            $context['http']['content'] = $body;
383
            // Prevent the HTTP handler from adding a Content-Type header.
384
            if (!$request->hasHeader('Content-Type')) {
385
                $context['http']['header'] .= "Content-Type:\r\n";
386
            }
387
        }
388
389
        $context['http']['header'] = \rtrim($context['http']['header']);
390
391
        return $context;
392
    }
393
394
    /**
395
     * @param mixed $value as passed via Request transfer options.
396
     */
397
    private function add_proxy(RequestInterface $request, array &$options, $value, array &$params): void
0 ignored issues
show
Unused Code introduced by
The parameter $params is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
398
    {
399
        $uri = null;
400
401
        if (!\is_array($value)) {
402
            $uri = $value;
403
        } else {
404
            $scheme = $request->getUri()->getScheme();
405
            if (isset($value[$scheme])) {
406
                if (!isset($value['no']) || !Utils::isHostInNoProxy($request->getUri()->getHost(), $value['no'])) {
407
                    $uri = $value[$scheme];
408
                }
409
            }
410
        }
411
412
        if (!$uri) {
413
            return;
414
        }
415
416
        $parsed = $this->parse_proxy($uri);
417
        $options['http']['proxy'] = $parsed['proxy'];
418
419
        if ($parsed['auth']) {
420
            if (!isset($options['http']['header'])) {
421
                $options['http']['header'] = [];
422
            }
423
            $options['http']['header'] .= "\r\nProxy-Authorization: {$parsed['auth']}";
424
        }
425
    }
426
427
    /**
428
     * Parses the given proxy URL to make it compatible with the format PHP's stream context expects.
429
     */
430
    private function parse_proxy(string $url): array
431
    {
432
        $parsed = \parse_url($url);
433
434
        if ($parsed !== false && isset($parsed['scheme']) && $parsed['scheme'] === 'http') {
435
            if (isset($parsed['host']) && isset($parsed['port'])) {
436
                $auth = null;
437
                if (isset($parsed['user']) && isset($parsed['pass'])) {
438
                    $auth = \base64_encode("{$parsed['user']}:{$parsed['pass']}");
439
                }
440
441
                return [
442
                    'proxy' => "tcp://{$parsed['host']}:{$parsed['port']}",
443
                    'auth' => $auth ? "Basic {$auth}" : null,
444
                ];
445
            }
446
        }
447
448
        // Return proxy as-is.
449
        return [
450
            'proxy' => $url,
451
            'auth' => null,
452
        ];
453
    }
454
455
    /**
456
     * @param mixed $value as passed via Request transfer options.
457
     */
458
    private function add_timeout(RequestInterface $request, array &$options, $value, array &$params): void
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $params is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
459
    {
460
        if ($value > 0) {
461
            $options['http']['timeout'] = $value;
462
        }
463
    }
464
465
    /**
466
     * @param mixed $value as passed via Request transfer options.
467
     */
468
    private function add_verify(RequestInterface $request, array &$options, $value, array &$params): void
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $params is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
469
    {
470
        if ($value === false) {
471
            $options['ssl']['verify_peer'] = false;
472
            $options['ssl']['verify_peer_name'] = false;
473
474
            return;
475
        }
476
477
        if (\is_string($value)) {
478
            $options['ssl']['cafile'] = $value;
479
            if (!\file_exists($value)) {
480
                throw new \RuntimeException("SSL CA bundle not found: $value");
481
            }
482
        } elseif ($value !== true) {
483
            throw new \InvalidArgumentException('Invalid verify request option');
484
        }
485
486
        $options['ssl']['verify_peer'] = true;
487
        $options['ssl']['verify_peer_name'] = true;
488
        $options['ssl']['allow_self_signed'] = false;
489
    }
490
491
    /**
492
     * @param mixed $value as passed via Request transfer options.
493
     */
494
    private function add_cert(RequestInterface $request, array &$options, $value, array &$params): void
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $params is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
495
    {
496
        if (\is_array($value)) {
497
            $options['ssl']['passphrase'] = $value[1];
498
            $value = $value[0];
499
        }
500
501
        if (!\file_exists($value)) {
502
            throw new \RuntimeException("SSL certificate not found: {$value}");
503
        }
504
505
        $options['ssl']['local_cert'] = $value;
506
    }
507
508
    /**
509
     * @param mixed $value as passed via Request transfer options.
510
     */
511
    private function add_progress(RequestInterface $request, array &$options, $value, array &$params): void
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $options is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
512
    {
513
        self::addNotification(
514
            $params,
515
            static function ($code, $a, $b, $c, $transferred, $total) use ($value) {
516
                if ($code == \STREAM_NOTIFY_PROGRESS) {
517
                    $value($total, $transferred, null, null);
518
                }
519
            }
520
        );
521
    }
522
523
    /**
524
     * @param mixed $value as passed via Request transfer options.
525
     */
526
    private function add_debug(RequestInterface $request, array &$options, $value, array &$params): void
0 ignored issues
show
Unused Code introduced by
The parameter $options is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
527
    {
528
        if ($value === false) {
529
            return;
530
        }
531
532
        static $map = [
533
            \STREAM_NOTIFY_CONNECT       => 'CONNECT',
534
            \STREAM_NOTIFY_AUTH_REQUIRED => 'AUTH_REQUIRED',
535
            \STREAM_NOTIFY_AUTH_RESULT   => 'AUTH_RESULT',
536
            \STREAM_NOTIFY_MIME_TYPE_IS  => 'MIME_TYPE_IS',
537
            \STREAM_NOTIFY_FILE_SIZE_IS  => 'FILE_SIZE_IS',
538
            \STREAM_NOTIFY_REDIRECTED    => 'REDIRECTED',
539
            \STREAM_NOTIFY_PROGRESS      => 'PROGRESS',
540
            \STREAM_NOTIFY_FAILURE       => 'FAILURE',
541
            \STREAM_NOTIFY_COMPLETED     => 'COMPLETED',
542
            \STREAM_NOTIFY_RESOLVE       => 'RESOLVE',
543
        ];
544
        static $args = ['severity', 'message', 'message_code', 'bytes_transferred', 'bytes_max'];
545
546
        $value = Utils::debugResource($value);
547
        $ident = $request->getMethod() . ' ' . $request->getUri()->withFragment('');
548
        self::addNotification(
549
            $params,
550
            static function (int $code, ...$passed) use ($ident, $value, $map, $args): void {
551
                \fprintf($value, '<%s> [%s] ', $ident, $map[$code]);
552
                foreach (\array_filter($passed) as $i => $v) {
553
                    \fwrite($value, $args[$i] . ': "' . $v . '" ');
554
                }
555
                \fwrite($value, "\n");
556
            }
557
        );
558
    }
559
560
    private static function addNotification(array &$params, callable $notify): void
561
    {
562
        // Wrap the existing function if needed.
563
        if (!isset($params['notification'])) {
564
            $params['notification'] = $notify;
565
        } else {
566
            $params['notification'] = self::callArray([
567
                $params['notification'],
568
                $notify
569
            ]);
570
        }
571
    }
572
573
    private static function callArray(array $functions): callable
574
    {
575
        return static function (...$args) use ($functions) {
576
            foreach ($functions as $fn) {
577
                $fn(...$args);
578
            }
579
        };
580
    }
581
}
582