Passed
Push — master ( a1dc53...5fb317 )
by Dante
01:13
created

BaseClient::setDefaultHeaders()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
/**
4
 * BEdita, API-first content management framework
5
 * Copyright 2023 Atlas Srl, ChannelWeb Srl, Chialab Srl
6
 *
7
 * Licensed under The MIT License
8
 * For full copyright and license information, please see the LICENSE.txt
9
 * Redistributions of files must retain the above copyright notice.
10
 */
11
12
namespace BEdita\SDK;
13
14
use GuzzleHttp\Psr7\Request;
15
use GuzzleHttp\Psr7\Uri;
16
use Http\Adapter\Guzzle7\Client;
17
use Psr\Http\Message\ResponseInterface;
18
use WoohooLabs\Yang\JsonApi\Client\JsonApiClient;
19
20
class BaseClient
21
{
22
    use LogTrait;
23
24
    /**
25
     * Last response.
26
     *
27
     * @var \Psr\Http\Message\ResponseInterface
28
     */
29
    private $response = null;
30
31
    /**
32
     * BEdita API base URL
33
     *
34
     * @var string
35
     */
36
    private $apiBaseUrl = null;
37
38
    /**
39
     * BEdita API KEY
40
     *
41
     * @var string
42
     */
43
    private $apiKey = null;
44
45
    /**
46
     * Default headers in request
47
     *
48
     * @var array
49
     */
50
    private $defaultHeaders = [
51
        'Accept' => 'application/vnd.api+json',
52
    ];
53
54
    /**
55
     * Default headers in request
56
     *
57
     * @var array
58
     */
59
    private $defaultContentTypeHeader = [
60
        'Content-Type' => 'application/json',
61
    ];
62
63
    /**
64
     * JWT Auth tokens
65
     *
66
     * @var array
67
     */
68
    private $tokens = [];
69
70
    /**
71
     * JSON API BEdita client
72
     *
73
     * @var \WoohooLabs\Yang\JsonApi\Client\JsonApiClient
74
     */
75
    private $jsonApiClient = null;
76
77
    /**
78
     * Setup main client options:
79
     *  - API base URL
80
     *  - API KEY
81
     *  - Auth tokens 'jwt' and 'renew' (optional)
82
     *
83
     * @param string $apiUrl API base URL
84
     * @param string|null $apiKey API key
85
     * @param array $tokens JWT Autorization tokens as associative array ['jwt' => '###', 'renew' => '###']
86
     * @param array $guzzleConfig Additional default configuration for GuzzleHTTP client.
87
     * @return void
88
     */
89
    public function __construct(string $apiUrl, ?string $apiKey = null, array $tokens = [], array $guzzleConfig = [])
90
    {
91
        $this->apiBaseUrl = $apiUrl;
92
        $this->apiKey = $apiKey;
93
94
        $this->defaultHeaders['X-Api-Key'] = $this->apiKey;
95
        $this->setupTokens($tokens);
96
97
        // setup an asynchronous JSON API client
98
        $guzzleClient = Client::createWithConfig($guzzleConfig);
99
        $this->jsonApiClient = new JsonApiClient($guzzleClient);
100
    }
101
102
    /**
103
     * Setup JWT access and refresh tokens.
104
     *
105
     * @param array $tokens JWT tokens as associative array ['jwt' => '###', 'renew' => '###']
106
     * @return void
107
     */
108
    public function setupTokens(array $tokens): void
109
    {
110
        $this->tokens = $tokens;
111
        if (!empty($tokens['jwt'])) {
112
            $this->defaultHeaders['Authorization'] = sprintf('Bearer %s', $tokens['jwt']);
113
        } else {
114
            unset($this->defaultHeaders['Authorization']);
115
        }
116
    }
117
118
    /**
119
     * Get default headers in use on every request
120
     *
121
     * @return array Default headers
122
     */
123
    public function getDefaultHeaders(): array
124
    {
125
        return $this->defaultHeaders;
126
    }
127
128
    /**
129
     * Set default headers in use on every request
130
     *
131
     * @param array $headers Default headers
132
     * @return void
133
     */
134
    public function setDefaultHeaders(array $headers): void
135
    {
136
        $this->defaultHeaders = $headers;
137
    }
138
139
    /**
140
     * Get API base URL used tokens
141
     *
142
     * @return string API base URL
143
     */
144
    public function getApiBaseUrl(): string
145
    {
146
        return $this->apiBaseUrl;
147
    }
148
149
    /**
150
     * Get current used tokens
151
     *
152
     * @return array Current tokens
153
     */
154
    public function getTokens(): array
155
    {
156
        return $this->tokens;
157
    }
158
159
    /**
160
     * Get last HTTP response
161
     *
162
     * @return ResponseInterface|null Response PSR interface
163
     */
164
    public function getResponse(): ?ResponseInterface
165
    {
166
        return $this->response;
167
    }
168
169
    /**
170
     * Get HTTP response status code
171
     * Return null if no response is available
172
     *
173
     * @return int|null Status code.
174
     */
175
    public function getStatusCode(): ?int
176
    {
177
        return $this->response ? $this->response->getStatusCode() : null;
178
    }
179
180
    /**
181
     * Get HTTP response status message
182
     * Return null if no response is available
183
     *
184
     * @return string|null Message related to status code.
185
     */
186
    public function getStatusMessage(): ?string
187
    {
188
        return $this->response ? $this->response->getReasonPhrase() : null;
189
    }
190
191
    /**
192
     * Get response body serialized into a PHP array
193
     *
194
     * @return array|null Response body as PHP array.
195
     */
196
    public function getResponseBody(): ?array
197
    {
198
        $response = $this->getResponse();
199
        if (empty($response)) {
200
            return null;
201
        }
202
        $responseBody = json_decode((string)$response->getBody(), true);
203
204
        return is_array($responseBody) ? $responseBody : null;
205
    }
206
207
    /**
208
     * Refresh JWT access token.
209
     *
210
     * On success `$this->tokens` data will be updated with new access and renew tokens.
211
     *
212
     * @throws \BadMethodCallException Throws an exception if client has no renew token available.
213
     * @return void
214
     * @throws \BEdita\SDK\BEditaClientException Throws an exception if server response code is not 20x.
215
     */
216
    public function refreshTokens(): void
217
    {
218
        if (empty($this->tokens['renew'])) {
219
            throw new \BadMethodCallException('You must be logged in to renew token');
220
        }
221
222
        $headers = [
223
            'Authorization' => sprintf('Bearer %s', $this->tokens['renew']),
224
        ];
225
        $data = ['grant_type' => 'refresh_token'];
226
227
        $this->sendRequest('POST', '/auth', [], $headers, json_encode($data));
228
        $body = $this->getResponseBody();
229
        if (empty($body['meta']['jwt'])) {
230
            throw new BEditaClientException('Invalid response from server');
231
        }
232
233
        $this->setupTokens($body['meta']);
234
    }
235
236
    /**
237
     * Send a generic JSON API request with a basic retry policy on expired token exception.
238
     *
239
     * @param string $method HTTP Method.
240
     * @param string $path Endpoint URL path.
241
     * @param array|null $query Query string parameters.
242
     * @param string[]|null $headers Custom request headers.
243
     * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Request body.
244
     * @return \Psr\Http\Message\ResponseInterface
245
     */
246
    protected function sendRequestRetry(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null): ResponseInterface
247
    {
248
        try {
249
            return $this->sendRequest($method, $path, $query, $headers, $body);
250
        } catch (BEditaClientException $e) {
251
            // Handle error.
252
            $attributes = $e->getAttributes();
253
            if ($e->getCode() !== 401 || empty($attributes['code']) || $attributes['code'] !== 'be_token_expired') {
254
                // Not an expired token's fault.
255
                throw $e;
256
            }
257
258
            // Refresh and retry.
259
            $this->refreshTokens();
260
            unset($headers['Authorization']);
261
262
            return $this->sendRequest($method, $path, $query, $headers, $body);
263
        }
264
    }
265
266
    /**
267
     * Refresh and retry.
268
     *
269
     * @param string $method HTTP Method.
270
     * @param string $path Endpoint URL path.
271
     * @param array|null $query Query string parameters.
272
     * @param string[]|null $headers Custom request headers.
273
     * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Request body.
274
     * @return \Psr\Http\Message\ResponseInterface
275
     */
276
    protected function refreshAndRetry(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null): ResponseInterface
277
    {
278
        $this->refreshTokens();
279
        unset($headers['Authorization']);
280
281
        return $this->sendRequest($method, $path, $query, $headers, $body);
282
    }
283
284
    /**
285
     * Send a generic JSON API request and retrieve response $this->response
286
     *
287
     * @param string $method HTTP Method.
288
     * @param string $path Endpoint URL path (with or without starting `/`) or absolute API path
289
     * @param array|null $query Query string parameters.
290
     * @param string[]|null $headers Custom request headers.
291
     * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Request body.
292
     * @return \Psr\Http\Message\ResponseInterface
293
     * @throws \BEdita\SDK\BEditaClientException Throws an exception if server response code is not 20x.
294
     */
295
    protected function sendRequest(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null): ResponseInterface
296
    {
297
        $uri = $this->requestUri($path, $query);
298
        $headers = array_merge($this->defaultHeaders, (array)$headers);
299
300
        // set default `Content-Type` if not set and $body not empty
301
        if (!empty($body)) {
302
            $headers = array_merge($this->defaultContentTypeHeader, $headers);
303
        }
304
305
        // Send the request synchronously to retrieve the response.
306
        // Request and response log performed only if configured via `initLogger()`
307
        $request = new Request($method, $uri, $headers, $body);
308
        $this->logRequest($request);
309
        $this->response = $this->jsonApiClient->sendRequest($request);
310
        $this->logResponse($this->response);
311
        if ($this->getStatusCode() >= 400) {
312
            // Something bad just happened.
313
            $response = $this->getResponseBody();
314
            // Message will be 'error` array, if absent use status massage
315
            $message = empty($response['error']) ? $this->getStatusMessage() : $response['error'];
316
            throw new BEditaClientException($message, $this->getStatusCode());
317
        }
318
319
        return $this->response;
320
    }
321
322
    /**
323
     * Create request URI from path.
324
     * If path is absolute, i.e. it starts with 'http://' or 'https://', path is unchanged.
325
     * Otherwise `$this->apiBaseUrl` is prefixed, prepending a `/` if necessary.
326
     *
327
     * @param string $path Endpoint URL path (with or without starting `/`) or absolute API path
328
     * @param array|null $query Query string parameters.
329
     * @return Uri
330
     */
331
    protected function requestUri(string $path, ?array $query = null): Uri
332
    {
333
        if (strpos($path, 'https://') !== 0 && strpos($path, 'http://') !== 0) {
334
            if (substr($path, 0, 1) !== '/') {
335
                $path = '/' . $path;
336
            }
337
            $path = $this->apiBaseUrl . $path;
338
        }
339
        $uri = new Uri($path);
340
341
        // if path contains query strings, remove them from path and add them to query filter
342
        parse_str($uri->getQuery(), $uriQuery);
343
        if ($query) {
344
            $query = array_merge((array)$uriQuery, (array)$query);
345
            $uri = $uri->withQuery(http_build_query($query));
346
        }
347
348
        return $uri;
349
    }
350
351
    /**
352
     * Unset Authorization from defaultHeaders.
353
     *
354
     * @return void
355
     */
356
    protected function unsetAuthorization(): void
357
    {
358
        if (!array_key_exists('Authorization', $this->defaultHeaders)) {
359
            return;
360
        }
361
        unset($this->defaultHeaders['Authorization']);
362
    }
363
364
    /**
365
     * Send a GET request a list of resources or objects or a single resource or object
366
     *
367
     * @param string $path Endpoint URL path to invoke
368
     * @param array|null $query Optional query string
369
     * @param array|null $headers Headers
370
     * @return array|null Response in array format
371
     */
372
    public function get(string $path, ?array $query = null, ?array $headers = null): ?array
373
    {
374
        $this->sendRequestRetry('GET', $path, $query, $headers);
375
376
        return $this->getResponseBody();
377
    }
378
379
    /**
380
     * Send a PATCH request to modify a single resource or object
381
     *
382
     * @param string $path Endpoint URL path to invoke
383
     * @param mixed $body Request body
384
     * @param array|null $headers Custom request headers
385
     * @return array|null Response in array format
386
     */
387
    public function patch(string $path, $body, ?array $headers = null): ?array
388
    {
389
        $this->sendRequestRetry('PATCH', $path, null, $headers, $body);
390
391
        return $this->getResponseBody();
392
    }
393
394
    /**
395
     * Send a POST request for creating resources or objects or other operations like /auth
396
     *
397
     * @param string $path Endpoint URL path to invoke
398
     * @param mixed $body Request body
399
     * @param array|null $headers Custom request headers
400
     * @return array|null Response in array format
401
     */
402
    public function post(string $path, $body, ?array $headers = null): ?array
403
    {
404
        $this->sendRequestRetry('POST', $path, null, $headers, $body);
405
406
        return $this->getResponseBody();
407
    }
408
409
    /**
410
     * Send a DELETE request
411
     *
412
     * @param string $path Endpoint URL path to invoke.
413
     * @param mixed $body Request body
414
     * @param array|null $headers Custom request headers
415
     * @return array|null Response in array format.
416
     */
417
    public function delete(string $path, $body = null, ?array $headers = null): ?array
418
    {
419
        $this->sendRequestRetry('DELETE', $path, null, $headers, $body);
420
421
        return $this->getResponseBody();
422
    }
423
}
424