Completed
Push — master ( 34ff25...7d00bf )
by Alberto
11s
created

BEditaClient::relationData()   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
/**
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 GuzzleHttp\Psr7\Request;
14
use GuzzleHttp\Psr7\Uri;
15
use Http\Adapter\Guzzle6\Client;
16
use Psr\Http\Message\ResponseInterface;
17
use WoohooLabs\Yang\JsonApi\Client\JsonApiClient;
18
19
/**
20
 * BEdita4 API Client class
21
 */
22
class BEditaClient
23
{
24
25
    /**
26
     * Last response.
27
     *
28
     * @var \Psr\Http\Message\ResponseInterface
29
     */
30
    private $response = null;
31
32
    /**
33
     * BEdita4 API base URL
34
     *
35
     * @var string
36
     */
37
    private $apiBaseUrl = null;
38
39
    /**
40
     * BEdita4 API KEY
41
     *
42
     * @var string
43
     */
44
    private $apiKey = null;
45
46
    /**
47
     * Default headers in request
48
     *
49
     * @var array
50
     */
51
    private $defaultHeaders = [
52
        'Accept' => 'application/vnd.api+json',
53
    ];
54
55
    /**
56
     * Default headers in request
57
     *
58
     * @var array
59
     */
60
    private $defaultContentTypeHeader = [
61
        'Content-Type' => 'application/json',
62
    ];
63
64
    /**
65
     * JWT Auth tokens
66
     *
67
     * @var array
68
     */
69
    private $tokens = [];
70
71
    /**
72
     * JSON API BEdita4 client
73
     *
74
     * @var \WoohooLabs\Yang\JsonApi\Client\JsonApiClient
75
     */
76
    private $jsonApiClient = null;
77
78
    /**
79
     * Setup main client options:
80
     *  - API base URL
81
     *  - API KEY
82
     *  - Auth tokens 'jwt' and 'renew' (optional)
83
     *
84
     * @param string $apiUrl API base URL
85
     * @param string $apiKey API key
86
     * @param array $tokens JWT Autorization tokens as associative array ['jwt' => '###', 'renew' => '###']
87
     * @return void
88
     */
89
    public function __construct(string $apiUrl, ?string $apiKey = null, array $tokens = [])
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([]);
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
     * @codeCoverageIgnore
123
     */
124
    public function getDefaultHeaders() : array
125
    {
126
        return $this->defaultHeaders;
127
    }
128
129
    /**
130
     * Get API base URL used tokens
131
     *
132
     * @return string API base URL
133
     * @codeCoverageIgnore
134
     */
135
    public function getApiBaseUrl() : string
136
    {
137
        return $this->apiBaseUrl;
138
    }
139
140
    /**
141
     * Get current used tokens
142
     *
143
     * @return array Current tokens
144
     * @codeCoverageIgnore
145
     */
146
    public function getTokens() : array
147
    {
148
        return $this->tokens;
149
    }
150
151
    /**
152
     * Get last HTTP response
153
     *
154
     * @return ResponseInterface|null Response PSR interface
155
     * @codeCoverageIgnore
156
     */
157
    public function getResponse() : ?ResponseInterface
158
    {
159
        return $this->response;
160
    }
161
162
    /**
163
     * Get HTTP response status code
164
     * Return null if no response is available
165
     *
166
     * @return int|null Status code.
167
     */
168
    public function getStatusCode() : ?int
169
    {
170
        return $this->response ? $this->response->getStatusCode() : null;
171
    }
172
173
    /**
174
     * Get HTTP response status message
175
     * Return null if no response is available
176
     *
177
     * @return string|null Message related to status code.
178
     */
179
    public function getStatusMessage() : ?string
180
    {
181
        return $this->response ? $this->response->getReasonPhrase() : null;
182
    }
183
184
    /**
185
     * Get response body serialized into a PHP array
186
     *
187
     * @return array|null Response body as PHP array.
188
     */
189
    public function getResponseBody() : ?array
190
    {
191
        $response = $this->getResponse();
192
        if (empty($response)) {
193
            return null;
194
        }
195
        $responseBody = json_decode((string)$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 resources or 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 resources or objects
261
     *
262
     * @param int|string $id Resource id or object uname/id
263
     * @param string $type 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 resources or objects
276
     *
277
     * @param int|string $id Resource id or object uname/id
278
     * @param string $type Type name
279
     * @param string $relation Relation name
280
     * @param string $data Related resources or 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
     * Remove a list of related resources or objects
293
     *
294
     * @param int|string $id Resource id or object uname/id
295
     * @param string $type Type name
296
     * @param string $relation Relation name
297
     * @param string $data Related resources or 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
     * Replace a list of related resources or objects: previuosly related are removed and replaced with these.
310
     *
311
     * @param int|string $id Object id
312
     * @param string $type Object type name
313
     * @param string $relation Relation name
314
     * @param string $data Related resources or objects to insert
315
     * @param array|null $headers Custom request headers
316
     * @return array|null Response in array format
317
     */
318
    public function replaceRelated($id, string $type, string $relation, array $data, ?array $headers = null) : ?array
319
    {
320
        $body = compact('data');
321
322
        return $this->patch(sprintf('/%s/%s/relationships/%s', $type, $id, $relation), json_encode($body), $headers);
323
    }
324
325
    /**
326
     * Create a new object or resource (POST) or modify an existing one (PATCH)
327
     *
328
     * @param string $type Object or resource type name
329
     * @param array $data Object or resource data to save
330
     * @param array|null $headers Custom request headers
331
     * @return array|null Response in array format
332
     */
333
    public function save(string $type, array $data, ?array $headers = null) : ?array
334
    {
335
        $id = null;
336
        if (array_key_exists('id', $data)) {
337
            $id = $data['id'];
338
            unset($data['id']);
339
        }
340
341
        $body = [
342
            'data' => [
343
                'type' => $type,
344
                'attributes' => $data,
345
            ],
346
        ];
347
        if (!$id) {
348
            return $this->post(sprintf('/%s', $type), json_encode($body), $headers);
349
        }
350
        $body['data']['id'] = $id;
351
352
        return $this->patch(sprintf('/%s/%s', $type, $id), json_encode($body), $headers);
353
    }
354
355
    /**
356
     * [DEPRECATED] Create a new object (POST) or modify an existing one (PATCH)
357
     *
358
     * @param string $type Object type name
359
     * @param array $data Object data to save
360
     * @param array|null $headers Custom request headers
361
     * @return array|null Response in array format
362
     * @deprecated Use `save()` method instead
363
     * @codeCoverageIgnore
364
     */
365
    public function saveObject(string $type, array $data, ?array $headers = null) : ?array
366
    {
367
        return $this->save($type, $data, $headers);
368
    }
369
370
    /**
371
     * Delete an object (DELETE) => move to trashcan.
372
     *
373
     * @param int|string $id Object id
374
     * @param string $type Object type name
375
     * @return array|null Response in array format
376
     */
377
    public function deleteObject($id, string $type) : ?array
378
    {
379
        return $this->delete(sprintf('/%s/%s', $type, $id));
380
    }
381
382
    /**
383
     * Remove an object => permanently remove object from trashcan.
384
     *
385
     * @param int|string $id Object id
386
     * @return array|null Response in array format
387
     */
388
    public function remove($id) : ?array
389
    {
390
        return $this->delete(sprintf('/trash/%s', $id));
391
    }
392
393
    /**
394
     * Upload file (POST)
395
     *
396
     * @param string $filename The file name
397
     * @param string $filepath File full path: could be on a local filesystem or a remote reachable URL
398
     * @param array|null $headers Custom request headers
399
     * @return array|null Response in array format
400
     * @throws BEditaClientException
401
     */
402
    public function upload(string $filename, string $filepath, ?array $headers = null) : ?array
403
    {
404
        if (!file_exists($filepath)) {
405
            throw new BEditaClientException('File not found', 500);
406
        }
407
        $file = file_get_contents($filepath);
408
        if (!$file) {
409
            throw new BEditaClientException('File get contents failed', 500);
410
        }
411
        if (empty($headers['Content-Type'])) {
412
            $headers['Content-Type'] = mime_content_type($filepath);
413
        }
414
415
        return $this->post(sprintf('/streams/upload/%s', $filename), $file, $headers);
416
    }
417
418
    /**
419
     * Create media by type and body data and link it to a stream:
420
     *  - `POST /:type` with `$body` as payload, create media object
421
     *  - `PATCH /streams/:stream_id/relationships/object` modify stream adding relation to media
422
     *  - `GET /:type/:id` get media data
423
     *
424
     * @param string $streamId The stream identifier
425
     * @param string $type The type
426
     * @param array $body The body data
427
     * @return array|null Response in array format
428
     * @throws BEditaClientException
429
     */
430
    public function createMediaFromStream($streamId, string $type, array $body) : ?array
431
    {
432
        $response = $this->post(sprintf('/%s', $type), json_encode($body));
433
        if (empty($response)) {
434
            throw new BEditaClientException('Invalid response from POST ' . sprintf('/%s', $type));
435
        }
436
        $id = $response['data']['id'];
437
        $data = compact('id', 'type');
438
        $body = compact('data');
439
        $response = $this->patch(sprintf('/streams/%s/relationships/object', $streamId), json_encode($body));
440
        if (empty($response)) {
441
            throw new BEditaClientException('Invalid response from PATCH ' . sprintf('/streams/%s/relationships/object', $id));
442
        }
443
444
        return $this->getObject($data['id'], $data['type']);
445
    }
446
447
    /**
448
     * Thumbnail request using `GET /media/thumbs` endpoint
449
     *
450
     *  Usage:
451
     *          thumbs(123) => `GET /media/thumbs/123`
452
     *          thumbs(123, ['preset' => 'glide']) => `GET /media/thumbs/123&preset=glide`
453
     *          thumbs(null, ['ids' => '123,124,125']) => `GET /media/thumbs?ids=123,124,125`
454
     *          thumbs(null, ['ids' => '123,124,125', 'preset' => 'async']) => `GET /media/thumbs?ids=123,124,125&preset=async`
455
     *          thumbs(123, ['options' => ['w' => 100, 'h' => 80, 'fm' => 'jpg']]) => `GET /media/thumbs/123/options[w]=100&options[h]=80&options[fm]=jpg` (these options could be not available... just set in preset(s))
456
     *
457
     * @param int|null $id the media Id.
458
     * @param array $query The query params for thumbs call.
459
     * @return array|null Response in array format
460
     */
461
    public function thumbs($id = null, $query = []) : ?array
462
    {
463
        if (empty($id) && empty($query['ids'])) {
464
            throw new BEditaClientException('Invalid empty id|ids for thumbs');
465
        }
466
        $endpoint = '/media/thumbs';
467
        if (!empty($id)) {
468
            $endpoint .= sprintf('/%d', $id);
469
        }
470
471
        return $this->get($endpoint, $query);
472
    }
473
474
    /**
475
     * Get JSON SCHEMA of a resource or object
476
     *
477
     * @param string $type Object or resource type name
478
     * @return array|null JSON SCHEMA in array format
479
     */
480
    public function schema(string $type) : ?array
481
    {
482
        $h = ['Accept' => 'application/schema+json'];
483
484
        return $this->get(sprintf('/model/schema/%s', $type), null, $h);
485
    }
486
487
    /**
488
     * Get info of a relation (data, params)
489
     *
490
     * @param string $name relation name
491
     * @return array|null relation data in array format
492
     */
493
    public function relationData(string $name) : ?array
494
    {
495
        return $this->get(sprintf('/model/relations/%s', $name));
496
    }
497
498
    /**
499
     * Restore object from trash
500
     *
501
     * @param int|string $id Object id
502
     * @param string $type Object type name
503
     * @return array|null Response in array format
504
     */
505
    public function restoreObject($id, string $type) : ?array
506
    {
507
        $body = [
508
            'data' => [
509
                'id' => $id,
510
                'type' => $type,
511
            ],
512
        ];
513
514
        return $this->patch(sprintf('/%s/%s', 'trash', $id), json_encode($body));
515
    }
516
517
    /**
518
     * Send a PATCH request to modify a single resource or object
519
     *
520
     * @param string $path Endpoint URL path to invoke
521
     * @param mixed $body Request body
522
     * @param array|null $headers Custom request headers
523
     * @return array|null Response in array format
524
     */
525
    public function patch(string $path, $body, ?array $headers = null) : ?array
526
    {
527
        $this->sendRequestRetry('PATCH', $path, null, $headers, $body);
528
529
        return $this->getResponseBody();
530
    }
531
532
    /**
533
     * Send a POST request for creating resources or objects or other operations like /auth
534
     *
535
     * @param string $path Endpoint URL path to invoke
536
     * @param mixed $body Request body
537
     * @param array|null $headers Custom request headers
538
     * @return array|null Response in array format
539
     */
540
    public function post(string $path, $body, ?array $headers = null) : ?array
541
    {
542
        $this->sendRequestRetry('POST', $path, null, $headers, $body);
543
544
        return $this->getResponseBody();
545
    }
546
547
    /**
548
     * Send a DELETE request
549
     *
550
     * @param string $path Endpoint URL path to invoke.
551
     * @param mixed $body Request body
552
     * @param array|null $headers Custom request headers
553
     * @return array|null Response in array format.
554
     */
555
    public function delete(string $path, $body = null, ?array $headers = null) : ?array
556
    {
557
        $this->sendRequestRetry('DELETE', $path, null, $headers, $body);
558
559
        return $this->getResponseBody();
560
    }
561
562
    /**
563
     * Send a generic JSON API request with a basic retry policy on expired token exception.
564
     *
565
     * @param string $method HTTP Method.
566
     * @param string $path Endpoint URL path.
567
     * @param array|null $query Query string parameters.
568
     * @param string[]|null $headers Custom request headers.
569
     * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Request body.
570
     * @return \Psr\Http\Message\ResponseInterface
571
     */
572
    protected function sendRequestRetry(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null) : ResponseInterface
573
    {
574
        try {
575
            return $this->sendRequest($method, $path, $query, $headers, $body);
576
        } catch (BEditaClientException $e) {
577
            // Handle error.
578
            $attributes = $e->getAttributes();
579
            if ($e->getCode() !== 401 || empty($attributes['code']) || $attributes['code'] !== 'be_token_expired') {
580
                // Not an expired token's fault.
581
                throw $e;
582
            }
583
584
            // Refresh and retry.
585
            $this->refreshTokens();
586
            unset($headers['Authorization']);
587
588
            return $this->sendRequest($method, $path, $query, $headers, $body);
589
        }
590
    }
591
592
    /**
593
     * Send a generic JSON API request and retrieve response $this->response
594
     *
595
     * @param string $method HTTP Method.
596
     * @param string $path Endpoint URL path (with or without starting `/`) or absolute API path
597
     * @param array|null $query Query string parameters.
598
     * @param string[]|null $headers Custom request headers.
599
     * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Request body.
600
     * @return \Psr\Http\Message\ResponseInterface
601
     * @throws BEditaClientException Throws an exception if server response code is not 20x.
602
     */
603
    protected function sendRequest(string $method, string $path, ?array $query = null, ?array $headers = null, $body = null) : ResponseInterface
604
    {
605
        $uri = $this->requestUri($path, $query);
606
        $headers = array_merge($this->defaultHeaders, (array)$headers);
607
608
        // set default `Content-Type` if not set and $body not empty
609
        if (!empty($body)) {
610
            $headers = array_merge($this->defaultContentTypeHeader, $headers);
611
        }
612
613
        // Send the request synchronously to retrieve the response.
614
        $this->response = $this->jsonApiClient->sendRequest(new Request($method, $uri, $headers, $body));
615
        if ($this->getStatusCode() >= 400) {
616
            // Something bad just happened.
617
            $response = $this->getResponseBody();
618
            // Message will be 'error` array, if absent use status massage
619
            $message = empty($response['error']) ? $this->getStatusMessage() : $response['error'];
620
            throw new BEditaClientException($message, $this->getStatusCode());
621
        }
622
623
        return $this->response;
624
    }
625
626
    /**
627
     * Create request URI from path
628
     *
629
     * @param string $path Endpoint URL path (with or without starting `/`) or absolute API path
630
     * @param array|null $query Query string parameters.
631
     * @return Uri
632
     */
633
    protected function requestUri(string $path, ?array $query = null) : Uri
634
    {
635
        if (strpos($path, $this->apiBaseUrl) !== 0) {
636
            if (substr($path, 0, 1) !== '/') {
637
                $path = '/' . $path;
638
            }
639
            $path = $this->apiBaseUrl . $path;
640
        }
641
        $uri = new Uri($path);
642
643
        // if path contains query strings, remove them from path and add them to query filter
644
        parse_str($uri->getQuery(), $uriQuery);
645
        if ($query) {
646
            $query = array_merge((array)$uriQuery, (array)$query);
647
            $uri = $uri->withQuery(http_build_query($query));
648
        }
649
650
        return $uri;
651
    }
652
653
    /**
654
     * Refresh JWT access token.
655
     *
656
     * On success `$this->tokens` data will be updated with new access and renew tokens.
657
     *
658
     * @throws \BadMethodCallException Throws an exception if client has no renew token available.
659
     * @throws \Cake\Network\Exception\ServiceUnavailableException Throws an exception if server response doesn't
660
     *      include the expected data.
661
     * @return void
662
     * @throws BEditaClientException Throws an exception if server response code is not 20x.
663
     */
664
    public function refreshTokens() : void
665
    {
666
        if (empty($this->tokens['renew'])) {
667
            throw new \BadMethodCallException('You must be logged in to renew token');
668
        }
669
670
        $headers = [
671
            'Authorization' => sprintf('Bearer %s', $this->tokens['renew']),
672
        ];
673
674
        $this->sendRequest('POST', '/auth', [], $headers);
675
        $body = $this->getResponseBody();
676
        if (empty($body['meta']['jwt'])) {
677
            throw new BEditaClientException('Invalid response from server');
678
        }
679
680
        $this->setupTokens($body['meta']);
681
    }
682
}
683