Passed
Push — master ( 2b595f...bfff23 )
by Charis
04:21
created

Publisher::setTokenSaver()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace One;
4
5
use GuzzleHttp\Client;
6
use GuzzleHttp\Exception\ClientException;
7
use GuzzleHttp\Psr7\MultipartStream;
8
use One\Model\Article;
9
use One\Model\Model;
10
use Psr\Http\Message\RequestInterface;
11
use Psr\Log\LoggerAwareInterface;
12
use Psr\Log\LoggerInterface;
13
14
/**
15
 * Publisher class
16
 * main class to be used that interfacing to the API
17
 */
18
class Publisher implements LoggerAwareInterface
19
{
20
    public const DEFAULT_MAX_ATTEMPT = 4;
21
22
    public const REST_SERVER = 'https://www.one.co.id';
23
24
    public const AUTHENTICATION = '/oauth/token';
25
26
    public const ARTICLE_CHECK_ENDPOINT = '/api/article';
27
28
    public const ARTICLE_ENDPOINT = '/api/publisher/article';
29
30
    /**
31
     * attachment url destination
32
     * @var array<string>
33
     */
34
    private $attachmentUrl;
35
36
    /**
37
     * Logger variable, if set log activity to this obejct each time sending request and receiving response
38
     *
39
     * @var \Psr\Log\LoggerInterface
40
     */
41
    private $logger = null;
42
43
    /**
44
     * credentials props
45
     *
46
     * @var string
47
     */
48
    private $clientId;
49
50
    /**
51
     * client secret
52
     * @var string
53
     */
54
    private $clientSecret;
55
56
    /**
57
     * Oauth access token response
58
     *
59
     * @var string
60
     */
61
    private $accessToken = null;
62
63
    /**
64
     * publisher custom options
65
     *
66
     * @var \One\Collection
67
     */
68
    private $options;
69
70
    /**
71
     * http transaction Client
72
     *
73
     * @var \GuzzleHttp\Client;
74
     */
75
    private $httpClient;
76
77
    /**
78
     * token saver storage
79
     *
80
     * @var Closure
0 ignored issues
show
Bug introduced by
The type One\Closure was not found. Did you mean Closure? If so, make sure to prefix the type with \.
Loading history...
81
     */
82
    private $tokenSaver = null;
83
84
    /**
85
     * constructor
86
     */
87
    public function __construct(string $clientId, string $clientSecret, array $options = [])
88
    {
89
        $this->clientId = $clientId;
90
        $this->clientSecret = $clientSecret;
91
92
        $this->assessOptions($options);
93
94
        $this->attachmentUrl = [
95
            Article::ATTACHMENT_FIELD_GALLERY => self::ARTICLE_ENDPOINT . '/{article_id}/gallery',
96
            Article::ATTACHMENT_FIELD_PAGE => self::ARTICLE_ENDPOINT . '/{article_id}/page',
97
            Article::ATTACHMENT_FIELD_PHOTO => self::ARTICLE_ENDPOINT . '/{article_id}/photo',
98
            Article::ATTACHMENT_FIELD_VIDEO => self::ARTICLE_ENDPOINT . '/{article_id}/video',
99
100
        ];
101
    }
102
103
    /**
104
     * recycleToken from callback. If use external token storage could leveraged on this
105
     */
106
    public function recycleToken(\Closure $tokenProducer): self
107
    {
108
        return $this->setAuthorizationHeader(
109
            $tokenProducer()
110
        );
111
    }
112
113
    public function setTokenSaver(\Closure $tokenSaver): self
114
    {
115
        $this->tokenSaver = $tokenSaver;
0 ignored issues
show
Documentation Bug introduced by
It seems like $tokenSaver of type Closure is incompatible with the declared type One\Closure of property $tokenSaver.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
116
117
        return $this;
118
    }
119
120
    /**
121
     * submitting article here, return new Object cloned from original
122
     */
123
    public function submitArticle(Article $article): \One\Model\Article
124
    {
125
        $responseArticle = $this->post(
126
            self::ARTICLE_ENDPOINT,
127
            $this->normalizePayload(
128
                $article->getCollection()
129
            )
130
        );
131
132
        $responseArticle = json_decode($responseArticle, true);
133
        $article->setId((string) $responseArticle['data']['id']);
134
135
        foreach ($article->getPossibleAttachment() as $field) {
136
            if ($article->hasAttachment($field)) {
137
                foreach ($article->getAttachmentByField($field) as $attachment) {
138
                    $this->submitAttachment(
139
                        $article->getId(),
140
                        $attachment,
141
                        $field
142
                    );
143
                }
144
            }
145
        }
146
147
        return $article;
148
    }
149
150
    /**
151
     * submit each attachment of an article here
152
     */
153
    public function submitAttachment(string $idArticle, Model $attachment, string $field): array
154
    {
155
        return json_decode(
156
            $this->post(
157
                $this->getAttachmentEndPoint($idArticle, $field),
158
                $this->normalizePayload(
159
                    $attachment->getCollection()
160
                )
161
            ),
162
            true
163
        );
164
    }
165
166
    /**
167
     * get article from rest API
168
     *
169
     * @return string json
170
     */
171
    public function getArticle(string $idArticle): string
172
    {
173
        return $this->get(
174
            self::ARTICLE_CHECK_ENDPOINT . "/${idArticle}"
175
        );
176
    }
177
178
    /**
179
     * get list article by publisher
180
     *
181
     * @return string json
182
     */
183
    public function listArticle(): string
184
    {
185
        return $this->get(
186
            self::ARTICLE_ENDPOINT
187
        );
188
    }
189
190
    /**
191
     * delete article based on id
192
     */
193
    public function deleteArticle(string $idArticle): ?string
194
    {
195
        $articleOnRest = $this->getArticle($idArticle);
196
197
        if (! empty($articleOnRest)) {
198
            $articleOnRest = json_decode($articleOnRest, true);
199
200
            if (isset($articleOnRest['data'])) {
201
                foreach (Article::getDeleteableAttachment() as $field) {
202
                    if (isset($articleOnRest['data'][$field])) {
203
                        foreach ($articleOnRest['data'][$field] as $attachment) {
204
                            if (isset($attachment[$field . '_order'])) {
205
                                $this->deleteAttachment($idArticle, $field, $attachment[$field . '_order']);
206
                            }
207
                        }
208
                    }
209
                }
210
            }
211
212
            return $this->delete(
213
                $this->getArticleWithIdEndPoint($idArticle)
214
            );
215
        }
216
    }
217
218
    /**
219
     * delete attachment of an article
220
     */
221
    public function deleteAttachment(string $idArticle, string $field, string $order): string
222
    {
223
        return $this->delete(
224
            $this->getAttachmentEndPoint($idArticle, $field) . "/${order}"
225
        );
226
    }
227
228
    /**
229
     * get proxy
230
     */
231
    final public function get(string $path, array $header = [], array $options = []): string
232
    {
233
        return $this->requestGate(
234
            'GET',
235
            $path,
236
            $header,
237
            [],
238
            $options
239
        );
240
    }
241
242
    /**
243
     * post proxy
244
     *
245
     * @param \One\Collection|array $body
246
     */
247
    final public function post(string $path, array $body = [], array $header = [], array $options = []): string
248
    {
249
        if ($this->hasLogger()) {
250
            $this->logger->info('Post to ' . $path);
251
        }
252
253
        return $this->requestGate(
254
            'POST',
255
            $path,
256
            $header,
257
            $body,
258
            $options
259
        );
260
    }
261
262
    /**
263
     * delete proxy
264
     *
265
     * @param \One\Collection|array $body
266
     * @param array $body
267
     */
268
    final public function delete(string $path, array $body = [], array $header = [], array $options = []): string
269
    {
270
        return $this->requestGate(
271
            'DELETE',
272
            $path,
273
            $header,
274
            $body,
275
            $options
276
        );
277
    }
278
279
    /**
280
     * @inheritDoc
281
     */
282
    public function setLogger(LoggerInterface $logger): void
283
    {
284
        $this->logger = $logger;
285
    }
286
287
    /**
288
     * assessing and custom option
289
     */
290
    private function assessOptions(array $options): void
291
    {
292
        $defaultOptions = [
293
            'rest_server' => self::REST_SERVER,
294
            'auth_url' => self::AUTHENTICATION,
295
            'max_attempt' => self::DEFAULT_MAX_ATTEMPT,
296
            'default_headers' => [
297
                'Accept' => 'application/json',
298
            ],
299
        ];
300
301
        $this->options = new Collection(
302
            array_merge(
303
                $defaultOptions,
304
                $options
305
            )
306
        );
307
308
        if (isset($options['access_token'])) {
309
            $this->setAuthorizationHeader($options['access_token']);
310
        }
311
312
        if (isset($options['recyle_token']) && is_callable($options['recyle_token'])) {
313
            $this->recycleToken(
314
                $options['recyle_token']
315
            );
316
        }
317
318
        if (isset($options['token_saver']) && is_callable($options['token_saver'])) {
319
            $this->setTokenSaver(
320
                $options['token_saver']
321
            );
322
        }
323
324
        $this->httpClient = new Client([
325
            'base_uri' => $this->options->get('rest_server'),
326
        ]);
327
    }
328
329
    /**
330
     * one gate menu for request creation.
331
     *
332
     * @param \One\Collection|array $body
333
     */
334
    private function requestGate(string $method, string $path, array $header = [], array $body = [], array $options = []): string
335
    {
336
        if (empty($this->accessToken)) {
337
            $this->renewAuthToken();
338
        }
339
340
        $request = new \GuzzleHttp\Psr7\Request(
341
            $method,
342
            $path,
343
            array_merge(
344
                $this->options->get('default_headers'),
0 ignored issues
show
Bug introduced by
It seems like $this->options->get('default_headers') can also be of type null; however, parameter $array1 of array_merge() does only seem to accept array, 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

344
                /** @scrutinizer ignore-type */ $this->options->get('default_headers'),
Loading history...
345
                $header
346
            ),
347
            $this->createBodyForRequest(
348
                $this->prepareMultipartData($body)
349
            )
350
        );
351
352
        return (string) $this->httpClient->send($request, $options)->getBody();
353
    }
354
355
    private function prepareMultipartData(array $data = []): array
356
    {
357
        $result = [];
358
        foreach ($data as $key => $value) {
359
            array_push($result, ['name' => $key, 'contents' => $value]);
360
        }
361
        return $result;
362
    }
363
364
    /**
365
     * actually send request created here, separated for easier attempt count and handling exception
366
     *
367
     * @throws \Exception
368
     * @throws \GuzzleHttp\Exception\ClientException
369
     * @throws \GuzzleHttp\Exception\BadResponseException
370
     */
371
    private function sendRequest(RequestInterface $request, int $attempt = 0): \Psr\Http\Message\StreamInterface
372
    {
373
        if ($attempt >= $this->options->get('max_attempt')) {
374
            throw new \Exception('MAX attempt reached for ' . $request->getUri() . ' with payload ' . (string) $request);
375
        }
376
377
        try {
378
            $response = $this->httpClient->send($request);
379
            if ($response->getStatusCode() === 200) {
380
                return $response->getBody();
381
            }
382
383
            return $this->sendRequest($request, $attempt++);
384
        } catch (ClientException $err) {
385
            if ($err->getResponse()->getStatusCode() === 429) {
386
                $this->renewAuthToken();
387
                return $this->sendRequest($err->getRequest(), $attempt++);
388
            }
389
390
            throw $err;
391
        } catch (\Throwable $err) {
392
            throw $err;
393
        }
394
    }
395
396
    /**
397
     * createBodyForRequest
398
     */
399
    private function createBodyForRequest(array $body = []): ?\GuzzleHttp\Psr7\MultiPartStream
400
    {
401
        if (empty($body)) {
402
            return null;
403
        }
404
        return new MultipartStream($body);
405
    }
406
407
    /**
408
     * renewing access_token
409
     *
410
     * @throws \Exception
411
     */
412
    private function renewAuthToken(): self
413
    {
414
        $request = new \GuzzleHttp\Psr7\Request(
415
            'POST',
416
            self::AUTHENTICATION,
417
            $this->options->get('default_headers'),
0 ignored issues
show
Bug introduced by
It seems like $this->options->get('default_headers') can also be of type null; however, parameter $headers of GuzzleHttp\Psr7\Request::__construct() does only seem to accept array, 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

417
            /** @scrutinizer ignore-type */ $this->options->get('default_headers'),
Loading history...
418
            $this->createBodyForRequest([
419
                ['name' => 'grant_type',
420
                    'contents' => 'client_credentials', ],
421
                ['name' => 'client_id',
422
                    'contents' => $this->clientId, ],
423
                ['name' => 'client_secret',
424
                    'contents' => $this->clientSecret, ],
425
            ])
426
        );
427
428
        $token = (string) $this->sendRequest($request);
429
430
        $token = json_decode($token, true);
431
432
        if (empty($token)) {
433
            throw new \Exception('Access token request return empty response');
434
        }
435
436
        if (! empty($this->tokenSaver)) {
437
            $this->tokenSaver($token['access_token']);
0 ignored issues
show
Bug introduced by
The method tokenSaver() does not exist on One\Publisher. ( Ignorable by Annotation )

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

437
            $this->/** @scrutinizer ignore-call */ 
438
                   tokenSaver($token['access_token']);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
438
        }
439
440
        return $this->setAuthorizationHeader(
441
            $token['access_token']
442
        );
443
    }
444
445
    /**
446
     * set header for OAuth 2.0
447
     */
448
    private function setAuthorizationHeader(string $accessToken): self
449
    {
450
        $this->accessToken = $accessToken;
451
452
        $this->options->set(
453
            'default_headers',
454
            array_merge(
455
                $this->options->get('default_headers'),
0 ignored issues
show
Bug introduced by
It seems like $this->options->get('default_headers') can also be of type null; however, parameter $array1 of array_merge() does only seem to accept array, 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

455
                /** @scrutinizer ignore-type */ $this->options->get('default_headers'),
Loading history...
456
                [
457
                    'Authorization' => 'Bearer ' . $accessToken,
458
                ]
459
            )
460
        );
461
462
        return $this;
463
    }
464
465
    /**
466
     * get Attachment Submission url Endpoint at rest API
467
     */
468
    private function getAttachmentEndPoint(string $idArticle, string $field): string
469
    {
470
        return $this->replaceEndPointId(
471
472
            $idArticle,
473
            $this->attachmentUrl[$field]
474
        );
475
    }
476
477
    /**
478
     * get article endpoint for deleting api
479
     */
480
    private function getArticleWithIdEndPoint(string $identifier): string
481
    {
482
        return self::ARTICLE_ENDPOINT . "/${identifier}";
483
    }
484
485
    /**
486
     * function that actually replace article_id inside endpoint pattern
487
     */
488
    private function replaceEndPointId(string $identifier, string $url): string
489
    {
490
        return str_replace(
491
            '{article_id}',
492
            $identifier,
493
            $url
494
        );
495
    }
496
497
    /**
498
     * normalizing payload. not yet implemented totally, currently just bypass a toArray() function from collection.
499
     */
500
    private function normalizePayload(Collection $collection): array
501
    {
502
        return $collection->toArray();
503
    }
504
505
    /**
506
     * Checks if Logger instance exists
507
     */
508
    private function hasLogger(): bool
509
    {
510
        return isset($this->logger) && $this->logger !== null;
511
    }
512
}
513