33
33
use PrestaShopBundle \Entity \Repository \LangRepository ;
34
34
use ReflectionClass ;
35
35
use ReflectionMethod ;
36
+ use ReflectionNamedType ;
36
37
use ReflectionParameter ;
37
38
use Symfony \Component \DependencyInjection \Attribute \AutoconfigureTag ;
38
39
use Symfony \Component \PropertyAccess \PropertyAccessorInterface ;
39
40
use Symfony \Component \PropertyInfo \PropertyTypeExtractorInterface ;
41
+ use Symfony \Component \Serializer \Exception \InvalidArgumentException ;
42
+ use Symfony \Component \Serializer \Exception \LogicException ;
40
43
use Symfony \Component \Serializer \Mapping \ClassDiscriminatorResolverInterface ;
41
44
use Symfony \Component \Serializer \Mapping \Factory \ClassMetadataFactoryInterface ;
42
45
use Symfony \Component \Serializer \NameConverter \NameConverterInterface ;
46
+ use Symfony \Component \Serializer \Normalizer \DenormalizerInterface ;
43
47
use Symfony \Component \Serializer \Normalizer \ObjectNormalizer ;
44
48
45
49
/**
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
47
51
* our CQRS <-> ApiPlatform conversion:
48
52
* - 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
56
54
* - converts localized values keys in the arrays:
57
55
* - the input is indexed by locale ['fr-FR' => 'Nom de la valeur', 'en-US' => 'Value name']
58
56
* - the data is normalized and indexed by locale ID [1 => 'Nom de la valeur', 2 => 'Value name']
59
57
* - reversely localized data indexed by IDs are converted into an array localized by locale
58
+ * - handle setter methods that use multiple parameters
60
59
*/
61
60
#[AutoconfigureTag('prestashop.api.normalizers ' )]
62
61
class CQRSApiNormalizer extends ObjectNormalizer
@@ -78,6 +77,20 @@ public function __construct(
78
77
parent ::__construct ($ classMetadataFactory , $ nameConverter , $ propertyAccessor , $ propertyTypeExtractor , $ classDiscriminatorResolver , $ objectClassResolver , $ defaultContext );
79
78
}
80
79
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
+
81
94
/**
82
95
* This method is only used to denormalize the constructor parameters, the CQRS classes usually expect scalar input values that
83
96
* 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, ?
100
113
return $ childContext + [ValueObjectNormalizer::VALUE_OBJECT_RETURNED_AS_SCALAR => true ];
101
114
}
102
115
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
+ */
103
120
protected function extractAttributes (object $ object , ?string $ format = null , array $ context = []): array
104
121
{
105
122
$ 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
+ }
111
129
112
130
foreach ($ reflClass ->getMethods (ReflectionMethod::IS_PUBLIC ) as $ reflMethod ) {
113
131
if (
@@ -121,7 +139,7 @@ protected function extractAttributes(object $object, ?string $format = null, arr
121
139
122
140
$ methodName = $ reflMethod ->name ;
123
141
// 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 ' ) ) {
125
143
continue ;
126
144
}
127
145
@@ -134,17 +152,115 @@ protected function extractAttributes(object $object, ?string $format = null, arr
134
152
return $ attributes ;
135
153
}
136
154
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
+ */
137
159
protected function getAttributeValue (object $ object , string $ attribute , ?string $ format = null , array $ context = []): mixed
138
160
{
139
161
$ attributeValue = parent ::getAttributeValue ($ object , $ attribute , $ format , $ context );
140
162
if (($ context [LocalizedValue::IS_LOCALIZED_VALUE ] ?? false ) && is_array ($ attributeValue )) {
141
- $ attributeValue = $ this ->indexByID ($ attributeValue );
163
+ $ attributeValue = $ this ->updateLanguageIndexesWithIDs ($ attributeValue );
142
164
}
143
165
144
166
return $ attributeValue ;
145
167
}
146
168
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
148
264
{
149
265
$ indexLocalizedValue = [];
150
266
$ this ->fetchLanguagesMapping ();
@@ -161,6 +277,11 @@ protected function indexByID(array $localizedValue): array
161
277
return $ indexLocalizedValue ;
162
278
}
163
279
280
+ /**
281
+ * Fetches the language mapping once and save them in local property for better performance.
282
+ *
283
+ * @return void
284
+ */
164
285
protected function fetchLanguagesMapping (): void
165
286
{
166
287
if (!isset ($ this ->localesByID ) || !isset ($ this ->idsByLocale )) {
@@ -174,7 +295,7 @@ protected function fetchLanguagesMapping(): void
174
295
}
175
296
176
297
/**
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 .
178
299
*
179
300
* @return int
180
301
*/
0 commit comments