Passed
Pull Request — master (#84)
by kenny
02:05
created

Publisher::prepareMultipartData()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 1
dl 0
loc 7
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 One\Model\Photo;
11
use One\Validator\PhotoAttachmentsValidator;
12
use Psr\Http\Message\RequestInterface;
13
use Psr\Log\LoggerAwareInterface;
14
use Psr\Log\LoggerInterface;
15
16
/**
17
 * Publisher class
18
 * main class to be used that interfacing to the API
19
 */
20
class Publisher implements LoggerAwareInterface
21
{
22
    public const DEFAULT_MAX_ATTEMPT = 4;
23
24
    public const REST_SERVER = 'https://dev.one.co.id';
25
26
    public const AUTHENTICATION = '/oauth/token';
27
28
    public const ARTICLE_CHECK_ENDPOINT = '/api/article';
29
30
    public const ARTICLE_ENDPOINT = '/api/publisher/article';
31
32
    public const PHOTO_ATTACHMENT_VALIDATOR = 'photo_attachments_validator';
33
34
    /**
35
     * attachment url destination
36
     * @var array<string>
37
     */
38
    private $attachmentUrl;
39
40
    /**
41
     * Logger variable, if set log activity to this obejct each time sending request and receiving response
42
     *
43
     * @var \Psr\Log\LoggerInterface
44
     */
45
    private $logger = null;
46
47
    /**
48
     * credentials props
49
     *
50
     * @var string
51
     */
52
    private $clientId;
53
54
    /**
55
     * client secret
56
     * @var string
57
     */
58
    private $clientSecret;
59
60
    /**
61
     * Oauth access token response
62
     *
63
     * @var string
64
     */
65
    private $accessToken = null;
66
67
    /**
68
     * publisher custom options
69
     *
70
     * @var \One\Collection
71
     */
72
    private $options;
73
74
    /**
75
     * http transaction Client
76
     *
77
     * @var \GuzzleHttp\Client;
78
     */
79
    private $httpClient;
80
81
    /**
82
     * Validator
83
     * @var \One\Validator\AbstractValidator
84
     */
85
    private $validator;
86
87
    /**
88
     * constructor
89
     */
90
    public function __construct(string $clientId, string $clientSecret, array $options = [])
91
    {
92
        $this->clientId = $clientId;
93
        $this->clientSecret = $clientSecret;
94
95
        $this->assessOptions($options);
96
97
        $this->attachmentUrl = [
98
            Article::ATTACHMENT_FIELD_GALLERY => self::ARTICLE_ENDPOINT . '/{article_id}/gallery',
99
            Article::ATTACHMENT_FIELD_PAGE => self::ARTICLE_ENDPOINT . '/{article_id}/page',
100
            Article::ATTACHMENT_FIELD_PHOTO => self::ARTICLE_ENDPOINT . '/{article_id}/photo',
101
            Article::ATTACHMENT_FIELD_VIDEO => self::ARTICLE_ENDPOINT . '/{article_id}/video',
102
103
        ];
104
    }
105
106
    /**
107
     * recycleToken from callback. If use external token storage could leveraged on this
108
     */
109
    public function recycleToken(\Closure $tokenProducer): self
110
    {
111
        return $this->setAuthorizationHeader($tokenProducer());
112
    }
113
114
    /**
115
     * submitting article here, return new Object cloned from original
116
     * @return \One\Model\Article|array
117
     */
118
    public function submitArticle(Article $article)
119
    {
120
        $validatorType = $this->options->offsetGet('validator');
121
122
        $this->createValidator($validatorType);
123
124
        $responseArticle = $this->post(
125
            self::ARTICLE_ENDPOINT,
126
            $this->normalizePayload(
127
                $article->getCollection()
128
            )
129
        );
130
131
        $responseArticle = json_decode($responseArticle, true);
132
        $article->setId((string) $responseArticle['data']['id']);
133
134
        foreach ($article->getPossibleAttachment() as $field) {
135
            if ($article->hasAttachment($field)) {
136
                if ($field === Article::ATTACHMENT_FIELD_PHOTO) {
137
                    $this->validator->setValue($article->getAttachmentByField($field));
138
                    $this->validator->checkHasRatio(Photo::RATIO_VERTICAL);
0 ignored issues
show
Bug introduced by
The method checkHasRatio() does not exist on One\Validator\AbstractValidator. Since it exists in all sub-types, consider adding an abstract or default implementation to One\Validator\AbstractValidator. ( Ignorable by Annotation )

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

138
                    $this->validator->/** @scrutinizer ignore-call */ 
139
                                      checkHasRatio(Photo::RATIO_VERTICAL);
Loading history...
139
                    if (! $this->validator->validate()) {
0 ignored issues
show
Bug introduced by
The method validate() does not exist on One\Validator\AbstractValidator. Since it exists in all sub-types, consider adding an abstract or default implementation to One\Validator\AbstractValidator. ( Ignorable by Annotation )

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

139
                    if (! $this->validator->/** @scrutinizer ignore-call */ validate()) {
Loading history...
140
                        return [
141
                            'error_message' => $this->validator->getErrorMessage(),
142
                        ];
143
                    }
144
                }
145
146
                foreach ($article->getAttachmentByField($field) as $attachment) {
147
                    $this->submitAttachment(
148
                        $article->getId(),
149
                        $attachment,
150
                        $field
151
                    );
152
                }
153
            }
154
        }
155
156
        return $article;
157
    }
158
159
    /**
160
     * submit each attachment of an article here
161
     */
162
    public function submitAttachment(string $idArticle, Model $attachment, string $field): array
163
    {
164
        return json_decode(
165
            $this->post(
166
                $this->getAttachmentEndPoint($idArticle, $field),
167
                $this->normalizePayload(
168
                    $attachment->getCollection()
169
                )
170
            ),
171
            true
172
        );
173
    }
174
175
    /**
176
     * get article from rest API
177
     *
178
     * @return string json
179
     */
180
    public function getArticle(string $idArticle): string
181
    {
182
        return $this->get(
183
            self::ARTICLE_CHECK_ENDPOINT . "/${idArticle}"
184
        );
185
    }
186
187
    /**
188
     * get list article by publisher
189
     *
190
     * @return string json
191
     */
192
    public function listArticle(): string
193
    {
194
        return $this->get(
195
            self::ARTICLE_ENDPOINT
196
        );
197
    }
198
199
    /**
200
     * delete article based on id
201
     */
202
    public function deleteArticle(string $idArticle): ?string
203
    {
204
        $articleOnRest = $this->getArticle($idArticle);
205
206
        if (! empty($articleOnRest)) {
207
            $articleOnRest = json_decode($articleOnRest, true);
208
209
            if (isset($articleOnRest['data'])) {
210
                foreach (Article::getDeleteableAttachment() as $field) {
211
                    if (isset($articleOnRest['data'][$field])) {
212
                        foreach ($articleOnRest['data'][$field] as $attachment) {
213
                            if (isset($attachment[$field . '_order'])) {
214
                                $this->deleteAttachment($idArticle, $field, $attachment[$field . '_order']);
215
                            }
216
                        }
217
                    }
218
                }
219
            }
220
221
            return $this->delete(
222
                $this->getArticleWithIdEndPoint($idArticle)
223
            );
224
        }
225
    }
226
227
    /**
228
     * delete attachment of an article
229
     */
230
    public function deleteAttachment(string $idArticle, string $field, string $order): string
231
    {
232
        return $this->delete(
233
            $this->getAttachmentEndPoint($idArticle, $field) . "/${order}"
234
        );
235
    }
236
237
    /**
238
     * get proxy
239
     */
240
    final public function get(string $path, array $header = [], array $options = []): string
241
    {
242
        return $this->requestGate(
243
            'GET',
244
            $path,
245
            $header,
246
            [],
247
            $options
248
        );
249
    }
250
251
    /**
252
     * post proxy
253
     *
254
     * @param \One\Collection|array $body
255
     */
256
    final public function post(string $path, array $body = [], array $header = [], array $options = []): string
257
    {
258
        if ($this->hasLogger()) {
259
            $this->logger->info('Post to ' . $path);
260
        }
261
262
        return $this->requestGate(
263
            'POST',
264
            $path,
265
            $header,
266
            $body,
267
            $options
268
        );
269
    }
270
271
    /**
272
     * delete proxy
273
     *
274
     * @param \One\Collection|array $body
275
     * @param array $body
276
     */
277
    final public function delete(string $path, array $body = [], array $header = [], array $options = []): string
278
    {
279
        return $this->requestGate(
280
            'DELETE',
281
            $path,
282
            $header,
283
            $body,
284
            $options
285
        );
286
    }
287
288
    /**
289
     * @inheritDoc
290
     */
291
    public function setLogger(LoggerInterface $logger): void
292
    {
293
        $this->logger = $logger;
294
    }
295
296
    /**
297
     * assessing and custom option
298
     */
299
    private function assessOptions(array $options): void
300
    {
301
        $defaultOptions = [
302
            'rest_server' => self::REST_SERVER,
303
            'auth_url' => self::AUTHENTICATION,
304
            'max_attempt' => self::DEFAULT_MAX_ATTEMPT,
305
            'default_headers' => [
306
                'Accept' => 'application/json',
307
            ],
308
        ];
309
310
        $this->options = new Collection(
311
            array_merge(
312
                $defaultOptions,
313
                $options
314
            )
315
        );
316
317
        if (isset($options['access_token'])) {
318
            $this->setAuthorizationHeader($options['access_token']);
319
        }
320
321
        $this->httpClient = new Client([
322
            'base_uri' => $this->options->get('rest_server'),
323
        ]);
324
    }
325
326
    /**
327
     * one gate menu for request creation.
328
     *
329
     * @param \One\Collection|array $body
330
     */
331
    private function requestGate(string $method, string $path, array $header = [], array $body = [], array $options = []): string
332
    {
333
        if (empty($this->accessToken)) {
334
            $this->renewAuthToken();
335
        }
336
337
        $request = new \GuzzleHttp\Psr7\Request(
338
            $method,
339
            $path,
340
            array_merge(
341
                $this->options->get('default_headers'),
342
                $header
343
            ),
344
            $this->createBodyForRequest(
345
                $this->prepareMultipartData($body)
346
            )
347
        );
348
349
        return (string) $this->httpClient->send($request, $options)->getBody();
350
    }
351
352
    private function prepareMultipartData(array $data = []): array
353
    {
354
        $result = [];
355
        foreach ($data as $key => $value) {
356
            array_push($result, ['name' => $key, 'contents' => $value]);
357
        }
358
        return $result;
359
    }
360
361
    /**
362
     * actually send request created here, separated for easier attempt count and handling exception
363
     *
364
     * @throws \Exception
365
     * @throws \GuzzleHttp\Exception\ClientException
366
     * @throws \GuzzleHttp\Exception\BadResponseException
367
     */
368
    private function sendRequest(RequestInterface $request, int $attempt = 0): \Psr\Http\Message\StreamInterface
369
    {
370
        if ($attempt >= $this->options->get('max_attempt')) {
371
            throw new \Exception('MAX attempt reached for ' . $request->getUri() . ' with payload ' . (string) $request);
372
        }
373
374
        try {
375
            $response = $this->httpClient->send($request);
376
            if ($response->getStatusCode() === 200) {
377
                return $response->getBody();
378
            }
379
380
            return $this->sendRequest($request, $attempt++);
381
        } catch (ClientException $err) {
382
            if ($err->getResponse()->getStatusCode() === 429) {
383
                $this->renewAuthToken();
384
                return $this->sendRequest($err->getRequest(), $attempt++);
385
            }
386
387
            throw $err;
388
        } catch (\Throwable $err) {
389
            throw $err;
390
        }
391
    }
392
393
    /**
394
     * createBodyForRequest
395
     */
396
    private function createBodyForRequest(array $body = []): ?\GuzzleHttp\Psr7\MultiPartStream
397
    {
398
        if (empty($body)) {
399
            return null;
400
        }
401
        return new MultipartStream($body);
402
    }
403
404
    /**
405
     * renewing access_token
406
     *
407
     * @throws \Exception
408
     */
409
    private function renewAuthToken(): self
410
    {
411
        $request = new \GuzzleHttp\Psr7\Request(
412
            'POST',
413
            self::AUTHENTICATION,
414
            $this->options->get('default_headers'),
415
            $this->createBodyForRequest([
416
                ['name' => 'grant_type',
417
                    'contents' => 'client_credentials', ],
418
                ['name' => 'client_id',
419
                    'contents' => $this->clientId, ],
420
                ['name' => 'client_secret',
421
                    'contents' => $this->clientSecret, ],
422
            ])
423
        );
424
425
        $token = (string) $this->sendRequest($request);
426
427
        $token = json_decode($token, true);
428
429
        if (empty($token)) {
430
            throw new \Exception('Access token request return empty response');
431
        }
432
433
        return $this->setAuthorizationHeader(
434
            $token['access_token']
435
        );
436
    }
437
438
    /**
439
     * set header for OAuth 2.0
440
     */
441
    private function setAuthorizationHeader(string $accessToken): self
442
    {
443
        $this->accessToken = $accessToken;
444
445
        $this->options->set(
446
            'default_headers',
447
            array_merge(
448
                $this->options->get('default_headers'),
449
                [
450
                    'Authorization' => 'Bearer ' . $accessToken,
451
                ]
452
            )
453
        );
454
455
        return $this;
456
    }
457
458
    /**
459
     * get Attachment Submission url Endpoint at rest API
460
     */
461
    private function getAttachmentEndPoint(string $idArticle, string $field): string
462
    {
463
        return $this->replaceEndPointId(
464
465
            $idArticle,
466
            $this->attachmentUrl[$field]
467
        );
468
    }
469
470
    /**
471
     * get article endpoint for deleting api
472
     */
473
    private function getArticleWithIdEndPoint(string $identifier): string
474
    {
475
        return self::ARTICLE_ENDPOINT . "/${identifier}";
476
    }
477
478
    /**
479
     * function that actually replace article_id inside endpoint pattern
480
     */
481
    private function replaceEndPointId(string $identifier, string $url): string
482
    {
483
        return str_replace(
484
            '{article_id}',
485
            $identifier,
486
            $url
487
        );
488
    }
489
490
    /**
491
     * normalizing payload. not yet implemented totally, currently just bypass a toArray() function from collection.
492
     */
493
    private function normalizePayload(Collection $collection): array
494
    {
495
        return $collection->toArray();
496
    }
497
498
    /**
499
     * Checks if Logger instance exists
500
     */
501
    private function hasLogger(): bool
502
    {
503
        return isset($this->logger) && $this->logger !== null;
504
    }
505
506
    /**
507
     * Check if validator instance existance
508
     */
509
    private function hasValidator(string $validatorType = ''): bool
510
    {
511
        return ! empty($this->validator)
512
            &&
513
            is_a($this->validator, $validatorType);
514
    }
515
516
    /**
517
     * Create validator instance
518
     * Based on type
519
     * */
520
    private function createValidator(string $validatorType = ''): void
521
    {
522
        if ($validatorType === self::PHOTO_ATTACHMENT_VALIDATOR) {
523
            if (! $this->hasValidator($validatorType)) {
524
                $this->validator = new PhotoAttachmentsValidator();
525
            }
526
        } else {
527
            throw new \Exception('Unknown validator type', 1);
528
        }
529
    }
530
}
531