Completed
Pull Request — master (#4)
by Dante
03:22
created

BEditaClient   B

Complexity

Total Complexity 52

Size/Duplication

Total Lines 577
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
dl 0
loc 577
rs 7.9487
c 0
b 0
f 0
wmc 52

29 Methods

Rating   Name   Duplication   Size   Complexity  
A deleteObject() 0 3 1
B sendRequest() 0 34 6
A getStatusCode() 0 3 2
A upload() 0 19 4
A getObjects() 0 3 1
A getTokens() 0 3 1
A saveObject() 0 20 3
B sendRequestRetry() 0 17 5
A getStatusMessage() 0 3 2
A getApiBaseUrl() 0 3 1
A getObject() 0 3 1
A getResponse() 0 3 1
A getDefaultHeaders() 0 3 1
A patch() 0 5 1
A post() 0 5 1
A __construct() 0 11 1
A authenticate() 0 5 1
A refreshTokens() 0 17 3
A removeRelated() 0 5 1
A restoreObject() 0 10 1
A createMediaFromStream() 0 15 3
A delete() 0 5 1
A get() 0 5 1
A remove() 0 3 1
A getRelated() 0 3 1
A schema() 0 5 1
A setupTokens() 0 7 2
A addRelated() 0 5 1
A getResponseBody() 0 11 3

How to fix   Complexity   

Complex Class

Complex classes like BEditaClient often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use BEditaClient, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2018 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 Cake\Network\Exception\NotFoundException;
0 ignored issues
show
Bug introduced by
The type Cake\Network\Exception\NotFoundException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
14
use GuzzleHttp\Psr7\Request;
15
use GuzzleHttp\Psr7\Uri;
16
use Http\Adapter\Guzzle6\Client;
17
use Psr\Http\Message\ResponseInterface;
18
use WoohooLabs\Yang\JsonApi\Client\JsonApiClient;
19
20
/**
21
 * BEdita4 API Client class
22
 */
23
class BEditaClient
24
{
25
26
    /**
27
     * Last response.
28
     *
29
     * @var \Psr\Http\Message\ResponseInterface
30
     */
31
    private $response = null;
32
33
    /**
34
     * BEdita4 API base URL
35
     *
36
     * @var string
37
     */
38
    private $apiBaseUrl = null;
39
40
    /**
41
     * BEdita4 API KEY
42
     *
43
     * @var string
44
     */
45
    private $apiKey = null;
46
47
    /**
48
     * Default headers in request
49
     *
50
     * @var array
51
     */
52
    private $defaultHeaders = [
53
        'Accept' => 'application/vnd.api+json',
54
    ];
55
56
    /**
57
     * Default headers in request
58
     *
59
     * @var array
60
     */
61
    private $defaultContentTypeHeader = [
62
        'Content-Type' => 'application/json',
63
    ];
64
65
    /**
66
     * JWT Auth tokens
67
     *
68
     * @var array
69
     */
70
    private $tokens = [];
71
72
    /**
73
     * JSON API BEdita4 client
74
     *
75
     * @var \WoohooLabs\Yang\JsonApi\Client\JsonApiClient
76
     */
77
    private $jsonApiClient = null;
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 $apiKey API key
87
     * @param array $tokens JWT Autorization tokens as associative array ['jwt' => '###', 'renew' => '###']
88
     * @return void
89
     */
90
    public function __construct(string $apiUrl, ?string $apiKey = null, array $tokens = [])
91
    {
92
        $this->apiBaseUrl = $apiUrl;
93
        $this->apiKey = $apiKey;
94
95
        $this->defaultHeaders['X-Api-Key'] = $this->apiKey;
96
        $this->setupTokens($tokens);
97
98
        // setup an asynchronous JSON API client
99
        $guzzleClient = Client::createWithConfig([]);
100
        $this->jsonApiClient = new JsonApiClient($guzzleClient);
101
    }
102
103
    /**
104
     * Setup JWT access and refresh tokens.
105
     *
106
     * @param array $tokens JWT tokens as associative array ['jwt' => '###', 'renew' => '###']
107
     * @return void
108
     */
109
    public function setupTokens(array $tokens) : void
110
    {
111
        $this->tokens = $tokens;
112
        if (!empty($tokens['jwt'])) {
113
            $this->defaultHeaders['Authorization'] = sprintf('Bearer %s', $tokens['jwt']);
114
        } else {
115
            unset($this->defaultHeaders['Authorization']);
116
        }
117
    }
118
119
    /**
120
     * Get default headers in use on every request
121
     *
122
     * @return array Default headers
123
     * @codeCoverageIgnore
124
     */
125
    public function getDefaultHeaders() : array
126
    {
127
        return $this->defaultHeaders;
128
    }
129
130
    /**
131
     * Get API base URL used tokens
132
     *
133
     * @return string API base URL
134
     * @codeCoverageIgnore
135
     */
136
    public function getApiBaseUrl() : string
137
    {
138
        return $this->apiBaseUrl;
139
    }
140
141
    /**
142
     * Get current used tokens
143
     *
144
     * @return array Current tokens
145
     * @codeCoverageIgnore
146
     */
147
    public function getTokens() : array
148
    {
149
        return $this->tokens;
150
    }
151
152
    /**
153
     * Get last HTTP response
154
     *
155
     * @return ResponseInterface Response PSR interface
156
     * @codeCoverageIgnore
157
     */
158
    public function getResponse() : ResponseInterface
159
    {
160
        return $this->response;
161
    }
162
163
    /**
164
     * Get HTTP response status code
165
     * Return null if no response is available
166
     *
167
     * @return int|null Status code.
168
     */
169
    public function getStatusCode() : ?int
170
    {
171
        return $this->response ? $this->response->getStatusCode() : null;
172
    }
173
174
    /**
175
     * Get HTTP response status message
176
     * Return null if no response is available
177
     *
178
     * @return string|null Message related to status code.
179
     */
180
    public function getStatusMessage() : ?string
181
    {
182
        return $this->response ? $this->response->getReasonPhrase() : null;
183
    }
184
185
    /**
186
     * Get response body serialized into a PHP array
187
     *
188
     * @return array|null Response body as PHP array.
189
     */
190
    public function getResponseBody() : ?array
191
    {
192
        if (empty($this->response)) {
193
            return null;
194
        }
195
        $responseBody = json_decode((string)$this->response->getBody(), true);
196
        if (!is_array($responseBody)) {
197
            return null;
198
        }
199
200
        return $responseBody;
201
    }
202
203
    /**
204
     * Classic authentication via POST /auth using username and password
205
     *
206
     * @param string $username username
207
     * @param string $password password
208
     * @return array|null Response in array format
209
     */
210
    public function authenticate(string $username, string $password) : ?array
211
    {
212
        $body = json_encode(compact('username', 'password'));
213
214
        return $this->post('/auth', $body, ['Content-Type' => 'application/json']);
215
    }
216
217
    /**
218
     * Send a GET request a list of resources or objects or a single resource or object
219
     *
220
     * @param string $path Endpoint URL path to invoke
221
     * @param array|null $query Optional query string
222
     * @param array|null $headers Headers
223
     * @return array|null Response in array format
224
     */
225
    public function get(string $path, ?array $query = null, ?array $headers = null) : ?array
226
    {
227
        $this->sendRequestRetry('GET', $path, $query, $headers);
228
229
        return $this->getResponseBody();
230
    }
231
232
    /**
233
     * GET a list of objects of a given type
234
     *
235
     * @param string $type Object type name
236
     * @param array|null $query Optional query string
237
     * @param array|null $headers Custom request headers
238
     * @return array|null Response in array format
239
     */
240
    public function getObjects(string $type = 'objects', ?array $query = null, ?array $headers = null) : ?array
241
    {
242
        return $this->get(sprintf('/%s', $type), $query, $headers);
243
    }
244
245
    /**
246
     * GET a single object of a given type
247
     *
248
     * @param int|string $id Object id
249
     * @param string $type Object type name
250
     * @param array|null $query Optional query string
251
     * @param array|null $headers Custom request headers
252
     * @return array|null Response in array format
253
     */
254
    public function getObject($id, string $type = 'objects', ?array $query = null, ?array $headers = null) : ?array
255
    {
256
        return $this->get(sprintf('/%s/%s', $type, $id), $query, $headers);
257
    }
258
259
    /**
260
     * GET a list of related objects
261
     *
262
     * @param int|string $id Object id
263
     * @param string $type Object type name
264
     * @param string $relation Relation name
265
     * @param array|null $query Optional query string
266
     * @param array|null $headers Custom request headers
267
     * @return array|null Response in array format
268
     */
269
    public function getRelated($id, string $type, string $relation, ?array $query = null, ?array $headers = null) : ?array
270
    {
271
        return $this->get(sprintf('/%s/%s/%s', $type, $id, $relation), $query, $headers);
272
    }
273
274
    /**
275
     * Add a list of related objects
276
     *
277
     * @param int|string $id Object id
278
     * @param string $type Object type name
279
     * @param string $relation Relation name
280
     * @param string $data Related objects to add, MUST contain id and type
281
     * @param array|null $headers Custom request headers
282
     * @return array|null Response in array format
283
     */
284
    public function addRelated($id, string $type, string $relation, array $data, ?array $headers = null) : ?array
285
    {
286
        $body = compact('data');
287
288
        return $this->post(sprintf('/%s/%s/relationships/%s', $type, $id, $relation), json_encode($body), $headers);
289
    }
290
291
    /**
292
     * DELETE a list of related objects
293
     *
294
     * @param int|string $id Object id
295
     * @param string $type Object type name
296
     * @param string $relation Relation name
297
     * @param string $data Related objects to remove from relation
298
     * @param array|null $headers Custom request headers
299
     * @return array|null Response in array format
300
     */
301
    public function removeRelated($id, string $type, string $relation, array $data, ?array $headers = null) : ?array
302
    {
303
        $body = compact('data');
304
305
        return $this->delete(sprintf('/%s/%s/relationships/%s', $type, $id, $relation), json_encode($body), $headers);
306
    }
307
308
    /**
309
     * Create a new object (POST) or modify an existing one (PATCH)
310
     *
311
     * @param string $type Object type name
312
     * @param array $data Object data to save
313
     * @param array|null $headers Custom request headers
314
     * @return array|null Response in array format
315
     */
316
    public function saveObject(string $type, array $data, ?array $headers = null) : ?array
317
    {
318
        $id = null;
319
        if (array_key_exists('id', $data)) {
320
            $id = $data['id'];
321
            unset($data['id']);
322
        }
323
324
        $body = [
325
            'data' => [
326
                'type' => $type,
327
                'attributes' => $data,
328
            ],
329
        ];
330
        if (!$id) {
331
            return $this->post(sprintf('/%s', $type), json_encode($body), $headers);
332
        }
333
        $body['data']['id'] = $id;
334
335
        return $this->patch(sprintf('/%s/%s', $type, $id), json_encode($body), $headers);
336
    }
337
338
    /**
339
     * Delete an object (DELETE) => move to trashcan.
340
     *
341
     * @param int|string $id Object id
342
     * @param string $type Object type name
343
     * @return array|null Response in array format
344
     */
345
    public function deleteObject($id, string $type) : ?array
346
    {
347
        return $this->delete(sprintf('/%s/%s', $type, $id));
348
    }
349
350
    /**
351
     * Remove an object => permanently remove object from trashcan.
352
     *
353
     * @param int|string $id Object id
354
     * @return array|null Response in array format
355
     */
356
    public function remove($id) : ?array
357
    {
358
        return $this->delete(sprintf('/trash/%s', $id));
359
    }
360
361
    /**
362
     * Upload file (POST)
363
     *
364
     * @param string $filename The file name
365
     * @param string $filepath File full path: could be on a local filesystem or a remote reachable URL
366
     * @param array|null $headers Custom request headers
367
     * @return array|null Response in array format
368
     * @throws \Cake\Network\Exception\NotFoundException
369
     */
370
    public function upload($filename, $filepath, ?array $headers = null) : array
371
    {
372
        $path = sprintf('/streams/upload/%s', $filename);
373
        try {
374
            if (!file_exists($filepath)) {
375
                throw new Exception('File not found.');
0 ignored issues
show
Bug introduced by
The type BEdita\SDK\Exception was not found. Did you mean Exception? If so, make sure to prefix the type with \.
Loading history...
376
            }
377
            $handle = fopen($filepath, "r");
378
            if (!$handle) {
0 ignored issues
show
introduced by
The condition $handle is always false.
Loading history...
379
                throw new Exception('File open failed.');
380
            }
381
            $body = fread($handle, filesize($filepath));
382
            fclose($handle);
383
        } catch (Exception $e) {
384
            throw new BEditaClientException('Error opening file');
385
        }
386
        $headers['Content-Type'] = mime_content_type($filepath);
387
388
        return $this->post($path, $body, $headers);
389
    }
390
391
    /**
392
     * Create media by type and body data and link it to a stream:
393
     *  - `POST /:type` with `$body` as payload, create media object
394
     *  - `PATCH /streams/:stream_id/relationships/object` modify stream adding relation to media
395
     *  - `GET /:type/:id` get media data
396
     *
397
     * @param string $streamId The stream identifier
398
     * @param string $type The type
399
     * @param array $body The body data
400
     * @return array|null Response in array format
401
     */
402
    public function createMediaFromStream($streamId, $type, $body) : array
403
    {
404
        $response = $this->post(sprintf('/%s', $type), json_encode($body));
405
        if (empty($response)) {
406
            throw new BEditaClientException('Invalid response from POST ' . sprintf('/%s', $type));
407
        }
408
        $id = $response['data']['id'];
409
        $data = compact('id', 'type');
410
        $body = compact('data');
411
        $response = $this->patch(sprintf('/streams/%s/relationships/object', $streamId), json_encode($body));
412
        if (empty($response)) {
413
            throw new BEditaClientException('Invalid response from PATCH ' . sprintf('/streams/%s/relationships/object', $id));
414
        }
415
416
        return $this->getObject($data['id'], $data['type']);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getObject(...a['id'], $data['type']) could return the type null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
417
    }
418
419
    /**
420
     * Get JSON SCHEMA of a resource or object
421
     *
422
     * @param string $type Object or resource type name
423
     * @return array|null JSON SCHEMA in array format
424
     */
425
    public function schema(string $type) : ?array
426
    {
427
        $h = ['Accept' => 'application/schema+json'];
428
429
        return $this->get(sprintf('/model/schema/%s', $type), null, $h);
430
    }
431
432
    /**
433
     * Restore object from trash
434
     *
435
     * @param int|string $id Object id
436
     * @param string $type Object type name
437
     * @return array|null Response in array format
438
     */
439
    public function restoreObject($id, string $type) : ?array
440
    {
441
        $body = [
442
            'data' => [
443
                'id' => $id,
444
                'type' => $type,
445
            ],
446
        ];
447
448
        return $this->patch(sprintf('/%s/%s', 'trash', $id), json_encode($body));
449
    }
450
451
    /**
452
     * Send a PATCH request to modify a single resource or object
453
     *
454
     * @param string $path Endpoint URL path to invoke
455
     * @param mixed $body Request body
456
     * @param array|null $headers Custom request headers
457
     * @return array|null Response in array format
458
     */
459
    public function patch(string $path, $body, ?array $headers = null) : ?array
460
    {
461
        $this->sendRequestRetry('PATCH', $path, null, $headers, $body);
462
463
        return $this->getResponseBody();
464
    }
465
466
    /**
467
     * Send a POST request for creating resources or objects or other operations like /auth
468
     *
469
     * @param string $path Endpoint URL path to invoke
470
     * @param mixed $body Request body
471
     * @param array|null $headers Custom request headers
472
     * @return array|null Response in array format
473
     */
474
    public function post(string $path, $body, ?array $headers = null) : ?array
475
    {
476
        $this->sendRequestRetry('POST', $path, null, $headers, $body);
477
478
        return $this->getResponseBody();
479
    }
480
481
    /**
482
     * Send a DELETE request
483
     *
484
     * @param string $path Endpoint URL path to invoke.
485
     * @param mixed $body Request body
486
     * @param array|null $headers Custom request headers
487
     * @return array|null Response in array format.
488
     */
489
    public function delete(string $path, $body = null, ?array $headers = null) : ?array
490
    {
491
        $this->sendRequestRetry('DELETE', $path, null, $headers, $body);
492
493
        return $this->getResponseBody();
494
    }
495
496
    /**
497
     * Send a generic JSON API request with a basic retry policy on expired token exception.
498
     *
499
     * @param string $method HTTP Method.
500
     * @param string $path Endpoint URL path.
501
     * @param array|null $query Query string parameters.
502
     * @param string[]|null $headers Custom request headers.
503
     * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Request body.
504
     * @return \Psr\Http\Message\ResponseInterface
505
     */
506
    protected function sendRequestRetry(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null) : ResponseInterface
507
    {
508
        try {
509
            return $this->sendRequest($method, $path, $query, $headers, $body);
510
        } catch (BEditaClientException $e) {
511
            // Handle error.
512
            $attributes = $e->getAttributes();
513
            if ($e->getCode() !== 401 || empty($attributes['code']) || $attributes['code'] !== 'be_token_expired') {
514
                // Not an expired token's fault.
515
                throw $e;
516
            }
517
518
            // Refresh and retry.
519
            $this->refreshTokens();
520
            unset($headers['Authorization']);
521
522
            return $this->sendRequest($method, $path, $query, $headers, $body);
523
        }
524
    }
525
526
    /**
527
     * Send a generic JSON API request and retrieve response $this->response
528
     *
529
     * @param string $method HTTP Method.
530
     * @param string $path Endpoint URL path.
531
     * @param array|null $query Query string parameters.
532
     * @param string[]|null $headers Custom request headers.
533
     * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Request body.
534
     * @return \Psr\Http\Message\ResponseInterface
535
     * @throws \App\Model\API\BEditaClientException Throws an exception if server response code is not 20x.
536
     */
537
    protected function sendRequest(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null) : ResponseInterface
538
    {
539
        $uri = new Uri($this->apiBaseUrl);
540
        $uri = $uri->withPath($uri->getPath() . '/' . $path);
541
        if ($query) {
542
            $uri = $uri->withQuery(http_build_query((array)$query));
543
        }
544
        $headers = array_merge($this->defaultHeaders, (array)$headers);
545
546
        // set default `Content-Type` if not set and $body not empty
547
        if (!empty($body)) {
548
            $headers = array_merge($this->defaultContentTypeHeader, $headers);
549
        }
550
551
        // Send the request synchronously to retrieve the response.
552
        $this->response = $this->jsonApiClient->sendRequest(new Request($method, $uri, $headers, $body));
553
        if ($this->getStatusCode() >= 400) {
554
            // Something bad just happened.
555
            $statusCode = $this->getStatusCode();
556
            $response = $this->getResponseBody();
557
558
            $code = (string)$statusCode;
559
            $reason = $this->getStatusMessage();
560
            if (!empty($response['error']['code'])) {
561
                $code = $response['error']['code'];
562
            }
563
            if (!empty($response['error']['title'])) {
564
                $reason = $response['error']['title'];
565
            }
566
567
            throw new BEditaClientException(compact('code', 'reason'), $statusCode);
568
        }
569
570
        return $this->response;
571
    }
572
573
    /**
574
     * Refresh JWT access token.
575
     *
576
     * On success `$this->tokens` data will be updated with new access and renew tokens.
577
     *
578
     * @throws \BadMethodCallException Throws an exception if client has no renew token available.
579
     * @throws \Cake\Network\Exception\ServiceUnavailableException Throws an exception if server response doesn't
580
     *      include the expected data.
581
     * @return void
582
     */
583
    public function refreshTokens() : void
584
    {
585
        if (empty($this->tokens['renew'])) {
586
            throw new \BadMethodCallException('You must be logged in to renew token');
587
        }
588
589
        $headers = [
590
            'Authorization' => sprintf('Bearer %s', $this->tokens['renew']),
591
        ];
592
593
        $this->sendRequest('POST', '/auth', [], $headers);
594
        $body = $this->getResponseBody();
595
        if (empty($body['meta']['jwt'])) {
596
            throw new BEditaClientException('Invalid response from server');
597
        }
598
599
        $this->setupTokens($body['meta']);
600
    }
601
}
602