Passed
Pull Request — master (#7)
by Sandro
02:09
created

Client::updateCommonHttpHeaders()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 32
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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