Skip to content

Commit 5f38966

Browse files
committed
CQRSApiNormalizer has now the responsibility to handle setters with multiple arguments, DomainSerializer is now much cleaner and is a Serializer itself
1 parent 4a17408 commit 5f38966

File tree

4 files changed

+182
-171
lines changed

4 files changed

+182
-171
lines changed

phpstan-baseline.neon

+5
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,11 @@ parameters:
970970
count: 1
971971
path: src/PrestaShopBundle/ApiPlatform/Normalizer/CQRSApiNormalizer.php
972972

973+
-
974+
message: "#^Method Symfony\\\\Component\\\\Serializer\\\\Normalizer\\\\DenormalizerInterface\\:\\:supportsDenormalization\\(\\) invoked with 4 parameters, 2\\-3 required\\.$#"
975+
count: 1
976+
path: src/PrestaShopBundle/ApiPlatform/Normalizer/CQRSApiNormalizer.php
977+
973978
-
974979
message: "#^Class Employee is forbidden, No legacy calls inside the prestashop bundle\\. Please create an interface and an adapter if you need to\\.$#"
975980
count: 1

src/PrestaShopBundle/ApiPlatform/Normalizer/CQRSApiNormalizer.php

+138-17
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,29 @@
3333
use PrestaShopBundle\Entity\Repository\LangRepository;
3434
use ReflectionClass;
3535
use ReflectionMethod;
36+
use ReflectionNamedType;
3637
use ReflectionParameter;
3738
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
3839
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
3940
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
41+
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
42+
use Symfony\Component\Serializer\Exception\LogicException;
4043
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface;
4144
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
4245
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
46+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
4347
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
4448

4549
/**
46-
* This normalizer is based on the Symfony ObjectNormalizer but it handles some specific normalization for
50+
* This normalizer is based on the Symfony ObjectNormalizer, but it handles some specific normalization for
4751
* our CQRS <-> ApiPlatform conversion:
4852
* - handle getters that match the property without starting by get, has, is
49-
* - normalize ValueObject (when it is the root object), it renames the [value] key based on the ValueObject class name
50-
* ex: new ProductId(42); is not normalized as ['value' => 42] but as ['productId' => 42] which most of the time matches
51-
* the following DTO object to denormalize and saves adding some extra mapping
52-
* - normalize attributes that are ValueObject (so not on the root level) to remove the extra value layer
53-
* ex: new CreatedApiAccess(42, 'my_secret') is not normalized as ['apiAccessId' => ['value' => 42], 'secret' => 'my_secret']
54-
* but as ['apiAccessId' => 42, 'secret' => 'my_secret']
55-
* Again this is useful to help the automatic mapping when denormalizing the following DTO in our workflow
53+
* - set appropriate context for the ValueObjectNormalizer for when we don't want a ValueObject but the scalar value to be used
5654
* - converts localized values keys in the arrays:
5755
* - the input is indexed by locale ['fr-FR' => 'Nom de la valeur', 'en-US' => 'Value name']
5856
* - the data is normalized and indexed by locale ID [1 => 'Nom de la valeur', 2 => 'Value name']
5957
* - reversely localized data indexed by IDs are converted into an array localized by locale
58+
* - handle setter methods that use multiple parameters
6059
*/
6160
#[AutoconfigureTag('prestashop.api.normalizers')]
6261
class CQRSApiNormalizer extends ObjectNormalizer
@@ -78,6 +77,20 @@ public function __construct(
7877
parent::__construct($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext);
7978
}
8079

80+
/**
81+
* This method is overridden because our CQRS objects sometimes have setters with multiple arguments, these are usually used to force specifying arguments that must
82+
* be defined all together, so they can be validated as a whole. The ObjectNormalizer only deserialize object properties one at a time, so we have to handle this special
83+
* use case and the best moment to do so is right after the object is instantiated and right before the properties are deserialized.
84+
*/
85+
protected function instantiateObject(array &$data, string $class, array &$context, ReflectionClass $reflectionClass, bool|array $allowedAttributes, ?string $format = null)
86+
{
87+
$object = parent::instantiateObject($data, $class, $context, $reflectionClass, $allowedAttributes, $format);
88+
$methodsWithMultipleArguments = $this->findMethodsWithMultipleArguments($reflectionClass, $data);
89+
$this->executeMethodsWithMultipleArguments($data, $object, $methodsWithMultipleArguments, $context, $format);
90+
91+
return $object;
92+
}
93+
8194
/**
8295
* This method is only used to denormalize the constructor parameters, the CQRS classes usually expect scalar input values that
8396
* are converted into ValueObject in the constructor, so only in this phase of the denormalization we disable the ValueObjectNormalizer
@@ -100,14 +113,19 @@ protected function createChildContext(array $parentContext, string $attribute, ?
100113
return $childContext + [ValueObjectNormalizer::VALUE_OBJECT_RETURNED_AS_SCALAR => true];
101114
}
102115

116+
/**
117+
* This method is overridden in order to increase the getters used to fetch attributes, by default the ObjectNormalizer
118+
* searches for getters start with get/is/has/can, but it ignores getters that matches the properties exactly.
119+
*/
103120
protected function extractAttributes(object $object, ?string $format = null, array $context = []): array
104121
{
105122
$attributes = parent::extractAttributes($object, $format, $context);
106-
107-
// Check methods that may have been ignored by the parent, the parent normalizer only checks getter if they start
108-
// with "is" or "get" we increase this behaviour on other potential getters that don't match this convention
109-
$metadata = $this->classMetadataFactory->getMetadataFor($object);
110-
$reflClass = $metadata->getReflectionClass();
123+
if ($this->classMetadataFactory) {
124+
$metadata = $this->classMetadataFactory->getMetadataFor($object);
125+
$reflClass = $metadata->getReflectionClass();
126+
} else {
127+
$reflClass = new ReflectionClass(\is_object($object) ? $object::class : $object);
128+
}
111129

112130
foreach ($reflClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflMethod) {
113131
if (
@@ -121,7 +139,7 @@ protected function extractAttributes(object $object, ?string $format = null, arr
121139

122140
$methodName = $reflMethod->name;
123141
// These type of getters have already been handled by the parent
124-
if (str_starts_with($methodName, 'get') || str_starts_with($methodName, 'has') || str_starts_with($methodName, 'is')) {
142+
if (str_starts_with($methodName, 'get') || str_starts_with($methodName, 'has') || str_starts_with($methodName, 'is') || str_starts_with($methodName, 'can')) {
125143
continue;
126144
}
127145

@@ -134,17 +152,115 @@ protected function extractAttributes(object $object, ?string $format = null, arr
134152
return $attributes;
135153
}
136154

155+
/**
156+
* This method is overridden, so we can dynamically change the localized properties identified by a context or the LocalizedValue
157+
* helper attribute. The used key that are based on Language's locale are automatically converted to rely on Language's ID.
158+
*/
137159
protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed
138160
{
139161
$attributeValue = parent::getAttributeValue($object, $attribute, $format, $context);
140162
if (($context[LocalizedValue::IS_LOCALIZED_VALUE] ?? false) && is_array($attributeValue)) {
141-
$attributeValue = $this->indexByID($attributeValue);
163+
$attributeValue = $this->updateLanguageIndexesWithIDs($attributeValue);
142164
}
143165

144166
return $attributeValue;
145167
}
146168

147-
protected function indexByID(array $localizedValue): array
169+
/**
170+
* Call all the method with multiple arguments and remove the data from the normalized data since it has already been denormalized into
171+
* the object.
172+
*
173+
* @param array $data
174+
* @param object $object
175+
* @param array<string, ReflectionMethod> $methodsWithMultipleArguments
176+
*
177+
* @return void
178+
*/
179+
protected function executeMethodsWithMultipleArguments(array &$data, object $object, array $methodsWithMultipleArguments, array $context, ?string $format = null): void
180+
{
181+
foreach ($methodsWithMultipleArguments as $attributeName => $reflectionMethod) {
182+
$methodParameters = $data[$attributeName];
183+
// denormalize parameters
184+
foreach ($reflectionMethod->getParameters() as $parameter) {
185+
$parameterType = $parameter->getType();
186+
if ($parameterType instanceof ReflectionNamedType && !$parameterType->isBuiltin()) {
187+
$childContext = $this->createChildContext($context, $parameter->getName(), $format);
188+
if (!$this->serializer instanceof DenormalizerInterface) {
189+
throw new LogicException(sprintf('Cannot denormalize parameter "%s" for method "%s" because injected serializer is not a denormalizer.', $parameter->getName(), $reflectionMethod->getName()));
190+
}
191+
192+
if ($this->serializer->supportsDenormalization($methodParameters[$parameter->getName()], $parameterType->getName(), $format, $childContext)) {
193+
$methodParameters[$parameter->getName()] = $this->serializer->denormalize($methodParameters[$parameter->getName()], $parameterType->getName(), $format, $childContext);
194+
}
195+
}
196+
}
197+
198+
$reflectionMethod->invoke($object, ...$methodParameters);
199+
unset($data[$attributeName]);
200+
}
201+
}
202+
203+
/**
204+
* @param ReflectionClass $reflectionClass
205+
* @param array $normalizedData
206+
*
207+
* @return array<string, ReflectionMethod>
208+
*/
209+
protected function findMethodsWithMultipleArguments(ReflectionClass $reflectionClass, array $normalizedData): array
210+
{
211+
$methodsWithMultipleArguments = [];
212+
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
213+
// We only look into public method that can be setters with multiple parameters
214+
if (
215+
$reflectionMethod->getNumberOfRequiredParameters() <= 1
216+
|| $reflectionMethod->isStatic()
217+
|| $reflectionMethod->isConstructor()
218+
|| $reflectionMethod->isDestructor()
219+
) {
220+
continue;
221+
}
222+
223+
// Remove set/with to get the potential matching property in data (use full method name by default)
224+
if (str_starts_with($reflectionMethod->getName(), 'set')) {
225+
$methodPropertyName = lcfirst(substr($reflectionMethod->getName(), 3));
226+
} elseif (str_starts_with($reflectionMethod->getName(), 'with')) {
227+
$methodPropertyName = lcfirst(substr($reflectionMethod->getName(), 4));
228+
} else {
229+
$methodPropertyName = $reflectionMethod->getName();
230+
}
231+
232+
// No data found matching the method so we skip it
233+
if (empty($normalizedData[$methodPropertyName])) {
234+
continue;
235+
}
236+
237+
$methodParameters = $normalizedData[$methodPropertyName];
238+
if (!is_array($methodParameters)) {
239+
throw new InvalidArgumentException(sprintf('Value for method "%s" should be an array', $reflectionMethod->getName()));
240+
}
241+
242+
// Now check that all required parameters are present
243+
foreach ($reflectionMethod->getParameters() as $reflectionParameter) {
244+
if (!$reflectionParameter->isOptional() && !isset($methodParameters[$reflectionParameter->getName()])) {
245+
throw new InvalidArgumentException(sprintf('Missing required parameter "%s" for method "%s"', $reflectionParameter->getName(), $reflectionMethod->getName()));
246+
}
247+
}
248+
$methodsWithMultipleArguments[$methodPropertyName] = $reflectionMethod;
249+
}
250+
251+
return $methodsWithMultipleArguments;
252+
}
253+
254+
/**
255+
* Return the localized array with keys based on local string value transformed into integer database IDs.
256+
*
257+
* @param array $localizedValue
258+
*
259+
* @return array
260+
*
261+
* @throws LocaleNotFoundException
262+
*/
263+
protected function updateLanguageIndexesWithIDs(array $localizedValue): array
148264
{
149265
$indexLocalizedValue = [];
150266
$this->fetchLanguagesMapping();
@@ -161,6 +277,11 @@ protected function indexByID(array $localizedValue): array
161277
return $indexLocalizedValue;
162278
}
163279

280+
/**
281+
* Fetches the language mapping once and save them in local property for better performance.
282+
*
283+
* @return void
284+
*/
164285
protected function fetchLanguagesMapping(): void
165286
{
166287
if (!isset($this->localesByID) || !isset($this->idsByLocale)) {
@@ -174,7 +295,7 @@ protected function fetchLanguagesMapping(): void
174295
}
175296

176297
/**
177-
* ObjectNormalizer must be the last normalizer as a fallback.
298+
* CQRSApiNormalizer must be the last normalizer executed after all the special types normalizers already did their job.
178299
*
179300
* @return int
180301
*/

0 commit comments

Comments
 (0)