Completed
Pull Request — master (#7)
by Sandro
02:18
created

Client   A

Complexity

Total Complexity 40

Size/Duplication

Total Lines 335
Duplicated Lines 0 %

Test Coverage

Coverage 73.38%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 40
eloc 150
c 1
b 0
f 0
dl 0
loc 335
ccs 102
cts 139
cp 0.7338
rs 9.2

8 Methods

Rating   Name   Duplication   Size   Complexity  
A __invoke() 0 3 1
A __construct() 0 12 2
B transmit() 0 37 10
A sendType() 0 29 5
A close() 0 4 1
B sendRequest() 0 59 9
A updateCommonHttpHeaders() 0 32 4
B open() 0 53 8

How to fix   Complexity   

Complex Class

Complex classes like Client 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.

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 Client, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * Sandro Keil (https://sandro-keil.de)
4
 *
5
 * @link      http://github.com/sandrokeil/arangodb-php-client for the canonical source repository
6
 * @copyright Copyright (c) 2018-2020 Sandro Keil
7
 * @license   http://github.com/sandrokeil/arangodb-php-client/blob/master/LICENSE.md New BSD License
8
 */
9
10
declare(strict_types=1);
11
12
namespace ArangoDb\Http;
13
14
use ArangoDb\Exception\ConnectionException;
15
use ArangoDb\Exception\NetworkException;
16
use ArangoDb\Exception\RequestFailedException;
17
use ArangoDb\Exception\TimeoutException;
18
use ArangoDb\Type\BatchType;
19
use ArangoDb\Type\GuardSupport;
20
use ArangoDb\Type\Type;
21
use Fig\Http\Message\StatusCodeInterface;
22
use Psr\Http\Message\RequestFactoryInterface;
23
use Psr\Http\Message\RequestInterface;
24
use Psr\Http\Message\ResponseFactoryInterface;
25
use Psr\Http\Message\ResponseInterface;
26
use Psr\Http\Message\StreamFactoryInterface;
27
28
final class Client implements TypeSupport
29
{
30
    /**
31
     * Chunk size in bytes
32
     */
33
    private const CHUNK_SIZE = 8192;
34
35
    /**
36
     * End of line mark used in HTTP
37
     */
38
    private const EOL = "\r\n";
39
40
    /**
41
     * Connection handle
42
     *
43
     * @var resource
44
     */
45
    private $handle;
46
47
    /**
48
     * @var bool
49
     */
50
    private $useKeepAlive;
51
52
    /**
53
     * @var ClientOptions
54
     */
55
    private $options;
56
57
    /**
58
     * @var string
59
     */
60
    private $baseUrl = '';
61
62
    /**
63
     * Default headers for all requests
64
     *
65
     * @var string
66
     */
67
    private $headerLines = '';
68
69
    /**
70
     * @var string
71
     */
72
    private $database = '';
73
74
    /**
75
     * @var RequestFactoryInterface
76
     */
77
    private $requestFactory;
78
79
    /**
80
     * @var ResponseFactoryInterface
81
     */
82
    private $responseFactory;
83
84
    /**
85
     * @var StreamFactoryInterface
86
     */
87
    private $streamFactory;
88
89
    /**
90
     * @param array|ClientOptions $options
91
     * @param RequestFactoryInterface $requestFactory
92
     * @param ResponseFactoryInterface $responseFactory
93
     * @param StreamFactoryInterface $streamFactory
94
     */
95 42
    public function __construct(
96
        $options,
97
        RequestFactoryInterface $requestFactory,
98
        ResponseFactoryInterface $responseFactory,
99
        StreamFactoryInterface $streamFactory
100
    ) {
101 42
        $this->options = $options instanceof ClientOptions ? $options : new ClientOptions($options);
102 42
        $this->useKeepAlive = ($this->options[ClientOptions::OPTION_CONNECTION] === 'Keep-Alive');
103 42
        $this->requestFactory = $requestFactory;
104 42
        $this->responseFactory = $responseFactory;
105 42
        $this->streamFactory = $streamFactory;
106 42
        $this->updateCommonHttpHeaders();
107 42
    }
108
109
    /**
110
     * Delegate / shorthand method
111
     *
112
     * @param RequestInterface $request
113
     * @return ResponseInterface
114
     * @throws \Psr\Http\Client\ClientExceptionInterface
115
     */
116
    public function __invoke(RequestInterface $request): ResponseInterface
117
    {
118
        return $this->sendRequest($request);
119
    }
120
121 7
    public function sendType(Type $type): ResponseInterface
122
    {
123 7
        $guard = null;
124
125 7
        if ($type instanceof GuardSupport) {
126 7
            $guard = $type->guard();
127
        }
128
129 7
        $response = $this->sendRequest($type->toRequest($this->requestFactory, $this->streamFactory));
130
131 7
        if ($guard !== null) {
132
            $guard($response);
133
134
            $response->getBody()->rewind();
135
        }
136
137 7
        if ($type instanceof BatchType
138 7
            && null !== ($guards = $type->guards())
139
        ) {
140 1
            BatchResult::fromResponse(
141 1
                $response,
142 1
                $this->responseFactory,
143 1
                $this->streamFactory
144 1
            )->validate(...$guards);
145
146 1
            $response->getBody()->rewind();
147
        }
148
149 7
        return $response;
150
    }
151
152 40
    public function sendRequest(RequestInterface $request): ResponseInterface
153
    {
154
        try {
155 40
            $stream = $request->getBody();
156 40
            $body = $stream->getContents();
157 40
            $method = $request->getMethod();
158
159 40
            $customHeaders = $request->getHeaders();
160 40
            unset($customHeaders['Connection'], $customHeaders['Content-Length']);
161
162 40
            if (! isset($customHeaders['Content-Type'])) {
163 7
                $customHeaders['Content-Type'] = ['application/json'];
164
            }
165
166 40
            $customHeader = '';
167 40
            foreach ($customHeaders as $headerKey => $headerValues) {
168 40
                foreach ($headerValues as $headerValue) {
169 40
                    $customHeader .= $headerKey . ': ' . $headerValue . self::EOL;
170
                }
171
            }
172
        } catch (\Throwable $e) {
173
            throw RequestFailedException::ofRequest($request, $e);
174
        }
175
176 40
        $customHeader .= 'Content-Length: ' . $stream->getSize() . self::EOL;
177
178 40
        $url = $this->baseUrl . $request->getUri();
179
180
        try {
181 40
            $this->open($request);
182
183 40
            $result = $this->transmit(
184 40
                $method . ' ' . $url . ' HTTP/1.1' .
185 40
                $this->headerLines .
186 40
                $customHeader . self::EOL .
187 40
                $body,
188
                $method
189
            );
190
            // TODO https://docs.arangodb.com/3.4/Manual/Architecture/DeploymentModes/ActiveFailover/Architecture.html
191 40
            $status = stream_get_meta_data($this->handle);
192
193 40
            if (true === $status['timed_out']) {
194
                throw TimeoutException::ofRequest($request);
195
            }
196 40
            if (! $this->useKeepAlive) {
197
                $this->close();
198
            }
199
200 40
            [$httpCode, $headers, $body] = HttpHelper::parseMessage($result);
201
        } catch (\Throwable $e) {
202
            throw NetworkException::with($request, $e);
203
        }
204 40
        $response = $this->responseFactory->createResponse($httpCode);
205
206 40
        foreach ($headers as $headerName => $header) {
207 40
            $response = $response->withAddedHeader($headerName, $header);
208
        }
209
210 40
        return $response->withBody($this->streamFactory->createStream($body));
211
    }
212
213
    /**
214
     * Sends request to server and reads response.
215
     *
216
     * @param string $request
217
     * @param string $method
218
     * @return string
219
     */
220 40
    private function transmit(string $request, string $method): string
221
    {
222 40
        fwrite($this->handle, $request);
223 40
        fflush($this->handle);
224
225 40
        $contentLength = 0;
226 40
        $bodyLength = 0;
227 40
        $readTotal = 0;
228 40
        $matches = [];
229 40
        $message = '';
230
231
        do {
232 40
            $read = fread($this->handle, self::CHUNK_SIZE);
233 40
            if (false === $read || $read === '') {
234
                break;
235
            }
236 40
            $readLength = strlen($read);
237 40
            $readTotal += $readLength;
238 40
            $message .= $read;
239
240 40
            if ($contentLength === 0
241 40
                && $method !== 'HEAD'
242 40
                && 1 === preg_match('/content-length: (\d+)/i', $message, $matches)
243
            ) {
244 40
                $contentLength = (int)$matches[1];
245
            }
246
247 40
            if ($bodyLength === 0) {
248 40
                $bodyStart = strpos($message, "\r\n\r\n");
249
250 40
                if (false !== $bodyStart) {
251 40
                    $bodyLength = $bodyStart + $contentLength + 4;
252
                }
253
            }
254 40
        } while ($readTotal < $bodyLength && ! feof($this->handle));
255
256 40
        return $message;
257
    }
258
259
    /**
260
     * Update common HTTP headers for all HTTP requests
261
     */
262 42
    private function updateCommonHttpHeaders(): void
263
    {
264 42
        $this->headerLines = self::EOL;
265
266 42
        $endpoint = $this->options[ClientOptions::OPTION_ENDPOINT];
267 42
        if (1 !== preg_match('/^unix:\/\/.+/', $endpoint)) {
268
            $this->headerLines .= 'Host: '
269 42
                . preg_replace('/^(tcp|ssl):\/\/(.+?):(\d+)\/?$/', '\\2', $endpoint)
270 42
                . self::EOL;
271
        }
272
        // add basic auth header
273
        if (isset(
274 42
            $this->options[ClientOptions::OPTION_AUTH_TYPE],
275 42
            $this->options[ClientOptions::OPTION_AUTH_USER]
276
        )) {
277
            $this->headerLines .= sprintf(
278
                'Authorization: %s %s%s',
279
                $this->options[ClientOptions::OPTION_AUTH_TYPE],
280
                base64_encode(
281
                    $this->options[ClientOptions::OPTION_AUTH_USER] . ':' .
282
                    $this->options[ClientOptions::OPTION_AUTH_PASSWD]
283
                ),
284
                self::EOL
285
            );
286
        }
287
288 42
        if (isset($this->options[ClientOptions::OPTION_CONNECTION])) {
289 42
            $this->headerLines .= 'Connection: ' . $this->options[ClientOptions::OPTION_CONNECTION] . self::EOL;
290
        }
291
292 42
        $this->database = $this->options[ClientOptions::OPTION_DATABASE];
293 42
        $this->baseUrl = '/_db/' . urlencode($this->database);
294 42
    }
295
296
    /**
297
     * Opens connection depending on options.
298
     *
299
     * @param RequestInterface $request
300
     */
301 40
    private function open(RequestInterface $request): void
302
    {
303 40
        if ($this->useKeepAlive && $this->handle !== null && is_resource($this->handle)) { // @phpstan-ignore-line
304 18
            if (! feof($this->handle)) {
305 18
                return;
306
            }
307
308
            $this->close();
309
310
            if (false === $this->options[ClientOptions::OPTION_RECONNECT]) {
311
                throw ConnectionException::forRequest(
312
                    $request,
313
                    'Server has closed the connection already.',
314
                    StatusCodeInterface::STATUS_REQUEST_TIMEOUT
315
                );
316
            }
317
        }
318
319 40
        $endpoint = $this->options[ClientOptions::OPTION_ENDPOINT];
320 40
        $context = stream_context_create();
321
322 40
        if (1 === preg_match('/^ssl:\/\/.+/', $endpoint)) {
323
            stream_context_set_option(
324
                $context,
325
                [
326
                    'ssl' => [
327
                        'verify_peer' => $this->options[ClientOptions::OPTION_VERIFY_CERT],
328
                        'verify_peer_name' => $this->options[ClientOptions::OPTION_VERIFY_CERT_NAME],
329
                        'allow_self_signed' => $this->options[ClientOptions::OPTION_ALLOW_SELF_SIGNED],
330
                        'ciphers' => $this->options[ClientOptions::OPTION_CIPHERS],
331
                    ],
332
                ]
333
            );
334
        }
335
336 40
        $handle = stream_socket_client(
337 40
            $endpoint,
338
            $errNo,
339
            $message,
340 40
            $this->options[ClientOptions::OPTION_TIMEOUT],
341 40
            STREAM_CLIENT_CONNECT,
342
            $context
343
        );
344
345 40
        if (false === $handle) {
346
            throw ConnectionException::forRequest(
347
                $request,
348
                sprintf('Cannot connect to endpoint "%s". Message: %s', $endpoint, $message),
349
                $errNo
350
            );
351
        }
352 40
        $this->handle = $handle;
353 40
        stream_set_timeout($this->handle, $this->options[ClientOptions::OPTION_TIMEOUT]);
354 40
    }
355
356
    /**
357
     * Closes connection
358
     */
359
    private function close(): void
360
    {
361
        fclose($this->handle);
362
        unset($this->handle);
363
    }
364
}
365