Passed
Push — develop ( 38ffd9...9f7101 )
by Jens
31:57 queued 05:22
created

ClientFactory::createClient()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 2
Bugs 1 Features 2
Metric Value
eloc 8
dl 0
loc 16
ccs 3
cts 3
cp 1
rs 10
c 2
b 1
f 2
cc 1
nc 1
nop 6
crap 1
1
<?php
2
3
namespace Commercetools\Core\Client;
4
5
use Commercetools\Core\Cache\CacheAdapterFactory;
6
use Commercetools\Core\Client\OAuth\CacheTokenProvider;
7
use Commercetools\Core\Client\OAuth\ClientCredentials;
8
use Commercetools\Core\Client\OAuth\CredentialTokenProvider;
9
use Commercetools\Core\Client\OAuth\OAuth2Handler;
10
use Commercetools\Core\Client\OAuth\TokenProvider;
11
use Commercetools\Core\Config;
12
use Commercetools\Core\Error\ApiException;
13
use Commercetools\Core\Error\DeprecatedException;
14
use Commercetools\Core\Error\InvalidTokenException;
15
use Commercetools\Core\Error\Message;
16
use Commercetools\Core\Helper\CorrelationIdProvider;
17
use Commercetools\Core\Error\InvalidArgumentException;
18
use Commercetools\Core\Model\Common\Context;
19
use Commercetools\Core\Model\Common\ContextAwareInterface;
20
use Commercetools\Core\Response\AbstractApiResponse;
21
use GuzzleHttp\Client;
22
use GuzzleHttp\Client as HttpClient;
23
use GuzzleHttp\Exception\RequestException;
24
use GuzzleHttp\HandlerStack;
25
use GuzzleHttp\MessageFormatter;
26
use GuzzleHttp\Middleware;
27
use Psr\Cache\CacheItemPoolInterface;
28
use Psr\Http\Message\RequestInterface;
29
use Psr\Http\Message\ResponseInterface;
30
use Psr\Log\LoggerInterface;
31
use Psr\Log\LogLevel;
32
use Psr\SimpleCache\CacheInterface;
33
34
/**
35
 * The factory to create a Client for communicating with the commercetools platform
36
 *
37
 * @description
38
 * This factory will create a Guzzle HTTP Client preconfigured for talking to the commercetools platform
39
 *
40
 * ## Instantiation
41
 *
42
 * ```php
43
 * $config = Config::fromArray(
44
 *  ['client_id' => '<client_id>', 'client_secret' => '<client_secret>', 'project' => '<project>']
45
 * );
46
 * $client = ClientFactory::of()->createClient($config);
47
 * ```
48
 *
49
 * ## Execution
50
 *
51
 * ### Synchronous
52
 *
53
 * ```php
54
 * $request = ProductProjectionSearchRequest::of();
55
 * $response = $client->execute($request);
56
 * $products = $request->mapFromResponse($response);
57
 * ```
58
 *
59
 * ### Asynchronous
60
 * The asynchronous execution will return a promise to fulfill the request.
61
 *
62
 * ```php
63
 * $request = ProductProjectionSearchRequest::of();
64
 * $response = $client->executeAsync($request);
65
 * $products = $request->mapFromResponse($response->wait());
66
 * ```
67
 *
68
 * ### Batch
69
 * By filling the batch queue and starting the execution all requests will be executed in parallel.
70
 *
71
 * ```php
72
 * $responses = Pool::batch(
73
 *     $client,
74
 *     [ProductProjectionSearchRequest::of()->httpRequest(), CartByIdGetRequest::ofId($cartId)->httpRequest()]
75
 * );
76
 * ```
77
 *
78
 * ## Instantiation options
79
 *
80
 * ### Using a logger
81
 *
82
 * The client uses the PSR-3 logger interface for logging requests and deprecation notices. To enable
83
 * logging provide a PSR-3 compliant logger (e.g. Monolog).
84
 *
85
 * ```php
86
 * $logger = new \Monolog\Logger('name');
87
 * $logger->pushHandler(new StreamHandler('./requests.log'));
88
 * $client = ClientFactory::of()->createClient($config, $logger);
89
 * ```
90
 *
91
 * ### Using a cache adapter ###
92
 *
93
 * The client will automatically request an OAuth token and store the token in the provided cache.
94
 *
95
 * It's also possible to use a different cache adapter. The SDK provides a Doctrine, a Redis and an APCu cache adapter.
96
 * By default the SDK tries to instantiate the APCu or a PSR-6 filesystem cache adapter if there is no cache given.
97
 * E.g. Redis:
98
 *
99
 * ```php
100
 * $redis = new \Redis();
101
 * $redis->connect('localhost');
102
 * $client = ClientFactory::of()->createClient($config, null, $cache);
103
 * ```
104
 *
105
 * #### Using cache and logger ####
106
 *
107
 * ```php
108
 * $client = ClientFactory::of()->createClient($config, $logger, $cache);
109
 * ```
110
 *
111
 * #### Using a custom cache adapter ####
112
 *
113
 * ```php
114
 * class <CacheClass>Adapter implements \Psr\Cache\CacheItemPoolInterface {
115
 *     protected $cache;
116
 *     public function __construct(<CacheClass> $cache) {
117
 *         $this->cache = $cache;
118
 *     }
119
 * }
120
 *
121
 * $client->getAdapterFactory()->registerCallback(function ($cache) {
122
 *     if ($cache instanceof <CacheClass>) {
123
 *         return new <CacheClass>Adapter($cache);
124
 *     }
125
 *     return null;
126
 * });
127
 * ```
128
 *
129
 * ### Using a custom client class
130
 *
131
 * If some additional configuration is needed or the client should have custom logic you could provide a class name
132
 * to be used for the client instance. This class has to be an extended Guzzle client.
133
 *
134
 * ```php
135
 * $client = ClientFactory::of()->createCustomClient(MyCustomClient::class, $config);
136
 * ```
137
 *
138
 * ## Middlewares
139
 *
140
 * Adding middlewares to the clients for platform as well for the authentication can be done using the config
141
 * by setting client options.
142
 *
143
 * ### Using a HandlerStack
144
 *
145
 * ```php
146
 * $handler = HandlerStack::create();
147
 * $handler->push(Middleware::mapRequest(function (RequestInterface $request) {
148
 *     ...
149
 *     return $request; })
150
 * );
151
 * $config = Config::of()->setClientOptions(['handler' => $handler])
152
 * ```
153
 *
154
 * ### Using a middleware array
155
 *
156
 * ```php
157
 * $middlewares = [
158
 *     Middleware::mapRequest(function (RequestInterface $request) {
159
 *     ...
160
 *     return $request; }),
161
 *     ...
162
 * ]
163
 * $config = Config::of()->setClientOptions(['middlewares' => $middlewares])
164
 * ```
165
 *
166
 * @package Commercetools\Core\Client
167
 */
168
class ClientFactory
169
{
170
    /**
171
     * @var bool
172
     */
173
    private static $isGuzzle6;
174
175
    /**
176
     * ClientFactory constructor.
177
     * @throws DeprecatedException
178
     */
179 28
    public function __construct()
180
    {
181 28
        if (!self::isGuzzle6()) {
182
            throw new DeprecatedException("ClientFactory doesn't support Guzzle version < 6");
183
        }
184 28
    }
185
186
    /**
187
     * @param string $clientClass
188
     * @param Config|array $config
189
     * @param LoggerInterface $logger
190
     * @param CacheItemPoolInterface|CacheInterface $cache
191
     * @param TokenProvider $provider
192
     * @param CacheAdapterFactory $cacheAdapterFactory
193
     * @param Context|null $context
194
     * @return Client
195
     */
196 27
    public function createCustomClient(
197
        $clientClass,
198
        $config,
199
        LoggerInterface $logger = null,
200
        $cache = null,
201
        TokenProvider $provider = null,
202
        CacheAdapterFactory $cacheAdapterFactory = null,
203
        Context $context = null
204
    ) {
205 27
        $config = $this->createConfig($config);
206
207 27
        if (is_null($context)) {
208 27
            $context = $config->getContext();
209
        }
210 27
        if (is_null($cacheAdapterFactory)) {
211 27
            $cacheDir = $config->getCacheDir();
212 27
            $cacheDir = !is_null($cacheDir) ? $cacheDir : realpath(__DIR__ . '/../../..');
0 ignored issues
show
introduced by
The condition is_null($cacheDir) is always false.
Loading history...
213 27
            $cacheAdapterFactory = new CacheAdapterFactory($cacheDir);
214
        }
215
216 27
        $cache = $cacheAdapterFactory->get($cache);
217 27
        if (is_null($cache)) {
218
            throw new InvalidArgumentException(Message::INVALID_CACHE_ADAPTER);
219
        }
220
221 27
        $credentials = $config->getClientCredentials();
222 27
        $oauthHandler = $this->getHandler(
223 27
            $credentials,
224 27
            $config->getOauthUrl(),
225
            $cache,
226
            $provider,
227 27
            $config->getOAuthClientOptions(),
228 27
            $config->getCorrelationIdProvider()
229
        );
230
231 27
        $options = $this->getDefaultOptions($config);
232
233 27
        $client = $this->createGuzzle6Client(
234 27
            $clientClass,
235
            $options,
236
            $oauthHandler,
237
            $logger,
238 27
            $config->getCorrelationIdProvider()
239
        );
240
241 27
        if ($client instanceof ContextAwareInterface) {
242 26
            $client->setContext($context);
0 ignored issues
show
Deprecated Code introduced by
The function GuzzleHttp\Client::__call() has been deprecated: Client::__call will be removed in guzzlehttp/guzzle:8.0. ( Ignorable by Annotation )

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

242
            /** @scrutinizer ignore-deprecated */ $client->setContext($context);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
243
        }
244 27
        return $client;
245
    }
246
247
    /**
248
     * @param Config|array $config
249
     * @param LoggerInterface $logger
250
     * @param CacheItemPoolInterface|CacheInterface $cache
251
     * @param TokenProvider $provider
252
     * @param CacheAdapterFactory $cacheAdapterFactory
253
     * @param Context|null $context
254
     * @return ApiClient
255
     */
256 25
    public function createClient(
257
        $config,
258
        LoggerInterface $logger = null,
259
        $cache = null,
260
        TokenProvider $provider = null,
261
        CacheAdapterFactory $cacheAdapterFactory = null,
262
        Context $context = null
263
    ) {
264 25
        return $this->createCustomClient(
265 25
            ApiClient::class,
266
            $config,
267
            $logger,
268
            $cache,
269
            $provider,
270
            $cacheAdapterFactory,
271
            $context
272
        );
273
    }
274
275 14
    public function createAuthClient(
276
        array $options = [],
277
        CorrelationIdProvider $correlationIdProvider = null
278
    ) {
279 14
        if (isset($options['handler']) && $options['handler'] instanceof HandlerStack) {
280
            $handler = $options['handler'];
281
        } else {
282 14
            $handler = HandlerStack::create();
283 14
            $options['handler'] = $handler;
284
        }
285
286 14
        $handler->remove("http_errors");
287 14
        $handler->unshift(self::httpErrors(), 'ctp_http_errors');
288
289 14
        $this->setCorrelationIdMiddleware($handler, $correlationIdProvider);
290
291 14
        $options = $this->addMiddlewares($handler, $options);
292
293 14
        return new Client($options);
294
    }
295
296 27
    private function getDefaultOptions(Config $config)
297
    {
298 27
        $options = $config->getClientOptions();
299 27
        $options['http_errors'] = $config->getThrowExceptions();
300 27
        $options['base_uri'] = $config->getApiUrl() . "/" . $config->getProject() . "/";
301
        $defaultHeaders = [
302 27
            'User-Agent' => (new UserAgentProvider())->getUserAgent()
303
        ];
304 27
        if (!is_null($config->getAcceptEncoding())) {
0 ignored issues
show
introduced by
The condition is_null($config->getAcceptEncoding()) is always false.
Loading history...
305 27
            $defaultHeaders['Accept-Encoding'] = $config->getAcceptEncoding();
306
        }
307 27
        $options['headers'] = array_merge($defaultHeaders, (isset($options['headers']) ? $options['headers'] : []));
308
309 27
        return $options;
310
    }
311
312
    /**
313
     * @param Config|array $config
314
     * @return Config
315
     * @throws InvalidArgumentException
316
     */
317 27
    private function createConfig($config)
318
    {
319 27
        if ($config instanceof Config) {
320 27
            return $config;
321
        }
322
        if (is_array($config)) {
0 ignored issues
show
introduced by
The condition is_array($config) is always true.
Loading history...
323
            return Config::fromArray($config);
324
        }
325
        throw new InvalidArgumentException();
326
    }
327
328
    /**
329
     * @param string $clientClass
330
     * @param array $options
331
     * @param OAuth2Handler $oauthHandler
332
     * @param LoggerInterface|null $logger
333
     * @param CorrelationIdProvider|null $correlationIdProvider
334
     * @return Client
335
     */
336 27
    private function createGuzzle6Client(
337
        $clientClass,
338
        array $options,
339
        OAuth2Handler $oauthHandler,
340
        LoggerInterface $logger = null,
341
        CorrelationIdProvider $correlationIdProvider = null
342
    ) {
343 27
        if (isset($options['handler']) && $options['handler'] instanceof HandlerStack) {
344
            $handler = $options['handler'];
345
        } else {
346 27
            $handler = HandlerStack::create();
347 27
            $options['handler'] = $handler;
348
        }
349
350 27
        $options = array_merge(
351
            [
352 27
                'allow_redirects' => false,
353
                'verify' => true,
354
                'timeout' => 60,
355
                'connect_timeout' => 10,
356
                'pool_size' => 25,
357
            ],
358
            $options
359
        );
360
361 27
        if (!is_null($logger)) {
362 23
            $this->setLogger($handler, $logger);
363
        }
364
365 27
        $handler->remove("http_errors");
366 27
        $handler->unshift(self::httpErrors(), 'ctp_http_errors');
367
368 27
        $handler->push(
369 27
            Middleware::mapRequest($oauthHandler),
370 27
            'oauth_2_0'
371
        );
372 27
        if ($oauthHandler->refreshable()) {
373 26
            $handler->push(
374 26
                self::reauthenticate($oauthHandler),
375 26
                'reauthenticate'
376
            );
377
        }
378
379 27
        $this->setCorrelationIdMiddleware($handler, $correlationIdProvider);
380 27
        $options = $this->addMiddlewares($handler, $options);
381
382 27
        $client = new $clientClass($options);
383
384 27
        return $client;
385
    }
386
387 23
    private function setLogger(
388
        HandlerStack $handler,
389
        LoggerInterface $logger,
390
        $logLevel = LogLevel::INFO,
391
        $formatter = null
392
    ) {
393 23
        if (is_null($formatter)) {
394 23
            $formatter = new MessageFormatter();
395
        }
396 23
        $handler->push(self::log($logger, $formatter, $logLevel), 'ctp_logger');
397 23
    }
398
399
    /**
400
     * @param CorrelationIdProvider $correlationIdProvider
401
     * @param HandlerStack $handler
402
     */
403 667
    private function setCorrelationIdMiddleware(
404
        HandlerStack $handler,
405
        CorrelationIdProvider $correlationIdProvider = null
406
    ) {
407 27
        if (!is_null($correlationIdProvider)) {
408
            $handler->push(Middleware::mapRequest(function (RequestInterface $request) use ($correlationIdProvider) {
409 667
                return $request->withAddedHeader(
410 667
                    AbstractApiResponse::X_CORRELATION_ID,
411 667
                    $correlationIdProvider->getCorrelationId()
412
                );
413 27
            }), 'ctp_correlation_id');
414
        }
415 27
    }
416
417
    /**
418
     * @param HandlerStack $handler
419
     * @param array $options
420
     * @return array
421
     */
422 27
    private function addMiddlewares(HandlerStack $handler, array $options)
423
    {
424 27
        if (isset($options['middlewares']) && is_array($options['middlewares'])) {
425 4
            foreach ($options['middlewares'] as $key => $middleware) {
426 4
                if (is_callable($middleware)) {
427 4
                    if (!is_numeric($key)) {
428 4
                        $handler->remove($key);
429 4
                        $handler->push($middleware, $key);
430
                    } else {
431
                        $handler->push($middleware);
432
                    }
433
                }
434
            }
435
        }
436 27
        return $options;
437
    }
438
439
    /**
440
     * Middleware that reauthenticates on invalid token error
441
     *
442
     * @param OAuth2Handler $oauthHandler
443
     * @param int $maxRetries
444
     * @return callable Returns a function that accepts the next handler.
445
     */
446 665
    public static function reauthenticate(OAuth2Handler $oauthHandler, $maxRetries = 1)
447
    {
448
        return function (callable $handler) use ($oauthHandler, $maxRetries) {
449
            return function (RequestInterface $request, array $options) use ($handler, $oauthHandler, $maxRetries) {
450 665
                return $handler($request, $options)->then(
451
                    function (ResponseInterface $response) use (
452 665
                        $request,
453 665
                        $handler,
454 665
                        $oauthHandler,
455 665
                        $options,
456 665
                        $maxRetries
457
                    ) {
458 665
                        if ($response->getStatusCode() == 401) {
459 1
                            if (!isset($options['reauth'])) {
460 1
                                $options['reauth'] = 0;
461
                            }
462 1
                            $exception = ApiException::create($request, $response);
463 1
                            if ($options['reauth'] < $maxRetries && $exception instanceof InvalidTokenException) {
464 1
                                $options['reauth']++;
465 1
                                $token = $oauthHandler->refreshToken();
466 1
                                $request = $request->withHeader(
467 1
                                    'Authorization',
468 1
                                    'Bearer ' . $token->getToken()
469
                                );
470 1
                                return $handler($request, $options);
471
                            }
472
                        }
473 664
                        return $response;
474 665
                    }
475
                );
476 24
            };
477 26
        };
478
    }
479
480
    /**
481
     * Middleware that throws exceptions for 4xx or 5xx responses when the
482
     * "http_error" request option is set to true.
483
     *
484
     * @return callable Returns a function that accepts the next handler.
485
     */
486 667
    public static function httpErrors()
487
    {
488
        return function (callable $handler) {
489
            return function ($request, array $options) use ($handler) {
490 667
                if (empty($options['http_errors'])) {
491 20
                    return $handler($request, $options);
492
                }
493 649
                return $handler($request, $options)->then(
494
                    function (ResponseInterface $response) use ($request, $handler) {
0 ignored issues
show
Unused Code introduced by
The import $handler is not used and could be removed.

This check looks for imports that have been defined, but are not used in the scope.

Loading history...
495 649
                        $code = $response->getStatusCode();
496 649
                        if ($code < 400) {
497 639
                            return $response;
498
                        }
499 190
                        throw ApiException::create($request, $response);
500 649
                    }
501
                );
502 25
            };
503 27
        };
504
    }
505
506
    /**
507
     * @param ClientCredentials $credentials
508
     * @param string $accessTokenUrl
509
     * @param CacheItemPoolInterface|CacheInterface $cache
510
     * @param TokenProvider $provider
511
     * @param array $authClientOptions
512
     * @param CorrelationIdProvider|null $correlationIdProvider
513
     * @return OAuth2Handler
514
     */
515 27
    private function getHandler(
516
        ClientCredentials $credentials,
517
        $accessTokenUrl,
518
        $cache,
519
        TokenProvider $provider = null,
520
        array $authClientOptions = [],
521
        CorrelationIdProvider $correlationIdProvider = null
522
    ) {
523 27
        if (is_null($provider)) {
524 14
            $provider = new CredentialTokenProvider(
525 14
                $this->createAuthClient($authClientOptions, $correlationIdProvider),
526
                $accessTokenUrl,
527
                $credentials
528
            );
529 14
            $cacheKey = sha1($credentials->getClientId() . $credentials->getScope());
530 14
            $provider = new CacheTokenProvider($provider, $cache, $cacheKey);
531
        }
532 27
        return new OAuth2Handler($provider);
533
    }
534
535
    /**
536
     * Middleware that logs requests, responses, and errors using a message
537
     * formatter.
538
     *
539
     * @param LoggerInterface  $logger Logs messages.
540
     * @param MessageFormatter $formatter Formatter used to create message strings.
541
     * @param string           $logLevel Level at which to log requests.
542
     *
543
     * @return callable Returns a function that accepts the next handler.
544
     */
545 665
    private static function log(LoggerInterface $logger, MessageFormatter $formatter, $logLevel = LogLevel::INFO)
546
    {
547
        return function (callable $handler) use ($logger, $formatter, $logLevel) {
548
            return function ($request, array $options) use ($handler, $logger, $formatter, $logLevel) {
549 665
                return $handler($request, $options)->then(
550
                    function (ResponseInterface $response) use ($logger, $request, $formatter, $logLevel) {
551 665
                        $message = $formatter->format($request, $response);
552
                        $context = [
553 665
                            AbstractApiResponse::X_CORRELATION_ID => $response->getHeader(
554 665
                                AbstractApiResponse::X_CORRELATION_ID
555
                            )
556
                        ];
557 665
                        $logger->log($logLevel, $message, $context);
558 665
                        return $response;
559 665
                    },
560
                    function ($reason) use ($logger, $request, $formatter) {
561
                        $response = null;
562
                        $context = [];
563
                        if ($reason instanceof RequestException) {
564
                            $response = $reason->getResponse();
565
                            if (!is_null($response)) {
566
                                $context[AbstractApiResponse::X_CORRELATION_ID] = $response->getHeader(
567
                                    AbstractApiResponse::X_CORRELATION_ID
568
                                );
569
                            }
570
                        }
571
                        $message = $formatter->format($request, $response, $reason);
572
                        $logger->notice($message, $context);
573
                        return \GuzzleHttp\Promise\rejection_for($reason);
0 ignored issues
show
Deprecated Code introduced by
The function GuzzleHttp\Promise\rejection_for() has been deprecated: rejection_for will be removed in guzzlehttp/promises:2.0. Use Create::rejectionFor instead. ( Ignorable by Annotation )

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

573
                        return /** @scrutinizer ignore-deprecated */ \GuzzleHttp\Promise\rejection_for($reason);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
574 665
                    }
575
                );
576 23
            };
577 23
        };
578
    }
579
580
    /**
581
     * @return bool
582
     */
583 28
    private static function isGuzzle6()
584
    {
585 28
        if (is_null(self::$isGuzzle6)) {
0 ignored issues
show
introduced by
The condition is_null(self::isGuzzle6) is always false.
Loading history...
586 1
            if (defined('\GuzzleHttp\Client::MAJOR_VERSION')) {
587 1
                $clientVersion = (string) constant(HttpClient::class . '::MAJOR_VERSION');
588
            } else {
589
                $clientVersion = (string) constant(HttpClient::class . '::VERSION');
590
            }
591 1
            if (version_compare($clientVersion, '6.0.0', '>=')) {
592 1
                self::$isGuzzle6 = true;
593
            } else {
594
                self::$isGuzzle6 = false;
595
            }
596
        }
597 28
        return self::$isGuzzle6;
598
    }
599
600
    /**
601
     * @return ClientFactory
602
     */
603 28
    public static function of()
604
    {
605 28
        return new static();
606
    }
607
}
608