commercetools-8.x-1.2-alpha1/src/CommercetoolsApiService.php
src/CommercetoolsApiService.php
<?php
namespace Drupal\commercetools;
use Commercetools\Api\Client\ClientCredentialsConfig;
use Commercetools\Api\Client\Config;
use Commercetools\Api\Models\GraphQl\GraphQLRequestBuilder;
use Commercetools\Api\Models\GraphQl\GraphQLVariablesMapModel;
use Commercetools\Client\ApiRequestBuilder;
use Commercetools\Client\ClientCredentials;
use Commercetools\Client\MiddlewareFactory;
use Commercetools\Client\OAuth2Handler;
use Commercetools\Client\OAuthHandlerFactory;
use Commercetools\Exception\InvalidArgumentException;
use Commercetools\Exception\NotFoundException;
use Drupal\commercetools\Cache\CacheableCommercetoolsGraphQlResponse;
use Drupal\commercetools\Event\CommercetoolsGraphQlOperationEvent;
use Drupal\commercetools\Event\CommercetoolsGraphQlOperationResultEvent;
use Drupal\commercetools\Exception\CommercetoolsGraphqlAccessException;
use Drupal\commercetools\Exception\CommercetoolsGraphqlErrorException;
use Drupal\commercetools\Exception\CommercetoolsOperationFailedException;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Cache\UseCacheBackendTrait;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Http\ClientFactory;
use Drupal\Core\Logger\LoggerChannelTrait;
use GraphQL\Entities\Node;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\HandlerStack;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface as PsrSimpleCacheInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Commercetools API service.
*/
class CommercetoolsApiService implements CommercetoolsApiServiceInterface {
use LoggerChannelTrait;
use UseCacheBackendTrait;
const COMMERCETOOLS_API_HOST = 'commercetools.com';
/**
* Guzzle Http client with Commercetools middlewares.
*
* @var \GuzzleHttp\ClientInterface|null
*/
protected ?ClientInterface $client;
/**
* Auth Config.
*
* @var \Commercetools\Api\Client\ClientCredentialsConfig
*/
protected ClientCredentialsConfig $authConfig;
/**
* Auth Handler.
*
* @var \Commercetools\Client\OAuth2Handler
*/
protected OAuth2Handler $authHandler;
/**
* The current connection credentials config.
*
* @var array
*/
protected array $connectionConfig;
/**
* CommercetoolsApiService constructor.
*
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher
* An event dispatcher instance.
* @param \Drupal\commercetools\CommercetoolsConfiguration $ctConfig
* The commercetools configuration service.
* @param \Psr\Log\LoggerInterface $logger
* The logger service.
* @param \Drupal\Core\Http\ClientFactory $httpClientFactory
* The HTTP client factory.
* @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
* A cache backend.
* @param \Psr\SimpleCache\CacheInterface $psrCache
* The cache instance for the Commercetools Auth.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The configuration factory.
*/
public function __construct(
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly CommercetoolsConfiguration $ctConfig,
protected readonly LoggerInterface $logger,
protected readonly ClientFactory $httpClientFactory,
CacheBackendInterface $cacheBackend,
protected readonly PsrSimpleCacheInterface $psrCache,
protected readonly TimeInterface $time,
protected readonly ConfigFactoryInterface $configFactory,
) {
$this->cacheBackend = $cacheBackend;
$this->connectionConfig = $ctConfig->getConnectionConfig();
}
/**
* {@inheritdoc}
*/
public function setOverriddenConfig(array $customConfig): void {
$this->connectionConfig = array_merge($this->connectionConfig, $customConfig);
$this->resetConnection();
}
/**
* {@inheritdoc}
*/
public function restoreOriginalConfig(): void {
$this->connectionConfig = $this->ctConfig->getConnectionConfig();
$this->resetConnection();
}
/**
* {@inheritdoc}
*/
public function getConnectionConfig(): array {
return $this->connectionConfig;
}
/**
* {@inheritdoc}
*/
public function resetConnection(): void {
$this->psrCache->clear();
$this->client = NULL;
}
/**
* {@inheritdoc}
*/
public function getClient(): ClientInterface {
// Return the client if it's already initialized and no rebuild required.
if (isset($this->client)) {
return $this->client;
}
// Set up the OAuth handler and middlewares.
$this->authHandler = $this->getAuthHandler();
$clientConfig = new Config([], $this->getApiUrl());
$options = $clientConfig->getOptions();
$options['handler'] = HandlerStack::create();
// A copy-paste from
// Commercetools\Client\ClientFactory::createGuzzleClientForHandler()
// to use a customized Guzzle client in our case.
$enableLogs = $this->configFactory->get(CommercetoolsService::CONFIGURATION_NAME)->get(CommercetoolsService::CONFIG_LOG_CT_REQUESTS);
$middlewares = MiddlewareFactory::createDefaultMiddlewares(
$this->authHandler,
$enableLogs ? $this->logger : NULL,
(int) ($options['maxRetries'] ?? 0)
);
// A copy-paste from
// Commercetools\Client\ClientFactory::createGuzzleClientWithOptions()
// to use a customized Guzzle client in our case.
foreach ($middlewares as $key => $middleware) {
if (!is_callable($middleware)) {
throw new InvalidArgumentException('Middleware isn\'t callable');
}
$name = is_numeric($key) ? '' : $key;
$options['handler']->push($middleware, $name);
}
$this->client = $this->httpClientFactory->fromOptions($options);
return $this->client;
}
/**
* Builds an OAuth2 handler for commercetools using client credentials.
*
* @param string|array|null $scope
* Optional scope override (e.g., "manage_project"). If omitted, the value
* from the connection configuration is used. The project key is appended
* automatically.
*
* @return \Commercetools\Client\OAuth2Handler
* A ready-to-use OAuth2 handler.
*/
public function getAuthHandler(string|array|null $scope = NULL): OAuth2Handler {
$config = $this->connectionConfig;
$scope = $scope ?? ($config[self::CONFIG_SCOPE] ?? NULL);
if (!empty($scope)) {
$scope = is_array($scope)
? $scope
: explode(' ', $scope);
}
// The commercetools API requires the project key included in the scope.
$scopeWithProjectId = !empty($scope)
? implode(' ', array_map(static fn($s) => "{$s}:{$config[self::CONFIG_PROJECT_KEY]}", $scope))
: NULL;
$clientCredentials = new ClientCredentials(
$config[self::CONFIG_CLIENT_ID],
$config[self::CONFIG_CLIENT_SECRET],
$scopeWithProjectId
);
// @todo Generate the HTTP client options from the factory using the method
// stubAlterConfigOptions() to improve the test coverage.
$httpClientOptions = [];
$authConfig = new ClientCredentialsConfig($clientCredentials, $httpClientOptions, $this->getApiUrl('auth'));
return OAuthHandlerFactory::ofAuthConfig($authConfig, $this->psrCache);
}
/**
* {@inheritdoc}
*/
public function isAccessConfigured(): bool {
return $this->ctConfig->isConnectionConfigured();
}
/**
* {@inheritdoc}
*/
public function getBuilder(): ApiRequestBuilder {
return new ApiRequestBuilder($this->connectionConfig[self::CONFIG_PROJECT_KEY], $this->getClient());
}
/**
* {@inheritdoc}
*/
public function getApiUrl(string $type = 'api'): string {
$region = $this->connectionConfig[self::CONFIG_HOSTED_REGION];
$host = $region . '.' . self::COMMERCETOOLS_API_HOST;
return match ($type) {
'region' => $region,
'api' => "https://api.$host",
'auth' => "https://auth.$host/oauth/token",
'session' => "https://session.$host",
default => throw new InvalidArgumentException('Invalid API endpoint type'),
};
}
/**
* Generates a cache key for the GraphQL request.
*
* @param string $query
* A GraphQL query.
* @param array $variables
* An array of variables.
* @param \Commercetools\Client\ApiRequestBuilder $builder
* The ApiRequestBuilder.
*
* @return string
* A cache key.
*/
protected function getGraphQlRequestCacheKey(string $query, array $variables, ApiRequestBuilder $builder) {
$hash = sha1(Json::encode([
'request' => $query,
'variables' => $variables,
'builderArgs' => $builder->me()->getArgs(),
]));
// @todo Make a constant with the prefix.
return 'commercetools_graphql_' . $hash;
}
/**
* Converts a max-age value to a real "expire" value for the Cache API.
*
* @param int $maxAge
* A max-age value.
*
* @return int
* A corresponding "expire" timestamp or permanent constant.
*
* @see \Drupal\Core\Cache\Cache::PERMANENT
*/
protected function cacheMaxAgeToExpire(int $maxAge): int {
if ($maxAge !== Cache::PERMANENT) {
return $this->time->getRequestTime() + $maxAge;
}
return $maxAge;
}
/**
* {@inheritdoc}
*/
public function executeGraphQlOperation(
string $query,
?array $variables = NULL,
?CacheableMetadata $cacheMetadata = NULL,
): CacheableCommercetoolsGraphQlResponse {
$variables ??= [];
$operationAllowed = TRUE;
// Handle cache metadata and apply overrides if any.
$cacheDefaultTtl = $this->connectionConfig[self::CONFIG_CACHE_RESPONSES_TTL];
if ($cacheMetadata instanceof CacheableDependencyInterface) {
$cacheableMetadata = CacheableMetadata::createFromObject($cacheMetadata);
if (
$cacheMetadata->getCacheMaxAge() == Cache::PERMANENT
&& $cacheDefaultTtl != Cache::PERMANENT
) {
$cacheableMetadata->setCacheMaxAge($cacheDefaultTtl);
}
}
else {
$cacheableMetadata = new CacheableMetadata();
$cacheableMetadata->setCacheMaxAge($cacheDefaultTtl);
}
// Allow changing variables and query cacheable metadata before execution.
$event = new CommercetoolsGraphQlOperationEvent($variables, $operationAllowed, $cacheableMetadata, $query);
$this->eventDispatcher->dispatch($event);
if ($operationAllowed === FALSE) {
throw new CommercetoolsGraphqlAccessException();
}
// Get the builder and use it to generate the cache ID and build request.
$builder = $this->getBuilder();
if ($cacheableMetadata->getCacheMaxAge() != 0) {
$cid = $this->getGraphQlRequestCacheKey($query, $variables, $builder);
if ($cachedResponse = $this->cacheGet($cid)) {
return CacheableCommercetoolsGraphQlResponse::createFromCachedObject($cachedResponse);
}
}
$graphQLRequest = GraphQLRequestBuilder::of()->withQuery($query);
if ($variables) {
$graphQLRequest->withVariables(GraphQLVariablesMapModel::of($variables));
}
$apiRequest = $builder->graphql()->post($graphQLRequest->build());
try {
$apiResponse = $apiRequest->execute();
if ($errors = $apiResponse->getErrors()) {
throw new CommercetoolsGraphqlErrorException($errors, $query, $variables);
}
$apiResponseData = $apiResponse->getData();
if (empty($apiResponseData)) {
throw new CommercetoolsOperationFailedException('The commercetools API returned an empty response.');
}
}
catch (ClientException | ConnectException | NotFoundException | RequestException $e) {
throw new CommercetoolsOperationFailedException(previous: $e);
}
// Converting nested object to nested associative array.
$result = (array) Json::decode(Json::encode($apiResponseData));
// Allow to change the query result after execution.
$event = new CommercetoolsGraphQlOperationResultEvent($result, $cacheableMetadata, $query, $variables);
$this->eventDispatcher->dispatch($event);
$cacheMaxAge = $cacheableMetadata->getCacheMaxAge();
if ($cacheMaxAge != 0) {
$this->cacheSet(
$cid ?? $this->getGraphQlRequestCacheKey($query, $variables, $builder),
$result,
$this->cacheMaxAgeToExpire($cacheMaxAge),
$cacheableMetadata->getCacheTags()
);
}
$response = new CacheableCommercetoolsGraphQlResponse($result);
$response->addCacheableDependency($cacheableMetadata);
return $response;
}
/**
* {@inheritdoc}
*/
public function getProjectInfo(): array {
$query = new Node('');
$query->project([])
->use('key', 'name', 'countries', 'currencies', 'languages');
$query->customerGroups([])->results([])->use('name', 'id');
$query->channels([])->results([])->use('id')->nameAllLocales([])->use('locale', 'value');
$cacheMetadata = new CacheableMetadata();
$cacheMetadata->setCacheMaxAge(0);
// Try to retrieve stores list.
try {
$storeQuery = clone $query;
$storeQuery->stores([])->results([])->use('key')->nameAllLocales([])->use('locale', 'value');
$result = $this->executeGraphQlOperation($storeQuery->toString(), cacheMetadata: $cacheMetadata)
->getData();
}
catch (CommercetoolsGraphqlErrorException) {
$result = $this->executeGraphQlOperation($query->toString(), cacheMetadata: $cacheMetadata)
->getData();
}
return $this->buildProjectInfo($result);
}
/**
* Prepares a project info.
*
* @param array $data
* The project info from API response.
*
* @return array
* A prepared project info list.
*/
private function buildProjectInfo(array $data): array {
$projectInfo = [
'stores' => [],
];
$projectInfo += $data['project'] ?? [];
foreach ($data['channels']['results'] ?? [] as $channel) {
foreach ($channel['nameAllLocales'] as $locale) {
$projectInfo['channels'][$channel['id']][$locale['locale']] = $locale['value'];
}
}
foreach ($data['stores']['results'] ?? [] as $store) {
foreach ($store['nameAllLocales'] as $locale) {
$projectInfo['stores'][$store['key']][$locale['locale']] = $locale['value'];
}
}
return $projectInfo;
}
/**
* {@inheritdoc}
*/
public function prepareResponse(array $data, ?RefinableCacheableDependencyInterface $cache = NULL): CacheableCommercetoolsResponse {
$response = new CacheableCommercetoolsResponse($data);
if ($cache) {
$response->addCacheableDependency($cache);
}
return $response;
}
}
