Passed
Push — master ( c5e328...cc3a16 )
by Charis
05:45
created

Publisher::listArticle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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

357
                /** @scrutinizer ignore-type */ $this->options->get('default_headers'),
Loading history...
358
                $header
359
            ),
360
            $this->createBodyForRequest(
361
                $this->prepareMultipartData($body)
362
            )
363
        );
364
365
        return (string) $this->sendRequest($request);
366
        //(string) $this->httpClient->send($request, $options)->getBody();
367
    }
368
369
    private function prepareMultipartData(array $data = []): array
370
    {
371
        $result = [];
372
        foreach ($data as $key => $value) {
373
            array_push($result, ['name' => $key, 'contents' => $value]);
374
        }
375
        return $result;
376
    }
377
378
    /**
379
     * actually send request created here, separated for easier attempt count and handling exception
380
     *
381
     * @throws \Exception
382
     * @throws \GuzzleHttp\Exception\ClientException
383
     * @throws \GuzzleHttp\Exception\BadResponseException
384
     */
385
    private function sendRequest(RequestInterface $request, int $attempt = 0): \Psr\Http\Message\StreamInterface
386
    {
387
        if ($attempt >= $this->options->get('max_attempt')) {
388
            throw new \Exception('MAX attempt reached for ' . $request->getUri() . ' with payload ' . (string) $request);
389
        }
390
391
        try {
392
            $response = $this->httpClient->send(
393
                $request,
394
                [
395
                    'allow_redirects' => false,
396
                    'synchronous' => true,
397
                    'curl' => [
398
                        CURLOPT_FORBID_REUSE => true,
399
                        CURLOPT_MAXCONNECTS => 30,
400
                        CURLOPT_SSL_VERIFYPEER => false,
401
                        CURLOPT_SSL_VERIFYSTATUS => false,
402
                    ],
403
                ]
404
            );
405
            if ($response->getStatusCode() === 200) {
406
                return $response->getBody();
407
            }
408
409
            return $this->sendRequest($request, $attempt++);
410
        } catch (ClientException $err) {
411
            if ($err->getResponse()->getStatusCode() === 429) {
412
                $this->renewAuthToken();
413
                return $this->sendRequest($err->getRequest(), $attempt++);
414
            }
415
416
            throw $err;
417
        } catch (\Throwable $err) {
418
            throw $err;
419
        }
420
    }
421
422
    /**
423
     * createBodyForRequest
424
     */
425
    private function createBodyForRequest(array $body = []): ?\GuzzleHttp\Psr7\MultiPartStream
426
    {
427
        if (empty($body)) {
428
            return null;
429
        }
430
        return new MultipartStream($body);
431
    }
432
433
    /**
434
     * renewing access_token
435
     *
436
     * @throws \Exception
437
     */
438
    private function renewAuthToken(): self
439
    {
440
        $request = new \GuzzleHttp\Psr7\Request(
441
            'POST',
442
            self::AUTHENTICATION,
443
            $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

443
            /** @scrutinizer ignore-type */ $this->options->get('default_headers'),
Loading history...
444
            $this->createBodyForRequest([
445
                ['name' => 'grant_type',
446
                    'contents' => 'client_credentials', ],
447
                ['name' => 'client_id',
448
                    'contents' => $this->clientId, ],
449
                ['name' => 'client_secret',
450
                    'contents' => $this->clientSecret, ],
451
            ])
452
        );
453
454
        $token = (string) $this->sendRequest($request);
455
456
        $token = json_decode($token, true);
457
458
        if (empty($token)) {
459
            throw new \Exception('Access token request return empty response');
460
        }
461
462
        if (! empty($this->tokenSaver)) {
463
            $this->getTokenSaver()(
464
                $token['access_token']
465
            );
466
        }
467
468
        return $this->setAuthorizationHeader(
469
            $token['access_token']
470
        );
471
    }
472
473
    /**
474
     * set header for OAuth 2.0
475
     */
476
    private function setAuthorizationHeader(string $accessToken): self
477
    {
478
        $this->accessToken = $accessToken;
479
480
        $this->options->set(
481
            'default_headers',
482
            array_merge(
483
                $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

483
                /** @scrutinizer ignore-type */ $this->options->get('default_headers'),
Loading history...
484
                [
485
                    'Authorization' => 'Bearer ' . $accessToken,
486
                ]
487
            )
488
        );
489
490
        return $this;
491
    }
492
493
    /**
494
     * get Attachment Submission url Endpoint at rest API
495
     */
496
    private function getAttachmentEndPoint(string $idArticle, string $field): string
497
    {
498
        return $this->replaceEndPointId(
499
            $idArticle,
500
            $this->attachmentUrl[$field]
501
        );
502
    }
503
504
    /**
505
     * get article endpoint for deleting api
506
     */
507
    private function getArticleWithIdEndPoint(string $identifier): string
508
    {
509
        return self::ARTICLE_ENDPOINT . "/${identifier}";
510
    }
511
512
    /**
513
     * function that actually replace article_id inside endpoint pattern
514
     */
515
    private function replaceEndPointId(string $identifier, string $url): string
516
    {
517
        return str_replace(
518
            '{article_id}',
519
            $identifier,
520
            $url
521
        );
522
    }
523
524
    /**
525
     * normalizing payload. not yet implemented totally, currently just bypass a toArray() function from collection.
526
     */
527
    private function normalizePayload(Collection $collection): array
528
    {
529
        return $collection->toArray();
530
    }
531
532
    /**
533
     * Checks if Logger instance exists
534
     */
535
    private function hasLogger(): bool
536
    {
537
        return isset($this->logger) && $this->logger !== null;
538
    }
539
}
540