Passed
Push — master ( bbfa5f...40b08b )
by Mihail
07:08
created

PhpClient   A

Complexity

Total Complexity 27

Size/Duplication

Total Lines 162
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 11
Bugs 0 Features 2
Metric Value
eloc 74
dl 0
loc 162
ccs 68
cts 68
cp 1
rs 10
c 11
b 0
f 2
wmc 27

14 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 8 2
A userAgent() 0 4 1
A createResource() 0 3 1
A prepareOptions() 0 5 1
A extractStatusAndHeaders() 0 22 4
A read() 0 22 6
A ignoreErrors() 0 4 1
A prepareRequestBody() 0 13 5
A verifySslPeer() 0 4 1
A hasError() 0 3 1
A verifySslHost() 0 4 1
A maxRedirects() 0 4 1
A timeout() 0 4 1
A followLocation() 0 4 1
1
<?php
2
3
/*
4
 * This file is part of the Koded package.
5
 *
6
 * (c) Mihail Binev <[email protected]>
7
 *
8
 * Please view the LICENSE distributed with this source code
9
 * for the full copyright and license information.
10
 *
11
 */
12
13
namespace Koded\Http\Client;
14
15
use Koded\Http\{ClientRequest, ServerResponse};
16
use Koded\Http\Interfaces\{HttpRequestClient, HttpStatus, Response};
17
use Psr\Http\Message\UriInterface;
18
use function Koded\Http\create_stream;
19
20
/**
21
 * @link http://php.net/manual/en/context.http.php
22
 */
23
class PhpClient extends ClientRequest implements HttpRequestClient
24
{
25
    use EncodingTrait, Psr18ClientTrait;
26
27
    /** @var array Stream context options */
28
    private array $options = [
29
        'protocol_version' => 1.1,
30
        'user_agent'       => self::USER_AGENT,
31
        'method'           => 'GET',
32
        'max_redirects'    => 20,
33
        'follow_location'  => 1,
34
        'ignore_errors'    => true,
35
        'request_fulluri'  => true,
36
        'ssl'              => [
37
            'verify_peer'       => true,
38
            'allow_self_signed' => false,
39
        ]
40
    ];
41
42 9
    public function __construct(
43
        string $method,
44 9
        string|UriInterface $uri,
45 9
        string|iterable $body = null,
46 9
        array $headers = [])
47
    {
48 13
        parent::__construct($method, $uri, $body, $headers);
49
        $this->options['timeout'] = (\ini_get('default_socket_timeout') ?: 10.0) * 1.0;
50 13
    }
51 1
52
    public function read(): Response
53
    {
54 12
        if ($resource = $this->assertSafeMethod()) {
55 12
            return $resource;
56
        }
57
        $this->prepareRequestBody();
58 12
        $this->prepareOptions();
59 4
        try {
60
            $resource = $this->createResource(\stream_context_create(['http' => $this->options]));
61
            if ($this->hasError($resource)) {
62 7
                return $this->getPhpError(HttpStatus::FAILED_DEPENDENCY);
63
            }
64 7
            return new ServerResponse(
65 7
                \stream_get_contents($resource),
66
                ...$this->extractStatusAndHeaders($resource));
0 ignored issues
show
Bug introduced by
$this->extractStatusAndHeaders($resource) is expanded, but the parameter $statusCode of Koded\Http\ServerResponse::__construct() does not expect variable arguments. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

66
                /** @scrutinizer ignore-type */ ...$this->extractStatusAndHeaders($resource));
Loading history...
67
        } catch (\ValueError $e) {
68
            return $this->getPhpError(HttpStatus::FAILED_DEPENDENCY, $e->getMessage());
69 1
        } catch (\Throwable $e) {
70 1
            return $this->getPhpError(HttpStatus::INTERNAL_SERVER_ERROR, $e->getMessage());
71
        } finally {
72 12
            if (\is_resource($resource)) {
73 12
                \fclose($resource);
74
            }
75
        }
76
    }
77
78 1
    public function userAgent(string $value): HttpRequestClient
79
    {
80 1
        $this->options['user_agent'] = $value;
81
        return $this;
82 1
    }
83
84
    public function followLocation(bool $value): HttpRequestClient
85 1
    {
86
        $this->options['follow_location'] = (int)$value;
87 1
        return $this;
88
    }
89 1
90
    public function maxRedirects(int $value): HttpRequestClient
91
    {
92 1
        $this->options['max_redirects'] = $value;
93
        return $this;
94 1
    }
95
96 1
    public function timeout(float $value): HttpRequestClient
97
    {
98
        $this->options['timeout'] = $value * 1.0;
99 8
        return $this;
100
    }
101 8
102
    public function ignoreErrors(bool $value): HttpRequestClient
103 8
    {
104
        $this->options['ignore_errors'] = $value;
105
        return $this;
106 1
    }
107
108 1
    public function verifySslHost(bool $value): HttpRequestClient
109
    {
110 1
        $this->options['ssl']['allow_self_signed'] = $value;
111
        return $this;
112
    }
113 1
114
    public function verifySslPeer(bool $value): HttpRequestClient
115 1
    {
116
        $this->options['ssl']['verify_peer'] = $value;
117 1
        return $this;
118
    }
119
120 1
    /**
121
     * @param resource $context from stream_context_create()
122 1
     *
123
     * @return resource|bool
124 1
     */
125
    protected function createResource($context)
126
    {
127
        return \fopen((string)$this->getUri(), 'rb', false, $context);
128
    }
129
130
    protected function prepareRequestBody(): void
131
    {
132 10
        if (!$this->stream->getSize()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->stream->getSize() of type integer|null is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
133
            return;
134 10
        }
135
        $this->stream->rewind();
136
        if (0 === $this->encoding) {
137 12
            $this->options['content'] = $this->stream->getContents();
138
        } elseif ($content = \json_decode($this->stream->getContents() ?: '[]', true)) {
139 12
            $this->normalizeHeader('Content-Type', self::X_WWW_FORM_URLENCODED, true);
140 9
            $this->options['content'] = \http_build_query($content, null, '&', $this->encoding);
0 ignored issues
show
Bug introduced by
null of type null is incompatible with the type string expected by parameter $numeric_prefix of http_build_query(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

140
            $this->options['content'] = \http_build_query($content, /** @scrutinizer ignore-type */ null, '&', $this->encoding);
Loading history...
141
        }
142
        $this->stream = create_stream($this->options['content']);
143 3
    }
144
145 3
    protected function hasError($resource): bool
146 1
    {
147 2
        return false === \is_resource($resource);
148 2
    }
149 2
150
    protected function prepareOptions(): void
151
    {
152 3
        $this->options['method'] = $this->getMethod();
153 3
        $this->options['header'] = $this->getFlattenedHeaders();
154
        unset($this->options['header'][0]); // Host header is always present and first
155 12
    }
156
157 12
    /**
158 12
     * Extracts the headers and status code from the response.
159 12
     *
160 12
     * @param resource $resource The resource from fopen()
161
     * @return array Status code and headers
162
     */
163
    protected function extractStatusAndHeaders($resource): array
164
    {
165
        try {
166
            $headers = [];
167
            $meta = \stream_get_meta_data($resource)['wrapper_data'] ?? [];
168
            /* HTTP status may not always be the first header in the response headers,
169 7
             * for example, if the stream follows one or multiple redirects, the last
170
             * status line is what is expected here.
171
             */
172 7
            $status = \array_filter($meta, fn(string $header) => \str_starts_with($header, 'HTTP/'));
173
            $status = \array_pop($status) ?: 'HTTP/1.1 200 OK';
174 7
            $status = (int)(\explode(' ', $status)[1] ?? HttpStatus::OK);
175 7
            foreach ($meta as $header) {
176 7
                [$k, $v] = \explode(':', $header, 2) + [1 => null];
177 7
                if (null === $v) {
178
                    continue;
179 7
                }
180 7
                $headers[$k] = $v;
181 7
            }
182 7
            return [$status, $headers];
183
        } finally {
184 7
            unset($meta, $header, $k, $v);
185
        }
186 7
    }
187
}
188