commercetools-8.x-1.2-alpha1/src/CommercetoolsService.php
src/CommercetoolsService.php
<?php
namespace Drupal\commercetools;
use Drupal\commercetools\Cache\CacheableCommercetoolsGraphQlResponse;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Logger\LoggerChannelTrait;
use GraphQL\Actions\Query;
use GraphQL\Entities\Node;
use Punic\Number;
/**
* Commercetools main service.
*/
class CommercetoolsService {
use LoggerChannelTrait;
const CONFIGURATION_NAME = CommercetoolsConfiguration::CONFIGURATION_SETTINGS;
const PRODUCT_TYPE_CACHE_KEY = 'commercetools_product';
const CATEGORY_CACHE_KEY = 'commercetools_product_category';
const CONFIG_PRICE_CUSTOMER_GROUP = 'price_customer_group';
const CONFIG_ITEMS_PER_PAGE = 'items_per_page';
const CONFIG_CARD_IMAGE_STYLE = 'card_image_style';
const CONFIG_UNAVAILABLE_DATA_TEXT = 'unavailable_data_text';
const CONFIG_DISPLAY_CONNECTION_ERRORS = 'display_connection_errors';
const CONFIG_LOG_CT_REQUESTS = 'log_commercetools_requests';
const CONFIG_CHECKOUT_MODE = 'checkout_mode';
const CONFIG_CHECKOUT_CT_APP_KEY = 'checkout_commercetools_app_key';
const CONFIG_CHECKOUT_CT_INLINE = 'checkout_commercetools_inline';
const CONFIG_PAGE_ATTRIBUTES_ENABLED = 'customize_page_attributes_enabled';
const CONFIG_PAGE_ATTRIBUTES = 'customize_page_attributes';
const FILTER_TYPE_VALUE = 'value';
const FILTER_TYPE_TREE = 'tree';
const FILTER_TYPE_RANGE = 'range';
const CT_VARIANTS_PRICE_AMOUNT = 'variants.price.centAmount';
const CATEGORY_LIST_LIMIT = 500;
/**
* The product types.
*
* @var array
*/
protected array $productsTypesLocalized;
/**
* CommercetoolsGraphQLService constructor.
*
* @param \Drupal\commercetools\CommercetoolsApiServiceInterface $ctApi
* The Commercetools API service.
* @param \Drupal\commercetools\CommercetoolsConfiguration $ctConfig
* The commercetools configuration service.
* @param \Drupal\commercetools\CommercetoolsLocalization $ctLocalization
* The commercetools localization service.
* @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
* The cache backend.
*/
public function __construct(
protected CommercetoolsApiServiceInterface $ctApi,
protected CommercetoolsConfiguration $ctConfig,
protected CommercetoolsLocalization $ctLocalization,
protected CacheBackendInterface $cacheBackend,
) {}
/**
* Get product types.
*
* @param bool $reset
* If the list of values should be reset.
*
* @return array
* Array of product types.
*/
public function getProductsTypes(bool $reset = FALSE): array {
if (isset($this->productsTypesLocalized)) {
return $this->productsTypesLocalized;
}
if (!$reset && $cacheData = $this->cacheBackend->get(self::PRODUCT_TYPE_CACHE_KEY)) {
$this->productsTypesLocalized = $this->localizeProductTypes($cacheData->data);
return $this->productsTypesLocalized;
}
$query = new Query('productTypes');
$resultsQuery = $query->results([]);
$resultsQuery->use('key', 'name');
$attrDefinition = $resultsQuery->attributeDefinitions([]);
$attrResults = $attrDefinition->results([]);
$attrResults->use('name', 'isSearchable');
$attrResults->labelAllLocales([])->use('locale', 'value');
$attrResults->type([])->use('name');
$response = $this->ctApi->executeGraphQlOperation($query);
$result = $response->getData();
$productTypes = [];
foreach ($result['productTypes']['results'] as $productType) {
$attributeDefinitions = [];
foreach ($productType['attributeDefinitions']['results'] as $attribute) {
// @todo Implement supporting set attribute type.
if ($attribute['type']['name'] === 'set') {
continue;
}
$attribute['type'] = $attribute['type']['name'];
$attributeDefinitions[$attribute['name']] = $attribute;
}
$productTypes[$productType['key']] = ['attributeDefinitions' => $attributeDefinitions] + $productType;
}
$this->cacheBackend->set(self::PRODUCT_TYPE_CACHE_KEY, $productTypes, tags: [
'config:' . self::CONFIGURATION_NAME,
'config:' . CommercetoolsApiServiceInterface::CONFIGURATION_NAME,
]);
$this->productsTypesLocalized = $this->localizeProductTypes($productTypes);
return $this->productsTypesLocalized;
}
/**
* Localize product types.
*
* @param array $productTypes
* Array of product types.
*
* @return array
* Array of localized product types.
*/
protected function localizeProductTypes(array $productTypes): array {
foreach ($productTypes as $typeName => $productType) {
foreach ($productType['attributeDefinitions'] as $attrName => $attr) {
$productTypes[$typeName]['attributeDefinitions'][$attrName]['label'] = $this->getTranslationValue($attr['labelAllLocales']);
}
}
return $productTypes;
}
/**
* Return product attribute path.
*/
public function getAttributePath(string $productTypeKey, string $attributeKey): string|null {
$productTypes = $this->getProductsTypes();
if (!($attribute = $productTypes[$productTypeKey]['attributeDefinitions'][$attributeKey])) {
return NULL;
}
switch ($attribute['type']) {
case 'enum':
$path = "variants.attributes.{$attribute['name']}.label";
break;
case 'ltext':
$path = "variants.attributes.{$attribute['name']}.[current_locale]";
break;
case 'lenum':
$path = "variants.attributes.{$attribute['name']}.label.[current_locale]";
break;
default:
$path = "variants.attributes.{$attribute['name']}";
}
return $path;
}
/**
* Replace the special locale token with the current locale.
*/
public function localizePath($path) {
return str_replace('[current_locale]', $this->ctLocalization->getOption(CommercetoolsLocalization::CONFIG_LANGUAGE), $path);
}
/**
* Build facet GraphQL structure to get facet by request.
*/
public function buildFacetGraphql($path, $countProducts = FALSE): array {
return [
'model' => [
'terms' => [
'path' => $path,
'countProducts' => $countProducts,
],
],
];
}
/**
* Build facet GraphQL structure to get facet by request.
*/
public function buildRangeFacetGraphql($path): array {
return [
'model' => [
'range' => [
'countProducts' => FALSE,
'path' => $path,
'ranges' => [
'from' => '0',
'to' => '*',
],
],
],
];
}
/**
* Get attributes which should be used on the product page.
*
* @return array
* A list of product attributes.
*/
public function getEnabledAttributes(): array {
$attributes = [];
$customization = $this->ctConfig->settings->get(self::CONFIG_PAGE_ATTRIBUTES_ENABLED) ?: NULL;
foreach ($this->getProductsTypes() as $productTypeKey => $productType) {
foreach ($productType['attributeDefinitions'] as $attributeKey => $attribute) {
if (isset($customization) && empty($customization[$productTypeKey . '_' . $attributeKey])) {
continue;
}
$attributes[$productTypeKey][] = $attribute;
}
}
return $attributes;
}
/**
* Retrieves product categories to be used for displaying products.
*
* @return array
* A list of available categories.
*/
public function getProductCategories(): array {
$list = [];
// Check for data in cache.
if ($cached = $this->cacheBackend->get(self::CATEGORY_CACHE_KEY)) {
$list = $cached->data;
}
// Request data from API.
else {
$query = new Query('categories', [
'limit' => self::CATEGORY_LIST_LIMIT,
'sort' => 'orderHint ASC',
]);
$query->results([])->use(
'id',
'key',
'orderHint',
'nameAllLocales{locale, value}',
'parent{id}',
);
$result = $this->ctApi->executeGraphQlOperation($query)->getData();
$list = $result['categories']['results'];
$this->cacheBackend->set(self::CATEGORY_CACHE_KEY, $list, tags: [
CacheableCommercetoolsGraphQlResponse::CACHE_TAG_CATEGORY_LIST,
]);
}
// Format categories list.
$categories = [];
foreach ($list as $item) {
$categories[$item['id']] = [
'id' => $item['id'],
'key' => $item['key'],
'name' => $this->getTranslationValue($item['nameAllLocales']),
'orderHint' => $item['orderHint'],
'parent' => $item['parent']['id'] ?? NULL,
];
}
return $categories;
}
/**
* Builds product categories tree.
*
* @param array|null $list
* The list of categories. Optional.
*
* @return array
* A tree of categories.
*/
public function getProductCategoriesTree(?array $list = NULL): array {
$categories = $list ?? $this->getProductCategories();
$fillChildren = function ($parentId = NULL) use ($categories, &$fillChildren) {
$children = [];
foreach ($categories as $category) {
if (($category['parent'] ?? NULL) == $parentId) {
$catChildren = $fillChildren($category['id']);
$category['children'] = $catChildren;
$children[$category['id']] = $category;
}
}
return $children;
};
return array_filter($fillChildren(), fn($cat) => empty($cat['parent']));
}
/**
* Get a localized price.
*
* @param array $price
* The array with all price data.
* @param string|null $locale
* Locale in which the number would be formatted.
*
* @return false|string
* The localized price.
*/
public function localizePrice(array $price, ?string $locale = NULL): string|false {
$locale ??= $this->ctLocalization->getOption(CommercetoolsLocalization::CONFIG_LANGUAGE);
$currencyValue = $price['centAmount'] / pow(10, $price['fractionDigits']);
return $this->formatCurrency($locale, $currencyValue, $price['currencyCode']);
}
/**
* Formats a monetary amount for a given locale and currency.
*/
protected function formatCurrency(string $locale, float $currencyValue, string $currencyCode): string {
if (class_exists(\NumberFormatter::class) && function_exists('numfmt_create') && function_exists('numfmt_format_currency')) {
$fmt = numfmt_create($locale, \NumberFormatter::CURRENCY);
$result = numfmt_format_currency($fmt, $currencyValue, $currencyCode);
if ($result !== FALSE) {
return $result;
}
}
return Number::formatCurrency($currencyValue, $currencyCode, 'standard', NULL, '', $locale);
}
/**
* Format a price array from the given array.
*
* @param array $price
* The price array, with the "currencyCode", "centAmount"
* and "fractionDigits" keys.
*
* @return array
* Array of all necessary price values.
*/
public function formatPrice(array $price): array {
return [
'centAmount' => $price['centAmount'],
'currencyCode' => $price['currencyCode'],
'currencyValue' => $price['centAmount'] / pow(10, $price['fractionDigits']),
'fractionDigits' => $price['fractionDigits'],
'localizedPrice' => $this->localizePrice($price),
];
}
/**
* Add default Commercetools price node to graphql node.
*
* @param \GraphQL\Entities\Node $node
* The node to which the price node will be added.
* @param bool $includeFilters
* If true, price filters will be added.
* @param array $filters
* The array with extra price filters.
*/
public function addPriceNode(Node $node, bool $includeFilters = FALSE, array $filters = []): void {
$filter = $includeFilters ? $filters + array_filter([
'currency' => $this->ctLocalization->getOption(CommercetoolsLocalization::CONFIG_CURRENCY),
'country' => $this->ctLocalization->getOption(CommercetoolsLocalization::CONFIG_COUNTRY),
'customerGroupId' => $this->ctConfig->settings->get(self::CONFIG_PRICE_CUSTOMER_GROUP) ?? NULL,
'channelId' => $this->ctLocalization->getOption(CommercetoolsLocalization::CONFIG_CHANNEL),
]) : [];
$price = $node->price($filter);
self::addPriceNodeFields($price->value([]));
self::addPriceNodeFields($price->discounted->value([]));
}
/**
* Add node price field into graphql node.
*
* @param \GraphQL\Entities\Node $node
* The node to which the price fields will be added.
*/
public function addPriceNodeFields(Node $node): void {
$node->use('centAmount', 'currencyCode', 'fractionDigits');
}
/**
* Add node address field into graphql node.
*
* @param \GraphQL\Entities\Node $node
* The node to which the address fields will be added.
*/
public function addAddressNodeFields(Node $node): void {
$node->use(...CommercetoolsCustomers::ADDRESS_FIELDS);
}
/**
* Adds fields to graphql node.
*
* @param \GraphQL\Entities\Node $node
* The node to which the fields will be added.
* @param array $fields
* The list of fields to add.
* @param array $translatableFields
* The list of translatable fields. Optional.
*
* @return \GraphQL\Entities\Node
* An updated graphql node.
*/
public function addFieldsToNode(Node $node, array $fields, array $translatableFields = []): Node {
foreach ($fields as $field) {
if (in_array($field, $translatableFields)) {
$node->$field($this->ctLocalization->getLanguageArgument());
}
else {
$node->use($field);
}
}
return $node;
}
/**
* Matches translations list items to a current language.
*
* @param array $values
* The list of localized variants.
* Two types of data structures are acceptable:
* [
* 'en' => 'Name',
* ]
* and
* [
* 'locale' => 'en',
* 'value' => 'Name',
* ].
*
* @return string
* A variant that matches current language.
*/
public function getTranslationValue(array $values): string {
$localized = [];
foreach ($values as $key => $value) {
if (isset($value['locale']) && isset($value['value'])) {
$localized[$value['locale']] = $value['value'];
}
// Otherwise we assume that key is a locale.
elseif (is_string($key) && is_string($value)) {
$localized[$key] = $value;
}
}
return $this->ctLocalization->fromLocalizedArray($localized);
}
/**
* {@inheritdoc}
*/
public function getWhereValue($name, $value, array $translatableFields = []) {
if (in_array($name, $translatableFields)) {
$localized = [];
foreach ($this->ctLocalization->getLanguageFallbacks() as $fallback) {
$localized[] = $fallback . '="' . addslashes($value) . '"';
}
$value = [implode(' or ', $localized)];
}
return $value;
}
/**
* {@inheritdoc}
*/
public static function whereToString(array $where): string {
$string = '';
// @todo Add support for different operators.
// @todo Add support for AND and OR.
foreach ($where as $key => $value) {
if (is_array($value)) {
$string .= $key . '(' . self::whereToString($value) . ')';
}
else {
$string .= is_string($key) ? $key . '="' . addslashes($value) . '"' : $value;
}
}
return $string;
}
/**
* Builds a filter array for a given path and values.
*
* @param string $path
* The filter path.
* @param string|array $values
* The filter values.
* @param string $type
* The filter option. Optional. Default is 'value'.
*
* @return array
* The filter array.
*/
public function buildFilter(string $path, string|array $values, string $type = self::FILTER_TYPE_VALUE): array {
// @todo Add support for different options: range, missing, exists, string.
switch ($type) {
case self::FILTER_TYPE_TREE:
return [
'model' => [
'tree' => [
'path' => $path,
'rootValues' => [],
'subTreeValues' => array_values($values),
],
],
];
case self::FILTER_TYPE_RANGE:
return [
'model' => [
'range' => [
'path' => $path,
// Array ['from' => '', 'to' => ''].
'ranges' => $values,
],
],
];
default:
return [
'model' => [
'value' => [
'path' => $path,
'values' => is_array($values) ? array_values($values) : $values,
],
],
];
}
}
}
