Skip to content

Commit

Permalink
Improve PHPdoc and localize value tests
Browse files Browse the repository at this point in the history
  • Loading branch information
jolelievre committed Dec 16, 2024
1 parent 0b9b865 commit 168e99f
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 18 deletions.
7 changes: 7 additions & 0 deletions src/PrestaShopBundle/ApiPlatform/Metadata/LocalizedValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
use Attribute;
use Symfony\Component\Serializer\Attribute\Context;

/**
* This attribute can be added on a property in an API resource class, when set the localized values
* are no longer index by Language IDs but by Language's locale instead. It impacts both inputs and outputs
* where JSON localized value must be indexed by locale:
*
* {names: {"2": "english name", "4": "nom français"}} => {"names": {"en-US": "english name", "fr-FR": "nom français"}}
*/
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class LocalizedValue extends Context
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,14 @@ class CQRSApiNormalizer extends ObjectNormalizer
{
public const CAST_BOOL = 'cast_bool';

/**
* @var array<int, string>
*/
protected array $localesByID;

/**
* @var array<string, int>
*/
protected array $idsByLocale;

public function __construct(
Expand All @@ -87,10 +93,10 @@ public function __construct(
*/
protected function instantiateObject(array &$data, string $class, array &$context, ReflectionClass $reflectionClass, bool|array $allowedAttributes, ?string $format = null)
{
$this->castBooleanAttributes($data, $context, $reflectionClass);
$object = parent::instantiateObject($data, $class, $context, $reflectionClass, $allowedAttributes, $format);
$methodsWithMultipleArguments = $this->findMethodsWithMultipleArguments($reflectionClass, $data);
$this->executeMethodsWithMultipleArguments($data, $object, $methodsWithMultipleArguments, $context, $format);
$this->castBooleanAttributes($data, $context, $reflectionClass);

return $object;
}
Expand All @@ -102,6 +108,10 @@ protected function instantiateObject(array &$data, string $class, array &$contex
*/
protected function denormalizeParameter(ReflectionClass $class, ReflectionParameter $parameter, string $parameterName, mixed $parameterData, array $context, ?string $format = null): mixed
{
if (($context[LocalizedValue::IS_LOCALIZED_VALUE] ?? false) && is_array($parameterData)) {
$parameterData = $this->updateLanguageIndexesWithLocales($parameterData);
}

return parent::denormalizeParameter($class, $parameter, $parameterName, $parameterData, $context + [ValueObjectNormalizer::VALUE_OBJECT_RETURNED_AS_SCALAR => true], $format);
}

Expand Down Expand Up @@ -220,6 +230,11 @@ protected function executeMethodsWithMultipleArguments(array &$data, object $obj
/**
* Force casting boolean properties si that values like (1, 0, true, on, false, ...) are valid, this is useful for
* data coming from DB where boolean are returned as tiny integers. Requires CAST_BOOL context option to be true.
*
* Note: in Symfony 7.1 a new option AbstractNormalizer::FILTER_BOOL has been introduced, when we upgrade our
* Symfony dependencies our custom CAST_BOOL option (inspired by the Symfony one) can be removed.
*
* https://symfony.com/doc/7.1/serializer.html#handling-boolean-values
*/
protected function castBooleanAttributes(array &$data, array $context, ReflectionClass $reflectionClass): void
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,38 @@
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

/**
* This normalizer is used to serialize our ValueObject properties used in our CQRS commands and queries.
* Since we don't have a class or a common interface it can detect if a class looks like a ValueObject based
* on these criteria:
* - the object is a class that exists
* - the FQCN contains ValueObject, usually in its namespace not in the class name
* - the class implements a getValue method
* - the constructor has exactly one required parameter which type matches the normalized data type (no
* type is also accepted for old VOs that were not strict enough)
*
* Here is the normalized format of a ValueObject, the index is based on the class name:
*
* new ProductId(42) => ['productId' => 42]
*
* The denormalization expects the key in the array to match either:
* - the constructor parameter name
* - the short class name in came case
* - the short class name in snake-case
* - `value`
*
* The default behaviour of this normalizer is to transform a scalar value into a VO or a VO into scalar value.
* This behaviour can be changed if the ValueObjectNormalizer::VALUE_OBJECT_RETURNED_AS_SCALAR is set to true, in
* which case:
* - denormalization will return the scalar value instead of the VO object
* - normalization will return the scalar value instead of an array containing the value
*
* This is useful:
* - to inject scalar values in the commands/queries constructor that expect scalar values that are then transformed into VOs
* - in normalized data when the VO is one of many properties to avoid an extra layer:
* ex: serialized product will not look like ['productId' => ['value' => 42], 'type' => standard]
* but instead ['productId' => 42, 'type' => standard]
*/
#[AutoconfigureTag('prestashop.api.normalizers')]
class ValueObjectNormalizer implements NormalizerInterface, DenormalizerInterface
{
Expand Down Expand Up @@ -177,11 +209,11 @@ protected function matchesConstructorParameter(mixed $value, string $type): bool
protected function getAllowedValueNames(string $type): array
{
if (!isset($this->allowedNamesByType[$type])) {
$className = substr($type, strrpos($type, '\\') + 1);
$shortPropertyName = lcfirst(substr($type, strrpos($type, '\\') + 1));

$this->allowedNamesByType[$type] = [
$this->inflector->camelize($className),
$this->inflector->tableize($className),
$this->inflector->camelize($shortPropertyName),
$this->inflector->tableize($shortPropertyName),
];

$constructorParameter = $this->getConstructorParameter($type);
Expand Down
6 changes: 6 additions & 0 deletions src/PrestaShopBundle/Entity/Repository/LangRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ public function getOneByLocaleOrIsoCode($locale)
return $language;
}

/**
* Returns all the mapping for all installed languages, the returned array is indexed by Language ID,
* it contains an array with Language info, only locale is relevant for now but it may evolve in the future.
*
* @return array<int, array{'locale': string}>
*/
public function getMapping(): array
{
$qb = $this->createQueryBuilder('l');
Expand Down
35 changes: 23 additions & 12 deletions tests/Integration/ApiPlatform/Serializer/DomainSerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,22 +103,31 @@ public function testDenormalizeWithEmptyValues(): void

public function getExpectedDenormalizedData()
{
$productResource = new Product();
$productResource->productId = 42;
$productResource->names = [
'en-US' => 'english name',
'fr-FR' => 'nom français',
$localizedResource = new LocalizedResource([
'en-US' => 'english link',
'fr-FR' => 'lien français',
]);

// This property has no context attributes, so it remains indexed by IDs
$localizedResource->names = [
self::EN_LANG_ID => 'english name',
self::getFrenchId() => 'nom français',
];
$productResource->descriptions = [
$localizedResource->descriptions = [
'en-US' => 'english description',
'fr-FR' => 'description française',
];
$productResource->active = true;
$productResource->type = ProductType::TYPE_STANDARD;
$localizedResource->titles = [
'en-US' => 'english title',
'fr-FR' => 'titre français',
];

yield 'api resource with localized properties should have indexes based on locale values instead of integers' => [
[
'productId' => 42,
'localizedLinks' => [
self::EN_LANG_ID => 'english link',
self::getFrenchId() => 'lien français',
],
'names' => [
self::EN_LANG_ID => 'english name',
self::getFrenchId() => 'nom français',
Expand All @@ -127,10 +136,12 @@ public function getExpectedDenormalizedData()
self::EN_LANG_ID => 'english description',
self::getFrenchId() => 'description française',
],
'active' => true,
'type' => ProductType::TYPE_STANDARD,
'titles' => [
self::EN_LANG_ID => 'english title',
self::getFrenchId() => 'titre français',
],
],
$productResource,
$localizedResource,
];

yield 'command with various property types all in constructor' => [
Expand Down
4 changes: 2 additions & 2 deletions tests/UI/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 168e99f

Please sign in to comment.