PhpClient   A
last analyzed

Complexity

Total Complexity 27

Size/Duplication

Total Lines 166
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 12
Bugs 0 Features 2
Metric Value
eloc 76
dl 0
loc 166
ccs 70
cts 70
cp 1
rs 10
c 12
b 0
f 2
wmc 27

14 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 8 2
A userAgent() 0 4 1
A ignoreErrors() 0 4 1
A verifySslPeer() 0 4 1
A verifySslHost() 0 4 1
A maxRedirects() 0 4 1
A timeout() 0 4 1
A followLocation() 0 4 1
A createResource() 0 3 1
A prepareOptions() 0 5 1
A extractStatusAndHeaders() 0 22 4
A read() 0 23 6
A prepareRequestBody() 0 13 5
A hasError() 0 3 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
                    'The HTTP client is not created therefore cannot read anything');
64 7
            }
65 7
            return new ServerResponse(
66
                \stream_get_contents($resource),
0 ignored issues
show
Bug introduced by
It seems like $resource can also be of type false; however, parameter $stream of stream_get_contents() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

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

66
                \stream_get_contents(/** @scrutinizer ignore-type */ $resource),
Loading history...
67
                ...$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

67
                /** @scrutinizer ignore-type */ ...$this->extractStatusAndHeaders($resource));
Loading history...
Bug introduced by
It seems like $resource can also be of type false; however, parameter $resource of Koded\Http\Client\PhpCli...tractStatusAndHeaders() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

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

67
                ...$this->extractStatusAndHeaders(/** @scrutinizer ignore-type */ $resource));
Loading history...
68
        } catch (\ValueError $e) {
69 1
            return $this->getPhpError(HttpStatus::FAILED_DEPENDENCY, $e->getMessage());
70 1
        } catch (\Throwable $e) {
71
            return $this->getPhpError(HttpStatus::INTERNAL_SERVER_ERROR, $e->getMessage());
72 12
        } finally {
73 12
            if (\is_resource($resource)) {
74
                \fclose($resource);
75
            }
76
        }
77
    }
78 1
79
    public function userAgent(string $value): HttpRequestClient
80 1
    {
81
        $this->options['user_agent'] = $value;
82 1
        return $this;
83
    }
84
85 1
    public function followLocation(bool $value): HttpRequestClient
86
    {
87 1
        $this->options['follow_location'] = (int)$value;
88
        return $this;
89 1
    }
90
91
    public function maxRedirects(int $value): HttpRequestClient
92 1
    {
93
        $this->options['max_redirects'] = $value;
94 1
        return $this;
95
    }
96 1
97
    public function timeout(float $value): HttpRequestClient
98
    {
99 8
        $this->options['timeout'] = $value * 1.0;
100
        return $this;
101 8
    }
102
103 8
    public function ignoreErrors(bool $value): HttpRequestClient
104
    {
105
        $this->options['ignore_errors'] = $value;
106 1
        return $this;
107
    }
108 1
109
    public function verifySslHost(bool $value): HttpRequestClient
110 1
    {
111
        $this->options['ssl']['allow_self_signed'] = $value;
112
        return $this;
113 1
    }
114
115 1
    public function verifySslPeer(bool $value): HttpRequestClient
116
    {
117 1
        $this->options['ssl']['verify_peer'] = $value;
118
        return $this;
119
    }
120 1
121
    /**
122 1
     * NOTE: if Content-Type is not provided
123
     * fopen() will assume application/x-www-form-urlencoded
124 1
     *
125
     * @param resource $context from stream_context_create()
126
     *
127
     * @return resource|bool
128
     */
129
    protected function createResource($context)
130
    {
131
        return @\fopen((string)$this->getUri(), 'rb', false, $context);
132 10
    }
133
134 10
    protected function prepareRequestBody(): void
135
    {
136
        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...
137 12
            return;
138
        }
139 12
        $this->stream->rewind();
140 9
        if (0 === $this->encoding) {
141
            $this->options['content'] = $this->stream->getContents();
142
        } elseif ($content = \json_decode($this->stream->getContents() ?: '[]', true)) {
143 3
            $this->normalizeHeader('Content-Type', self::X_WWW_FORM_URLENCODED, true);
144
            $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

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