1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
declare(strict_types=1); |
4
|
|
|
|
5
|
|
|
namespace Mapado\RestClientSdk\Model; |
6
|
|
|
|
7
|
|
|
use DateTime; |
8
|
|
|
use DateTimeImmutable; |
9
|
|
|
use DateTimeInterface; |
10
|
|
|
use libphonenumber\PhoneNumber; |
11
|
|
|
use libphonenumber\PhoneNumberFormat; |
12
|
|
|
use libphonenumber\PhoneNumberUtil; |
13
|
|
|
use Mapado\RestClientSdk\Exception\MissingSetterException; |
14
|
|
|
use Mapado\RestClientSdk\Exception\SdkException; |
15
|
|
|
use Mapado\RestClientSdk\Helper\ArrayHelper; |
16
|
|
|
use Mapado\RestClientSdk\Mapping; |
17
|
|
|
use Mapado\RestClientSdk\Mapping\ClassMetadata; |
18
|
|
|
use Mapado\RestClientSdk\SdkClient; |
19
|
|
|
use Mapado\RestClientSdk\UnitOfWork; |
20
|
|
|
use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException; |
21
|
|
|
use Symfony\Component\PropertyAccess\PropertyAccess; |
22
|
|
|
use Symfony\Component\PropertyAccess\PropertyAccessor; |
23
|
|
|
|
24
|
|
|
/** |
25
|
|
|
* Class Serializer |
26
|
|
|
* |
27
|
|
|
* @author Julien Deniau <[email protected]> |
28
|
|
|
*/ |
29
|
|
|
class Serializer |
30
|
|
|
{ |
31
|
|
|
/** |
32
|
|
|
* mapping |
33
|
|
|
* |
34
|
|
|
* @var Mapping |
35
|
|
|
*/ |
36
|
|
|
private $mapping; |
37
|
|
|
|
38
|
|
|
/** |
39
|
|
|
* @var SdkClient |
40
|
|
|
*/ |
41
|
|
|
private $sdk; |
42
|
|
|
|
43
|
|
|
/** |
44
|
|
|
* @var UnitOfWork |
45
|
|
|
*/ |
46
|
|
|
private $unitOfWork; |
47
|
|
|
|
48
|
|
|
/** |
49
|
|
|
* @var PropertyAccessor |
50
|
|
|
*/ |
51
|
|
|
private $propertyAccessor; |
52
|
|
|
|
53
|
|
|
public function __construct(Mapping $mapping, UnitOfWork $unitOfWork) |
54
|
|
|
{ |
55
|
1 |
|
$this->mapping = $mapping; |
56
|
1 |
|
$this->unitOfWork = $unitOfWork; |
57
|
1 |
|
$this->propertyAccessor = PropertyAccess::createPropertyAccessor(); |
58
|
1 |
|
} |
59
|
|
|
|
60
|
|
|
/** |
61
|
|
|
* @required |
62
|
|
|
*/ |
63
|
|
|
public function setSdk(SdkClient $sdk): self |
64
|
|
|
{ |
65
|
1 |
|
$this->sdk = $sdk; |
66
|
|
|
|
67
|
1 |
|
return $this; |
68
|
|
|
} |
69
|
|
|
|
70
|
|
|
/** |
71
|
|
|
* serialize entity for POST and PUT |
72
|
|
|
*/ |
73
|
|
|
public function serialize( |
74
|
|
|
object $entity, |
75
|
|
|
string $modelName, |
76
|
|
|
array $context = [] |
77
|
|
|
): array { |
78
|
1 |
|
$out = $this->recursiveSerialize($entity, $modelName, 0, $context); |
79
|
|
|
|
80
|
1 |
|
if (is_string($out)) { |
81
|
|
|
throw new \RuntimeException( |
82
|
|
|
'recursiveSerialize should return an array for level 0 of serialization. This should not happen.' |
83
|
|
|
); |
84
|
|
|
} |
85
|
|
|
|
86
|
1 |
|
return $out; |
87
|
|
|
} |
88
|
|
|
|
89
|
|
|
public function deserialize(array $data, string $className): object |
90
|
|
|
{ |
91
|
1 |
|
$className = $this->resolveRealClassName($data, $className); |
92
|
|
|
|
93
|
1 |
|
$classMetadata = $this->mapping->getClassMetadata($className); |
94
|
|
|
|
95
|
1 |
|
$attributeList = $classMetadata->getAttributeList(); |
96
|
|
|
|
97
|
1 |
|
$instance = new $className(); |
98
|
|
|
|
99
|
1 |
|
if ($attributeList) { |
|
|
|
|
100
|
1 |
|
foreach ($attributeList as $attribute) { |
101
|
1 |
|
$key = $attribute->getSerializedKey(); |
102
|
|
|
|
103
|
1 |
|
if (!ArrayHelper::arrayHas($data, $key)) { |
104
|
1 |
|
continue; |
105
|
|
|
} |
106
|
|
|
|
107
|
1 |
|
$value = ArrayHelper::arrayGet($data, $key); |
108
|
|
|
|
109
|
1 |
|
$attributeName = $attribute->getAttributeName(); |
110
|
1 |
|
$this->throwIfAttributeIsNotWritable($instance, $attributeName); |
111
|
|
|
|
112
|
1 |
|
$relation = $classMetadata->getRelation($key); |
113
|
1 |
|
if ($relation) { |
114
|
1 |
|
if (is_string($value)) { |
115
|
1 |
|
$value = $this->sdk->createProxy($value); |
116
|
1 |
|
} elseif (is_array($value)) { |
117
|
1 |
|
$targetEntity = $relation->getTargetEntity(); |
118
|
1 |
|
$relationClassMetadata = $this->mapping->getClassMetadata( |
119
|
1 |
|
$targetEntity |
120
|
|
|
); |
121
|
|
|
|
122
|
1 |
|
if ($relation->isManyToOne()) { |
123
|
1 |
|
$value = $this->deserialize( |
124
|
1 |
|
$value, |
125
|
1 |
|
$relationClassMetadata->getModelName() |
126
|
|
|
); |
127
|
|
|
} else { |
128
|
|
|
// One-To-Many association |
129
|
1 |
|
$list = []; |
130
|
1 |
|
foreach ($value as $item) { |
131
|
1 |
|
if (is_string($item)) { |
132
|
|
|
$list[] = $this->sdk->createProxy($item); |
133
|
1 |
|
} elseif (is_array($item)) { |
134
|
1 |
|
$list[] = $this->deserialize( |
135
|
1 |
|
$item, |
136
|
1 |
|
$relationClassMetadata->getModelName() |
137
|
|
|
); |
138
|
|
|
} |
139
|
|
|
} |
140
|
|
|
|
141
|
1 |
|
$value = $list; |
142
|
|
|
} |
143
|
|
|
} |
144
|
|
|
} |
145
|
|
|
|
146
|
1 |
|
if (isset($value)) { |
147
|
1 |
|
if ('datetime' === $attribute->getType()) { |
148
|
1 |
|
$this->setDateTimeValue( |
149
|
1 |
|
$instance, |
150
|
|
|
$attributeName, |
151
|
|
|
$value |
152
|
|
|
); |
153
|
|
|
} else { |
154
|
1 |
|
$this->propertyAccessor->setValue( |
155
|
1 |
|
$instance, |
156
|
|
|
$attributeName, |
157
|
|
|
$value |
158
|
|
|
); |
159
|
|
|
} |
160
|
|
|
} |
161
|
|
|
} |
162
|
|
|
} |
163
|
|
|
|
164
|
1 |
|
$classMetadata = $this->getClassMetadata($instance); |
165
|
1 |
|
if ($classMetadata->hasIdentifierAttribute()) { |
166
|
1 |
|
$idGetter = $classMetadata->getIdGetter(); |
167
|
|
|
|
168
|
1 |
|
if ($idGetter) { |
169
|
1 |
|
$callable = [$instance, $idGetter]; |
170
|
1 |
|
$identifier = is_callable($callable) |
171
|
1 |
|
? call_user_func($callable) |
172
|
1 |
|
: null; |
173
|
|
|
|
174
|
1 |
|
if ($identifier) { |
175
|
1 |
|
$this->unitOfWork->registerClean( |
176
|
1 |
|
(string) $identifier, |
177
|
|
|
$instance |
178
|
|
|
); |
179
|
|
|
} |
180
|
|
|
} |
181
|
|
|
} |
182
|
|
|
|
183
|
1 |
|
return $instance; |
184
|
|
|
} |
185
|
|
|
|
186
|
|
|
/** |
187
|
|
|
* If provided class name is abstract (a base class), the real class name (child class) |
188
|
|
|
* may be available in some data fields. |
189
|
|
|
*/ |
190
|
|
|
private function resolveRealClassName( |
191
|
|
|
array $data, |
192
|
|
|
string $className |
193
|
|
|
): string { |
194
|
1 |
|
if (!empty($data['@id'])) { |
195
|
1 |
|
$classMetadata = $this->mapping->tryGetClassMetadataById( |
196
|
1 |
|
$data['@id'] |
197
|
|
|
); |
198
|
|
|
|
199
|
1 |
|
if ($classMetadata) { |
200
|
1 |
|
return $classMetadata->getModelName(); |
201
|
|
|
} |
202
|
|
|
} |
203
|
|
|
|
204
|
|
|
// Real class name could also be retrieved from @type property. |
205
|
1 |
|
return $className; |
206
|
|
|
} |
207
|
|
|
|
208
|
|
|
/** |
209
|
|
|
* @return array|string |
210
|
|
|
*/ |
211
|
|
|
private function recursiveSerialize( |
212
|
|
|
object $entity, |
213
|
|
|
string $modelName, |
214
|
|
|
int $level = 0, |
215
|
|
|
array $context = [] |
216
|
|
|
) { |
217
|
1 |
|
$classMetadata = $this->mapping->getClassMetadata($modelName); |
218
|
|
|
|
219
|
1 |
|
if ($level > 0 && empty($context['serializeRelation'])) { |
220
|
1 |
|
if ($classMetadata->hasIdentifierAttribute()) { |
221
|
1 |
|
$tmpId = $entity->{$classMetadata->getIdGetter()}(); |
222
|
1 |
|
if ($tmpId) { |
223
|
1 |
|
return $tmpId; |
224
|
|
|
} |
225
|
|
|
} |
226
|
|
|
} |
227
|
|
|
|
228
|
1 |
|
$attributeList = $classMetadata->getAttributeList(); |
229
|
|
|
|
230
|
1 |
|
$out = []; |
231
|
1 |
|
if (!empty($attributeList)) { |
232
|
1 |
|
foreach ($attributeList as $attribute) { |
233
|
1 |
|
$method = 'get' . ucfirst($attribute->getAttributeName()); |
234
|
|
|
|
235
|
1 |
|
if ($attribute->isIdentifier() && !$entity->{$method}()) { |
236
|
1 |
|
continue; |
237
|
|
|
} |
238
|
1 |
|
$relation = $classMetadata->getRelation( |
239
|
1 |
|
$attribute->getSerializedKey() |
240
|
|
|
); |
241
|
|
|
|
242
|
1 |
|
$data = $entity->{$method}(); |
243
|
|
|
|
244
|
|
|
if ( |
245
|
1 |
|
null === $data && |
246
|
1 |
|
$relation && |
247
|
1 |
|
$relation->isManyToOne() && |
248
|
1 |
|
$level > 0 |
249
|
|
|
) { |
250
|
|
|
/* |
251
|
|
|
We only serialize the root many-to-one relations to prevent, hopefully, |
252
|
|
|
unlinked and/or duplicated content. For instance, a cart with cartItemList containing |
253
|
|
|
null values for the cart [{ cart => null, ... }] may lead the creation of |
254
|
|
|
CartItem entities explicitly bound to a null Cart instead of the created/updated Cart. |
255
|
|
|
*/ |
256
|
1 |
|
continue; |
257
|
1 |
|
} elseif ($data instanceof DateTimeInterface) { |
258
|
1 |
|
$data = $data->format('c'); |
259
|
1 |
|
} elseif (is_object($data) && $data instanceof PhoneNumber) { |
260
|
1 |
|
$phoneNumberUtil = PhoneNumberUtil::getInstance(); |
261
|
1 |
|
$data = $phoneNumberUtil->format( |
262
|
1 |
|
$data, |
263
|
1 |
|
PhoneNumberFormat::INTERNATIONAL |
264
|
|
|
); |
265
|
|
|
} elseif ( |
266
|
1 |
|
is_object($data) && |
267
|
1 |
|
$relation && |
268
|
1 |
|
$this->mapping->hasClassMetadata( |
269
|
1 |
|
$relation->getTargetEntity() |
270
|
|
|
) |
271
|
|
|
) { |
272
|
1 |
|
$relationClassMetadata = $this->mapping->getClassMetadata( |
273
|
1 |
|
$relation->getTargetEntity() |
274
|
|
|
); |
275
|
|
|
|
276
|
1 |
|
if (!$relationClassMetadata->hasIdentifierAttribute()) { |
277
|
1 |
|
$data = $this->recursiveSerialize( |
278
|
1 |
|
$data, |
279
|
1 |
|
$relation->getTargetEntity(), |
280
|
1 |
|
$level + 1, |
281
|
1 |
|
$context |
282
|
|
|
); |
283
|
|
|
} else { |
284
|
1 |
|
$idAttribute = $relationClassMetadata->getIdentifierAttribute(); |
285
|
|
|
$idGetter = |
286
|
1 |
|
'get' . ucfirst($idAttribute->getAttributeName()); |
287
|
|
|
|
288
|
|
|
if ( |
289
|
1 |
|
method_exists($data, $idGetter) && |
290
|
1 |
|
$data->{$idGetter}() |
291
|
|
|
) { |
292
|
1 |
|
$data = $data->{$idGetter}(); |
293
|
1 |
|
} elseif ($relation->isManyToOne()) { |
294
|
1 |
|
if ($level > 0) { |
295
|
1 |
|
continue; |
296
|
|
|
} else { |
297
|
1 |
|
throw new SdkException( |
298
|
1 |
|
'Case not allowed for now' |
299
|
|
|
); |
300
|
|
|
} |
301
|
|
|
} |
302
|
|
|
} |
303
|
1 |
|
} elseif (is_array($data)) { |
304
|
1 |
|
$newData = []; |
305
|
1 |
|
foreach ($data as $key => $item) { |
306
|
1 |
|
if ($item instanceof DateTimeInterface) { |
307
|
1 |
|
$newData[$key] = $item->format('c'); |
308
|
|
|
} elseif ( |
309
|
1 |
|
is_object($item) && |
310
|
1 |
|
$relation && |
311
|
1 |
|
$this->mapping->hasClassMetadata( |
312
|
1 |
|
$relation->getTargetEntity() |
313
|
|
|
) |
314
|
|
|
) { |
315
|
|
|
$serializeRelation = |
316
|
1 |
|
!empty($context['serializeRelations']) && |
317
|
1 |
|
in_array( |
318
|
1 |
|
$relation->getSerializedKey(), |
319
|
1 |
|
$context['serializeRelations'] |
320
|
|
|
); |
321
|
|
|
|
322
|
1 |
|
$newData[$key] = $this->recursiveSerialize( |
323
|
1 |
|
$item, |
324
|
1 |
|
$relation->getTargetEntity(), |
325
|
1 |
|
$level + 1, |
326
|
1 |
|
['serializeRelation' => $serializeRelation] |
327
|
|
|
); |
328
|
|
|
} else { |
329
|
1 |
|
$newData[$key] = $item; |
330
|
|
|
} |
331
|
|
|
} |
332
|
1 |
|
$data = $newData; |
333
|
|
|
} |
334
|
|
|
|
335
|
1 |
|
$key = $attribute->getSerializedKey(); |
336
|
|
|
|
337
|
1 |
|
$out[$key] = $data; |
338
|
|
|
} |
339
|
|
|
} |
340
|
|
|
|
341
|
1 |
|
return $out; |
342
|
|
|
} |
343
|
|
|
|
344
|
|
|
private function getClassMetadataFromId(string $id): ?ClassMetadata |
345
|
|
|
{ |
346
|
|
|
$key = $this->mapping->getKeyFromId($id); |
347
|
|
|
|
348
|
|
|
return $this->mapping->getClassMetadataByKey($key); |
349
|
|
|
} |
350
|
|
|
|
351
|
|
|
private function getClassMetadata(object $entity): ClassMetadata |
352
|
|
|
{ |
353
|
1 |
|
return $this->mapping->getClassMetadata(get_class($entity)); |
354
|
|
|
} |
355
|
|
|
|
356
|
|
|
private function throwIfAttributeIsNotWritable( |
357
|
|
|
object $instance, |
358
|
|
|
string $attribute |
359
|
|
|
): void { |
360
|
1 |
|
if (!$this->propertyAccessor->isWritable($instance, $attribute)) { |
361
|
1 |
|
throw new MissingSetterException( |
362
|
1 |
|
sprintf( |
363
|
1 |
|
'Property %s is not writable for class %s. Please make it writable. You can check the property-access documentation here : https://symfony.com/doc/current/components/property_access.html#writing-to-objects', |
364
|
|
|
$attribute, |
365
|
1 |
|
get_class($instance) |
366
|
|
|
) |
367
|
|
|
); |
368
|
|
|
} |
369
|
1 |
|
} |
370
|
|
|
|
371
|
|
|
private function setDateTimeValue( |
372
|
|
|
object $instance, |
373
|
|
|
string $attributeName, |
374
|
|
|
string $value |
375
|
|
|
): void { |
376
|
|
|
try { |
377
|
1 |
|
$this->propertyAccessor->setValue( |
378
|
1 |
|
$instance, |
379
|
|
|
$attributeName, |
380
|
1 |
|
new DateTime($value) |
381
|
|
|
); |
382
|
1 |
|
} catch (InvalidArgumentException $e) { |
383
|
|
|
if ( |
384
|
|
|
false === |
385
|
1 |
|
mb_strpos( |
386
|
1 |
|
$e->getMessage(), |
387
|
1 |
|
'Expected argument of type "DateTimeImmutable", "DateTime" given' |
388
|
|
|
) |
389
|
|
|
) { |
390
|
|
|
// not an issue with DateTimeImmutable, then rethrow exception |
391
|
|
|
throw $e; |
392
|
|
|
} |
393
|
|
|
|
394
|
|
|
// The excepted value is a DateTimeImmutable, so let's do that |
395
|
1 |
|
$this->propertyAccessor->setValue( |
396
|
1 |
|
$instance, |
397
|
|
|
$attributeName, |
398
|
1 |
|
new DateTimeImmutable($value) |
399
|
|
|
); |
400
|
|
|
} catch (\TypeError $e) { |
401
|
|
|
// this `catch` block can be dropped when minimum support of symfony/property-access is 3.4 |
402
|
|
|
if ( |
403
|
|
|
false === |
404
|
|
|
mb_strpos( |
405
|
|
|
$e->getMessage(), |
406
|
|
|
'must be an instance of DateTimeImmutable, instance of DateTime given' |
407
|
|
|
) |
408
|
|
|
) { |
409
|
|
|
// not an issue with DateTimeImmutable, then rethrow exception |
410
|
|
|
throw $e; |
411
|
|
|
} |
412
|
|
|
|
413
|
|
|
// The excepted value is a DateTimeImmutable, so let's do that |
414
|
|
|
$this->propertyAccessor->setValue( |
415
|
|
|
$instance, |
416
|
|
|
$attributeName, |
417
|
|
|
new DateTimeImmutable($value) |
418
|
|
|
); |
419
|
|
|
} |
420
|
1 |
|
} |
421
|
|
|
} |
422
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.