Passed
Pull Request — master (#74)
by Martino Catur
01:36
created

Publisher::createBodyForRequest()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 1
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace One;
4
5
use Guzzle\Http\Exception\ClientErrorResponseException;
0 ignored issues
show
Bug introduced by
The type Guzzle\Http\Exception\ClientErrorResponseException 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...
6
use GuzzleHttp\Client;
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://dev.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 \Guzzle\Http\Client
0 ignored issues
show
Bug introduced by
The type Guzzle\Http\Client 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...
74
     */
75
    private $httpClient;
76
77
    /**
78
     * constructor
79
     */
80
    public function __construct(string $clientId, string $clientSecret, array $options = [])
81
    {
82
        $this->clientId = $clientId;
83
        $this->clientSecret = $clientSecret;
84
85
        $this->assessOptions($options);
86
87
        $this->attachmentUrl = [
0 ignored issues
show
Documentation Bug introduced by
It seems like array(One\Model\Article:... '/{article_id}/video') of type array<string,string> is incompatible with the declared type array<mixed,string[]> of property $attachmentUrl.

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...
88
            Article::ATTACHMENT_FIELD_GALLERY => self::ARTICLE_ENDPOINT . '/{article_id}/gallery',
89
            Article::ATTACHMENT_FIELD_PAGE => self::ARTICLE_ENDPOINT . '/{article_id}/page',
90
            Article::ATTACHMENT_FIELD_PHOTO => self::ARTICLE_ENDPOINT . '/{article_id}/photo',
91
            Article::ATTACHMENT_FIELD_VIDEO => self::ARTICLE_ENDPOINT . '/{article_id}/video',
92
93
        ];
94
    }
95
96
    /**
97
     * recycleToken from callback. If use external token storage could leveraged on this
98
     */
99
    public function recycleToken(\Closure $tokenProducer): self
100
    {
101
        return $this->setAuthorizationHeader($tokenProducer());
102
    }
103
104
    /**
105
     * submitting article here, return new Object cloned from original
106
     */
107
    public function submitArticle(Article $article): \One\Model\Article
108
    {
109
        $responseArticle = $this->post(
110
            self::ARTICLE_ENDPOINT,
111
            $this->normalizePayload(
112
                $article->getCollection()
113
            )
114
        );
115
116
        $responseArticle = json_decode($responseArticle, true);
117
        $article->setId((string) $responseArticle['data']['id']);
118
119
        foreach ($article->getPossibleAttachment() as $field) {
120
            if ($article->hasAttachment($field)) {
121
                foreach ($article->getAttachmentByField($field) as $attachment) {
122
                    $this->submitAttachment(
123
                        $article->getId(),
124
                        $attachment,
125
                        $field
126
                    );
127
                }
128
            }
129
        }
130
131
        return $article;
132
    }
133
134
    /**
135
     * submit each attachment of an article here
136
     */
137
    public function submitAttachment(string $idArticle, Model $attachment, string $field): array
138
    {
139
        return json_decode(
140
            $this->post(
141
                $this->getAttachmentEndPoint($idArticle, $field),
142
                $this->normalizePayload(
143
                    $attachment->getCollection()
144
                )
145
            ),
146
            true
147
        );
148
    }
149
150
    /**
151
     * get article from rest API
152
     *
153
     * @return string json
154
     */
155
    public function getArticle(string $idArticle): string
156
    {
157
        return $this->get(
158
            self::ARTICLE_CHECK_ENDPOINT . "/${idArticle}"
159
        );
160
    }
161
162
    /**
163
     * get list article by publisher
164
     *
165
     * @return string json
166
     */
167
    public function listArticle(): string
168
    {
169
        return $this->get(
170
            self::ARTICLE_ENDPOINT
171
        );
172
    }
173
174
    /**
175
     * delete article based on id
176
     */
177
    public function deleteArticle(string $idArticle): string
178
    {
179
        $articleOnRest = $this->getArticle($idArticle);
180
181
        if (! empty($articleOnRest)) {
182
            $articleOnRest = json_decode($articleOnRest, true);
183
184
            if (isset($articleOnRest['data'])) {
185
                foreach (Article::getDeleteableAttachment() as $field) {
186
                    if (isset($articleOnRest['data'][$field])) {
187
                        foreach ($articleOnRest['data'][$field] as $attachment) {
188
                            if (isset($attachment[$field . '_order'])) {
189
                                $this->deleteAttachment($idArticle, $field, $attachment[$field . '_order']);
190
                            }
191
                        }
192
                    }
193
                }
194
            }
195
196
            return $this->delete(
197
                $this->getArticleWithIdEndPoint($idArticle)
198
            );
199
        }
0 ignored issues
show
Bug Best Practice introduced by
The function implicitly returns null when the if condition on line 181 is false. This is incompatible with the type-hinted return string. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
200
    }
201
202
    /**
203
     * delete attachment of an article
204
     */
205
    public function deleteAttachment(string $idArticle, string $field, string $order): string
206
    {
207
        return $this->delete(
208
            $this->getAttachmentEndPoint($idArticle, $field) . "/${order}"
209
        );
210
    }
211
212
    /**
213
     * get proxy
214
     */
215
    final public function get(string $path, array $header = [], array $options = []): string
216
    {
217
        return $this->requestGate(
218
            'GET',
219
            $path,
220
            $header,
221
            [],
222
            $options
223
        );
224
    }
225
226
    /**
227
     * post proxy
228
     *
229
     * @param \One\Collection|array $body
230
     */
231
    final public function post(string $path, array $body = [], array $header = [], array $options = []): string
232
    {
233
        if ($this->hasLogger()) {
234
            $this->logger->info('Post to ' . $path);
235
        }
236
237
        return $this->requestGate(
238
            'POST',
239
            $path,
240
            $header,
241
            $body,
242
            $options
243
        );
244
    }
245
246
    /**
247
     * delete proxy
248
     *
249
     * @param \One\Collection|array $body
250
     * @param array $body
251
     */
252
    final public function delete(string $path, array $body = [], array $header = [], array $options = []): string
253
    {
254
        return $this->requestGate(
255
            'DELETE',
256
            $path,
257
            $header,
258
            $body,
259
            $options
260
        );
261
    }
262
263
    /**
264
     * @inheritDoc
265
     */
266
    public function setLogger(LoggerInterface $logger): void
267
    {
268
        $this->logger = $logger;
269
    }
270
271
    /**
272
     * assessing and custom option
273
     */
274
    private function assessOptions(array $options): void
275
    {
276
        $defaultOptions = [
277
            'rest_server' => self::REST_SERVER,
278
            'auth_url' => self::AUTHENTICATION,
279
            'max_attempt' => self::DEFAULT_MAX_ATTEMPT,
280
            'default_headers' => [
281
                'Accept' => 'application/json',
282
            ],
283
        ];
284
285
        $this->options = new Collection(
286
            array_merge(
287
                $defaultOptions,
288
                $options
289
            )
290
        );
291
292
        if (isset($options['access_token'])) {
293
            $this->setAuthorizationHeader($options['access_token']);
294
        }
295
296
        $this->httpClient = new Client([
0 ignored issues
show
Documentation Bug introduced by
It seems like new GuzzleHttp\Client(ar...s->get('rest_server'))) of type GuzzleHttp\Client is incompatible with the declared type Guzzle\Http\Client of property $httpClient.

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...
297
            'base_uri' => $this->options->get('rest_server'),
298
        ]);
299
    }
300
301
    /**
302
     * one gate menu for request creation.
303
     *
304
     * @param \One\Collection|array $body
305
     */
306
    private function requestGate(string $method, string $path, array $header = [], array $body = [], array $options = []): string
307
    {
308
        if (empty($this->accessToken)) {
309
            $this->renewAuthToken();
310
        }
311
312
        $request = new \GuzzleHttp\Psr7\Request(
313
            $method,
314
            $path,
315
            array_merge(
316
                $this->options->get('default_headers'),
317
                $header
318
            ),
319
            $this->createBodyForRequest(
320
                $this->prepareMultipartData($body)
321
            )
322
        );
323
324
        return (string) $this->httpClient->send($request, $options)->getBody();
325
    }
326
327
    private function prepareMultipartData(array $data = []): array
328
    {
329
        $result = [];
330
        foreach ($data as $key => $value) {
331
            array_push($result, ['name' => $key, 'contents' => $value]);
332
        }
333
        return $result;
334
    }
335
336
    /**
337
     * actually send request created here, separated for easier attempt count and handling exception
338
     *
339
     * @return \Guzzle\Http\EntityBodyInterface|string|null
0 ignored issues
show
Bug introduced by
The type Guzzle\Http\EntityBodyInterface 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...
340
     * @throws \Exception
341
     * @throws \Guzzle\Http\Exception\ClientErrorResponseException
342
     * @throws \Guzzle\Http\Exception\BadResponseException
343
     */
344
    private function sendRequest(RequestInterface $request, int $attempt = 0)
345
    {
346
        if ($attempt >= $this->options->get('max_attempt')) {
347
            throw new \Exception('MAX attempt reached for ' . $request->getUrl() . ' with payload ' . (string) $request);
0 ignored issues
show
Bug introduced by
The method getUrl() does not exist on Psr\Http\Message\RequestInterface. Did you maybe mean getUri()? ( Ignorable by Annotation )

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

347
            throw new \Exception('MAX attempt reached for ' . $request->/** @scrutinizer ignore-call */ getUrl() . ' with payload ' . (string) $request);

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...
348
        }
349
350
        try {
351
            $response = $this->httpClient->send($request);
352
            if ($response->getStatusCode() === 200) {
353
                return $response->getBody();
354
            }
355
            if ($response->getStatusCode() === 429) {
356
                $this->renewAuthToken();
357
            }
358
359
            return $this->sendRequest($request, $attempt++);
360
        } catch (ClientErrorResponseException $err) {
361
            if ($err->getResponse()->getStatusCode() === 429) {
362
                $this->renewAuthToken();
363
                return $this->sendRequest($err->getRequest(), $attempt++);
364
            }
365
366
            throw $err;
367
        } catch (\Throwable $err) {
368
            throw $err;
369
        }
370
    }
371
372
    /**
373
     * createBodyForRequest
374
     */
375
    private function createBodyForRequest(array $body = []): ?\GuzzleHttp\Psr7\MultiPartStream
376
    {
377
        if (empty($body)) {
378
            return null;
379
        }
380
        return new MultipartStream($body);
381
    }
382
383
    /**
384
     * renewing access_token
385
     *
386
     * @throws \Exception
387
     */
388
    private function renewAuthToken(): self
389
    {
390
        $request = new \GuzzleHttp\Psr7\Request(
391
            'POST',
392
            self::AUTHENTICATION,
393
            $this->options->get('default_headers'),
394
            $this->createBodyForRequest([
395
                ['name' => 'grant_type',
396
                    'contents' => 'client_credentials', ],
397
                ['name' => 'client_id',
398
                    'contents' => $this->clientId, ],
399
                ['name' => 'client_secret',
400
                    'contents' => $this->clientSecret, ],
401
            ])
402
        );
403
404
        $token = (string) $this->sendRequest($request);
405
406
        $token = json_decode($token, true);
407
408
        if (empty($token)) {
409
            throw new \Exception('Access token request return empty response');
410
        }
411
412
        return $this->setAuthorizationHeader(
413
            $token['access_token']
414
        );
415
    }
416
417
    /**
418
     * set header for OAuth 2.0
419
     */
420
    private function setAuthorizationHeader(string $accessToken): self
421
    {
422
        $this->accessToken = $accessToken;
423
424
        $this->options->set(
425
            'default_headers',
426
            array_merge(
427
                $this->options->get('default_headers'),
428
                [
429
                    'Authorization' => 'Bearer ' . $accessToken,
430
                ]
431
            )
432
        );
433
434
        return $this;
435
    }
436
437
    /**
438
     * get Attachment Submission url Endpoint at rest API
439
     */
440
    private function getAttachmentEndPoint(string $idArticle, string $field): string
441
    {
442
        return $this->replaceEndPointId(
443
444
            $idArticle,
445
            $this->attachmentUrl[$field]
0 ignored issues
show
Bug introduced by
$this->attachmentUrl[$field] of type string[] is incompatible with the type string expected by parameter $url of One\Publisher::replaceEndPointId(). ( Ignorable by Annotation )

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

445
            /** @scrutinizer ignore-type */ $this->attachmentUrl[$field]
Loading history...
446
        );
447
    }
448
449
    /**
450
     * get article endpoint for deleting api
451
     */
452
    private function getArticleWithIdEndPoint(string $identifier): string
453
    {
454
        return self::ARTICLE_ENDPOINT . "/${identifier}";
455
    }
456
457
    /**
458
     * function that actually replace article_id inside endpoint pattern
459
     */
460
    private function replaceEndPointId(string $identifier, string $url): string
461
    {
462
        return str_replace(
463
            '{article_id}',
464
            $identifier,
465
            $url
466
        );
467
    }
468
469
    /**
470
     * normalizing payload. not yet implemented totally, currently just bypass a toArray() function from collection.
471
     */
472
    private function normalizePayload(Collection $collection): array
473
    {
474
        return $collection->toArray();
475
    }
476
477
    /**
478
     * Checks if Logger instance exists
479
     */
480
    private function hasLogger(): bool
481
    {
482
        return isset($this->logger) && $this->logger !== null;
483
    }
484
}
485