Passed
Push — master ( 4ac306...d2aa89 )
by Mihail
05:30
created

CurlClient   A

Complexity

Total Complexity 30

Size/Duplication

Total Lines 195
Duplicated Lines 0 %

Test Coverage

Coverage 97.75%

Importance

Changes 3
Bugs 1 Features 1
Metric Value
eloc 84
c 3
b 1
f 1
dl 0
loc 195
ccs 87
cts 89
cp 0.9775
rs 10
wmc 30

16 Methods

Rating   Name   Duplication   Size   Complexity  
A createResource() 0 3 1
A extractFromResponseHeaders() 0 11 3
A prepareOptions() 0 6 1
A followLocation() 0 5 1
A maxRedirects() 0 5 1
A verifySslHost() 0 5 2
A getCurlError() 0 7 1
A hasError() 0 3 1
A ignoreErrors() 0 6 1
A __construct() 0 4 2
A withProtocolVersion() 0 10 1
A timeout() 0 5 1
A verifySslPeer() 0 5 2
A userAgent() 0 5 1
B read() 0 36 6
A prepareRequestBody() 0 16 5
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 Throwable;
18
use function Koded\Http\create_stream;
19
use function Koded\Stdlib\json_serialize;
20
21
/**
22
 * @link http://php.net/manual/en/context.curl.php
23
 */
24
class CurlClient extends ClientRequest implements HttpRequestClient
25
{
26
    use EncodingTrait, Psr18ClientTrait;
27
28
    /** @var array curl options */
29
    private $options = [
30
        CURLOPT_MAXREDIRS      => 20,
31
        CURLOPT_RETURNTRANSFER => true,
32
        CURLOPT_FOLLOWLOCATION => true,
33
        CURLOPT_SSL_VERIFYPEER => 1,
34
        CURLOPT_SSL_VERIFYHOST => 2,
35
        CURLOPT_USERAGENT      => self::USER_AGENT,
36
        CURLOPT_HTTP_VERSION   => CURL_HTTP_VERSION_1_1,
37
        CURLOPT_FAILONERROR    => 0,
38
    ];
39
40
    /** @var array Parsed response headers */
41
    private $responseHeaders = [];
42
43 19
    public function __construct(string $method, $uri, $body = null, array $headers = [])
44
    {
45 19
        parent::__construct($method, $uri, $body, $headers);
46 19
        $this->options[CURLOPT_TIMEOUT] = (ini_get('default_socket_timeout') ?: 10.0) * 1.0;
47 19
    }
48
49 15
    public function read(): Response
50
    {
51 15
        if ($resource = $this->assertSafeMethod()) {
52 1
            return $resource;
53
        }
54
55 14
        $this->prepareRequestBody();
56 14
        $this->prepareOptions();
57
58
        try {
59 14
            if (false === $resource = $this->createResource()) {
60 1
                return new ServerResponse(
61 1
                    'The HTTP client is not created therefore cannot read anything',
62 1
                    HttpStatus::PRECONDITION_FAILED);
63
            }
64
65 12
            curl_setopt_array($resource, $this->options);
66 11
            $response = curl_exec($resource);
67
68 11
            if (true === $this->hasError($resource)) {
69 4
                return (new ServerResponse($this->getCurlError($resource), HttpStatus::FAILED_DEPENDENCY))
70 4
                    ->withHeader('Content-Type', 'application/json');
71
            }
72
73 7
            return new ServerResponse(
74 7
                $response,
75 7
                curl_getinfo($resource, CURLINFO_RESPONSE_CODE),
76 7
                $this->responseHeaders
77
            );
78 2
        } catch (Throwable $e) {
79 2
            return new ServerResponse($e->getMessage(), HttpStatus::INTERNAL_SERVER_ERROR);
80
        } finally {
81 14
            unset($response);
82
83 14
            if (is_resource($resource)) {
84 14
                curl_close($resource);
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return Koded\Http\Interfaces\Response. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
85
            }
86
        }
87
    }
88
89 1
    public function userAgent(string $value): HttpRequestClient
90
    {
91 1
        $this->options[CURLOPT_USERAGENT] = $value;
92
93 1
        return $this;
94
    }
95
96 1
    public function followLocation(bool $value): HttpRequestClient
97
    {
98 1
        $this->options[CURLOPT_FOLLOWLOCATION] = $value;
99
100 1
        return $this;
101
    }
102
103 2
    public function maxRedirects(int $value): HttpRequestClient
104
    {
105 2
        $this->options[CURLOPT_MAXREDIRS] = $value;
106
107 2
        return $this;
108
    }
109
110 11
    public function timeout(float $value): HttpRequestClient
111
    {
112 11
        $this->options[CURLOPT_TIMEOUT] = $value;
113
114 11
        return $this;
115
    }
116
117 1
    public function ignoreErrors(bool $value): HttpRequestClient
118
    {
119
        // false = do not fail on error
120 1
        $this->options[CURLOPT_FAILONERROR] = (int)!$value;
121
122 1
        return $this;
123
    }
124
125 1
    public function verifySslHost(bool $value): HttpRequestClient
126
    {
127 1
        $this->options[CURLOPT_SSL_VERIFYHOST] = $value ? 2 : 0;
128
129 1
        return $this;
130
    }
131
132 1
    public function verifySslPeer(bool $value): HttpRequestClient
133
    {
134 1
        $this->options[CURLOPT_SSL_VERIFYPEER] = $value ? 1 : 0;
135
136 1
        return $this;
137
    }
138
139 1
    public function withProtocolVersion($version): HttpRequestClient
140
    {
141 1
        $instance = parent::withProtocolVersion($version);
142
143 1
        $instance->options[CURLOPT_HTTP_VERSION] = [
144 1
                                                       '1.1' => CURL_HTTP_VERSION_1_1,
145 1
                                                       '1.0' => CURL_HTTP_VERSION_1_0
146 1
                                                   ][$version];
147
148 1
        return $instance;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $instance returns the type Koded\Http\ClientRequest which is incompatible with the type-hinted return Koded\Http\Interfaces\HttpRequestClient.
Loading history...
149
    }
150
151
    /**
152
     * @return false|resource
153
     */
154 11
    protected function createResource()
155
    {
156 11
        return curl_init((string)$this->getUri());
157
    }
158
159 10
    protected function hasError($resource): bool
160
    {
161 10
        return curl_errno($resource) > 0;
162
    }
163
164 14
    protected function prepareOptions(): void
165
    {
166 14
        $this->options[CURLOPT_HEADERFUNCTION] = [$this, 'extractFromResponseHeaders'];
167 14
        $this->options[CURLOPT_CUSTOMREQUEST]  = $this->getMethod();
168 14
        $this->options[CURLOPT_HTTPHEADER]     = $this->getFlattenedHeaders();
169 14
        unset($this->options[CURLOPT_HTTPHEADER][0]); // Host header is always present and first
170 14
    }
171
172 14
    protected function prepareRequestBody(): void
173
    {
174 14
        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...
175 11
            return;
176
        }
177
178 3
        $this->stream->rewind();
179
180 3
        if (0 === $this->encoding) {
181 1
            $this->options[CURLOPT_POSTFIELDS] = $this->stream->getContents();
182 2
        } elseif ($content = json_decode($this->stream->getContents() ?: '[]', true)) {
183 2
            $this->normalizeHeader('Content-Type', self::X_WWW_FORM_URLENCODED, true);
184 2
            $this->options[CURLOPT_POSTFIELDS] = http_build_query($content, null, '&', $this->encoding);
185
        }
186
187 3
        $this->stream = create_stream($this->options[CURLOPT_POSTFIELDS]);
188 3
    }
189
190 4
    protected function getCurlError($resource): string
191
    {
192 4
        return json_serialize([
193 4
            'uri'     => curl_getinfo($resource, CURLINFO_EFFECTIVE_URL),
194 4
            'message' => curl_strerror(curl_errno($resource)),
195 4
            'explain' => curl_error($resource),
196 4
            'code'    => HttpStatus::FAILED_DEPENDENCY,
197
        ]);
198
    }
199
200
    /**
201
     * Extracts the headers from curl response.
202
     *
203
     * @param resource $_      curl instance
204
     * @param string   $header Current header line
205
     *
206
     * @return int Header length
207
     */
208 8
    protected function extractFromResponseHeaders($_, string $header): int
209
    {
210
        try {
211 8
            [$k, $v] = explode(':', $header, 2) + [1 => null];
212 8
            if (null !== $v) {
213 8
                $this->responseHeaders[$k] = $v;
214
            }
215
        } catch (Throwable $e) {
216
            /** NOOP **/
217
        } finally {
218 8
            return mb_strlen($header);
219
        }
220
    }
221
}
222