Completed
Pull Request — master (#7)
by Sandro
03:08
created

Client::sendRequest()   B

Complexity

Conditions 9
Paths 78

Size

Total Lines 58
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 9.4867

Importance

Changes 0
Metric Value
eloc 35
dl 0
loc 58
ccs 27
cts 33
cp 0.8182
rs 8.0555
c 0
b 0
f 0
cc 9
nc 78
nop 1
crap 9.4867

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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