Passed
Pull Request — master (#38)
by Dante
59s
created

BaseClient::getApiBaseUrl()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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