Issues (3)

src/BaseClient.php (1 issue)

Labels
Severity
1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * BEdita, API-first content management framework
6
 * Copyright 2023 Atlas Srl, ChannelWeb Srl, Chialab Srl
7
 *
8
 * Licensed under The MIT License
9
 * For full copyright and license information, please see the LICENSE.txt
10
 * Redistributions of files must retain the above copyright notice.
11
 */
12
13
namespace BEdita\SDK;
14
15
use BadMethodCallException;
16
use GuzzleHttp\Psr7\Request;
17
use GuzzleHttp\Psr7\Uri;
18
use Http\Adapter\Guzzle7\Client;
19
use Psr\Http\Message\ResponseInterface;
20
use WoohooLabs\Yang\JsonApi\Client\JsonApiClient;
21
22
class BaseClient
23
{
24
    use LogTrait;
25
26
    /**
27
     * Last response.
28
     *
29
     * @var \Psr\Http\Message\ResponseInterface
30
     */
31
    private ResponseInterface $response;
32
33
    /**
34
     * BEdita API base URL
35
     *
36
     * @var string
37
     */
38
    private string $apiBaseUrl;
39
40
    /**
41
     * BEdita API KEY
42
     *
43
     * @var string
44
     */
45
    private string $apiKey;
46
47
    /**
48
     * Default headers in request
49
     *
50
     * @var array
51
     */
52
    private array $defaultHeaders = [
53
        'Accept' => 'application/vnd.api+json',
54
    ];
55
56
    /**
57
     * Default headers in request
58
     *
59
     * @var array
60
     */
61
    private array $defaultContentTypeHeader = [
62
        'Content-Type' => 'application/json',
63
    ];
64
65
    /**
66
     * JWT Auth tokens
67
     *
68
     * @var array
69
     */
70
    private array $tokens = [];
71
72
    /**
73
     * JSON API BEdita client
74
     *
75
     * @var \WoohooLabs\Yang\JsonApi\Client\JsonApiClient
76
     */
77
    private JsonApiClient $jsonApiClient;
78
79
    /**
80
     * Setup main client options:
81
     *  - API base URL
82
     *  - API KEY
83
     *  - Auth tokens 'jwt' and 'renew' (optional)
84
     *
85
     * @param string $apiUrl API base URL
86
     * @param string|null $apiKey API key
87
     * @param array $tokens JWT Autorization tokens as associative array ['jwt' => '###', 'renew' => '###']
88
     * @param array $guzzleConfig Additional default configuration for GuzzleHTTP client.
89
     * @return void
90
     */
91
    public function __construct(string $apiUrl, ?string $apiKey = null, array $tokens = [], array $guzzleConfig = [])
92
    {
93
        $this->apiBaseUrl = $apiUrl;
94
        $this->apiKey = (string)$apiKey;
95
96
        $this->defaultHeaders['X-Api-Key'] = $this->apiKey;
97
        $this->setupTokens($tokens);
98
99
        // setup an asynchronous JSON API client
100
        $guzzleClient = Client::createWithConfig($guzzleConfig);
101
        $this->jsonApiClient = new JsonApiClient($guzzleClient);
102
    }
103
104
    /**
105
     * Setup JWT access and refresh tokens.
106
     *
107
     * @param array $tokens JWT tokens as associative array ['jwt' => '###', 'renew' => '###']
108
     * @return void
109
     */
110
    public function setupTokens(array $tokens): void
111
    {
112
        $this->tokens = $tokens;
113
        if (!empty($tokens['jwt'])) {
114
            $this->defaultHeaders['Authorization'] = sprintf('Bearer %s', $tokens['jwt']);
115
        } else {
116
            unset($this->defaultHeaders['Authorization']);
117
        }
118
    }
119
120
    /**
121
     * Get default headers in use on every request
122
     *
123
     * @return array Default headers
124
     */
125
    public function getDefaultHeaders(): array
126
    {
127
        return $this->defaultHeaders;
128
    }
129
130
    /**
131
     * Set default headers in use on every request
132
     *
133
     * @param array $headers Default headers
134
     * @return void
135
     */
136
    public function setDefaultHeaders(array $headers): void
137
    {
138
        $this->defaultHeaders = $headers;
139
    }
140
141
    /**
142
     * Get API base URL used tokens
143
     *
144
     * @return string API base URL
145
     */
146
    public function getApiBaseUrl(): string
147
    {
148
        return $this->apiBaseUrl;
149
    }
150
151
    /**
152
     * Get current used tokens
153
     *
154
     * @return array Current tokens
155
     */
156
    public function getTokens(): array
157
    {
158
        return $this->tokens;
159
    }
160
161
    /**
162
     * Get last HTTP response
163
     *
164
     * @return \Psr\Http\Message\ResponseInterface|null Response PSR interface
165
     */
166
    public function getResponse(): ?ResponseInterface
167
    {
168
        return $this->response ?? null;
169
    }
170
171
    /**
172
     * Get HTTP response status code
173
     * Return null if no response is available
174
     *
175
     * @return int|null Status code.
176
     */
177
    public function getStatusCode(): ?int
178
    {
179
        return !empty($this->getResponse()) ? $this->response->getStatusCode() : null;
180
    }
181
182
    /**
183
     * Get HTTP response status message
184
     * Return null if no response is available
185
     *
186
     * @return string|null Message related to status code.
187
     */
188
    public function getStatusMessage(): ?string
189
    {
190
        return !empty($this->getResponse()) ? $this->response->getReasonPhrase() : null;
191
    }
192
193
    /**
194
     * Get response body serialized into a PHP array
195
     *
196
     * @return array|null Response body as PHP array.
197
     */
198
    public function getResponseBody(): ?array
199
    {
200
        $response = $this->getResponse();
201
        if (empty($response)) {
202
            return null;
203
        }
204
        $responseBody = json_decode((string)$response->getBody(), true);
205
206
        return is_array($responseBody) ? $responseBody : null;
207
    }
208
209
    /**
210
     * Refresh JWT access token.
211
     *
212
     * On success `$this->tokens` data will be updated with new access and renew tokens.
213
     *
214
     * @throws \BadMethodCallException Throws an exception if client has no renew token available.
215
     * @return void
216
     * @throws \BEdita\SDK\BEditaClientException Throws an exception if server response code is not 20x.
217
     */
218
    public function refreshTokens(): void
219
    {
220
        if (empty($this->tokens['renew'])) {
221
            throw new BadMethodCallException('You must be logged in to renew token');
222
        }
223
224
        $headers = [
225
            'Authorization' => sprintf('Bearer %s', $this->tokens['renew']),
226
        ];
227
        $data = ['grant_type' => 'refresh_token'];
228
229
        $this->sendRequest('POST', '/auth', [], $headers, json_encode($data));
230
        $body = $this->getResponseBody();
231
        if (empty($body['meta']['jwt'])) {
232
            throw new BEditaClientException('Invalid response from server');
233
        }
234
235
        $this->setupTokens($body['meta']);
236
    }
237
238
    /**
239
     * Send a generic JSON API request with a basic retry policy on expired token exception.
240
     *
241
     * @param string $method HTTP Method.
242
     * @param string $path Endpoint URL path.
243
     * @param array|null $query Query string parameters.
244
     * @param array<string>|null $headers Custom request headers.
245
     * @param \Psr\Http\Message\StreamInterface|resource|string|null $body Request body.
246
     * @return \Psr\Http\Message\ResponseInterface
247
     */
248
    protected function sendRequestRetry(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null): ResponseInterface
249
    {
250
        try {
251
            return $this->sendRequest($method, $path, $query, $headers, $body);
252
        } catch (BEditaClientException $e) {
253
            // Handle error.
254
            $attributes = $e->getAttributes();
255
            if ($e->getCode() !== 401 || empty($attributes['code']) || $attributes['code'] !== 'be_token_expired') {
256
                // Not an expired token's fault.
257
                throw $e;
258
            }
259
260
            // Refresh and retry.
261
            $this->refreshTokens();
262
            unset($headers['Authorization']);
263
264
            return $this->sendRequest($method, $path, $query, $headers, $body);
265
        }
266
    }
267
268
    /**
269
     * Refresh and retry.
270
     *
271
     * @param string $method HTTP Method.
272
     * @param string $path Endpoint URL path.
273
     * @param array|null $query Query string parameters.
274
     * @param array<string>|null $headers Custom request headers.
275
     * @param \Psr\Http\Message\StreamInterface|resource|string|null $body Request body.
276
     * @return \Psr\Http\Message\ResponseInterface
277
     */
278
    protected function refreshAndRetry(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null): ResponseInterface
279
    {
280
        $this->refreshTokens();
281
        unset($headers['Authorization']);
282
283
        return $this->sendRequest($method, $path, $query, $headers, $body);
284
    }
285
286
    /**
287
     * Send a generic JSON API request and retrieve response $this->response
288
     *
289
     * @param string $method HTTP Method.
290
     * @param string $path Endpoint URL path (with or without starting `/`) or absolute API path
291
     * @param array|null $query Query string parameters.
292
     * @param array<string>|null $headers Custom request headers.
293
     * @param \Psr\Http\Message\StreamInterface|resource|string|null $body Request body.
294
     * @return \Psr\Http\Message\ResponseInterface
295
     * @throws \BEdita\SDK\BEditaClientException Throws an exception if server response code is not 20x.
296
     */
297
    protected function sendRequest(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null): ResponseInterface
298
    {
299
        $uri = $this->requestUri($path, $query);
300
        $headers = array_merge($this->defaultHeaders, (array)$headers);
301
302
        // set default `Content-Type` if not set and $body not empty
303
        if (!empty($body)) {
304
            $headers = array_merge($this->defaultContentTypeHeader, $headers);
305
        }
306
307
        // Send the request synchronously to retrieve the response.
308
        // Request and response log performed only if configured via `initLogger()`
309
        $request = new Request($method, $uri, $headers, $body);
310
        $this->logRequest($request);
311
        $this->response = $this->jsonApiClient->sendRequest($request);
312
        $this->logResponse($this->response);
313
        if ($this->getStatusCode() >= 400) {
314
            // Something bad just happened.
315
            $response = $this->getResponseBody();
316
            // Message will be 'error` array, if absent use status massage
317
            $message = empty($response['error']) ? $this->getStatusMessage() : $response['error'];
318
            throw new BEditaClientException($message, $this->getStatusCode());
0 ignored issues
show
It seems like $message can also be of type null; however, parameter $message of BEdita\SDK\BEditaClientException::__construct() does only seem to accept array|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

318
            throw new BEditaClientException(/** @scrutinizer ignore-type */ $message, $this->getStatusCode());
Loading history...
319
        }
320
321
        return $this->response;
322
    }
323
324
    /**
325
     * Create request URI from path.
326
     * If path is absolute, i.e. it starts with 'http://' or 'https://', path is unchanged.
327
     * Otherwise `$this->apiBaseUrl` is prefixed, prepending a `/` if necessary.
328
     *
329
     * @param string $path Endpoint URL path (with or without starting `/`) or absolute API path
330
     * @param array|null $query Query string parameters.
331
     * @return \GuzzleHttp\Psr7\Uri
332
     */
333
    protected function requestUri(string $path, ?array $query = null): Uri
334
    {
335
        if (strpos($path, 'https://') !== 0 && strpos($path, 'http://') !== 0) {
336
            if (substr($path, 0, 1) !== '/') {
337
                $path = '/' . $path;
338
            }
339
            $path = $this->apiBaseUrl . $path;
340
        }
341
        $uri = new Uri($path);
342
343
        // if path contains query strings, remove them from path and add them to query filter
344
        parse_str($uri->getQuery(), $uriQuery);
345
        if ($query) {
346
            $query = array_merge((array)$uriQuery, (array)$query);
347
            $uri = $uri->withQuery(http_build_query($query));
348
        }
349
350
        return $uri;
351
    }
352
353
    /**
354
     * Unset Authorization from defaultHeaders.
355
     *
356
     * @return void
357
     */
358
    protected function unsetAuthorization(): void
359
    {
360
        if (!array_key_exists('Authorization', $this->defaultHeaders)) {
361
            return;
362
        }
363
        unset($this->defaultHeaders['Authorization']);
364
    }
365
366
    /**
367
     * Send a GET request a list of resources or objects or a single resource or object
368
     *
369
     * @param string $path Endpoint URL path to invoke
370
     * @param array|null $query Optional query string
371
     * @param array|null $headers Headers
372
     * @return array|null Response in array format
373
     */
374
    public function get(string $path, ?array $query = null, ?array $headers = null): ?array
375
    {
376
        $this->sendRequestRetry('GET', $path, $query, $headers);
377
378
        return $this->getResponseBody();
379
    }
380
381
    /**
382
     * Send a PATCH request to modify a single resource or object
383
     *
384
     * @param string $path Endpoint URL path to invoke
385
     * @param string|null $body Request body
386
     * @param array|null $headers Custom request headers
387
     * @return array|null Response in array format
388
     */
389
    public function patch(string $path, ?string $body = null, ?array $headers = null): ?array
390
    {
391
        $this->sendRequestRetry('PATCH', $path, null, $headers, $body);
392
393
        return $this->getResponseBody();
394
    }
395
396
    /**
397
     * Send a POST request for creating resources or objects or other operations like /auth
398
     *
399
     * @param string $path Endpoint URL path to invoke
400
     * @param string|null $body Request body
401
     * @param array|null $headers Custom request headers
402
     * @return array|null Response in array format
403
     */
404
    public function post(string $path, ?string $body = null, ?array $headers = null): ?array
405
    {
406
        $this->sendRequestRetry('POST', $path, null, $headers, $body);
407
408
        return $this->getResponseBody();
409
    }
410
411
    /**
412
     * Send a DELETE request
413
     *
414
     * @param string $path Endpoint URL path to invoke.
415
     * @param string|null $body Request body
416
     * @param array|null $headers Custom request headers
417
     * @return array|null Response in array format.
418
     */
419
    public function delete(string $path, ?string $body = null, ?array $headers = null): ?array
420
    {
421
        $this->sendRequestRetry('DELETE', $path, null, $headers, $body);
422
423
        return $this->getResponseBody();
424
    }
425
}
426