Last active
February 21, 2024 14:20
-
-
Save iKsSs/f048fd04f28c638a5c3d8b4390976d25 to your computer and use it in GitHub Desktop.
JMS Serializer wrapper which sets promoted properties, validates deserialized data, and checks uninitialized properties
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App\Serializer\Exception; | |
use Symfony\Component\HttpFoundation\Response; | |
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; | |
use Symfony\Component\Validator\ConstraintViolationInterface; | |
#[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY)] | |
class DeserializationException extends \Exception | |
{ | |
public function __construct( | |
string $message = "", | |
int $code = 422, | |
?\Throwable $previous = null, | |
/** @var list<ConstraintViolationInterface> */ | |
private array $validationErrors = [], | |
) { | |
parent::__construct($message, $code, $previous); | |
} | |
/** | |
* @return list<ConstraintViolationInterface> | |
*/ | |
public function getValidationErrors(): array | |
{ | |
return $this->validationErrors; | |
} | |
/** | |
* @return list<string> | |
*/ | |
public function getFormattedValidationErrors(): array | |
{ | |
return \array_map( | |
fn (ConstraintViolationInterface $violation) => | |
$violation->getPropertyPath() === '' | |
? (string) $violation->getMessage() | |
: \rtrim((string) $violation->getMessage(), '.').' ('.$violation->getPropertyPath().')', | |
$this->getValidationErrors() | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App\Serializer\Service; | |
use App\Serializer\Exception\DeserializationException; | |
use JMS\Serializer\ArrayTransformerInterface; | |
use JMS\Serializer\DeserializationContext; | |
use JMS\Serializer\SerializationContext; | |
use JMS\Serializer\SerializerInterface; | |
use Psr\Log\LoggerInterface; | |
use Symfony\Component\HttpKernel\KernelInterface; | |
use Symfony\Component\Validator\ConstraintViolation; | |
use Symfony\Component\Validator\ConstraintViolationInterface; | |
use Symfony\Component\Validator\Validator\ValidatorInterface; | |
class MyJMSSerializer implements MyJMSSerializerInterface | |
{ | |
public function __construct( | |
private readonly SerializerInterface&ArrayTransformerInterface $serializer, | |
private readonly ValidatorInterface $validator, | |
private readonly KernelInterface $kernel, | |
private readonly ?LoggerInterface $logger, | |
) { | |
} | |
public function deserializeWithoutValidation(string $data, string $type, string $format = 'json', ?DeserializationContext $context = null): mixed | |
{ | |
$deserializedItem = $this->serializer->deserialize($data, $type, $format, $context); | |
$this->setDefaultValues($deserializedItem); | |
return $deserializedItem; | |
} | |
public function deserialize(string $data, string $type, string $format = 'json', ?DeserializationContext $context = null): mixed | |
{ | |
$deserializedItem = $this->deserializeWithoutValidation($data, $type, $format, $context); | |
$this->validate($deserializedItem); | |
return $deserializedItem; | |
} | |
public function serialize(mixed $data, string $format = 'json', ?SerializationContext $context = null, ?string $type = null): string | |
{ | |
return $this->serializer->serialize($data, $format, $context, $type); | |
} | |
public function fromArrayWithoutValidation(array $data, string $type, ?DeserializationContext $context = null): mixed | |
{ | |
$deserializedItem = $this->serializer->fromArray($data, $type, $context); | |
$this->setDefaultValues($deserializedItem); | |
return $deserializedItem; | |
} | |
public function fromArray(array $data, string $type, ?DeserializationContext $context = null): mixed | |
{ | |
$deserializedItem = $this->fromArrayWithoutValidation($data, $type, $context); | |
$this->validate($deserializedItem); | |
return $deserializedItem; | |
} | |
public function toArray(mixed $data, ?SerializationContext $context = null, ?string $type = null): array | |
{ | |
return $this->serializer->toArray($data, $context, $type); | |
} | |
/** | |
* @throws DeserializationException | |
*/ | |
private function validate(mixed $deserializedItem): void | |
{ | |
$validationErrors = []; | |
$initializationErrors = $this->checkInitialization($deserializedItem); | |
if ($this->kernel->getEnvironment() !== 'prod') // throw on initialization error only in NON-prod environment | |
{ | |
\array_push($validationErrors, ...$initializationErrors); | |
} | |
elseif (\count($initializationErrors) > 0) | |
{ | |
$message = $this->getMessageForErrors($initializationErrors); | |
$this->logger?->warning($message); | |
} | |
if (\is_object($deserializedItem) || \is_array($deserializedItem)) | |
{ | |
$validatorErrors = $this->validator->validate($deserializedItem); | |
\array_push($validationErrors, ...$validatorErrors); | |
} | |
$this->evaluateValidity($validationErrors); | |
} | |
/** | |
* @param list<ConstraintViolationInterface> $validationErrors | |
* | |
* @throws DeserializationException | |
*/ | |
private function evaluateValidity(array $validationErrors): void | |
{ | |
if (\count($validationErrors) === 0) | |
{ | |
return; | |
} | |
$message = $this->getMessageForErrors($validationErrors); | |
$this->logger?->error($message); | |
throw new DeserializationException($message, validationErrors: $validationErrors); | |
} | |
/** | |
* @return list<ConstraintViolationInterface> | |
*/ | |
private function checkInitialization(mixed $deserializedItem): array | |
{ | |
$initializationErrors = []; | |
if (\is_array($deserializedItem)) | |
{ | |
foreach ($deserializedItem as $item) | |
{ | |
if (!\is_object($item)) // array should contain children of the same type - if the first one is not object we can break the loop | |
{ | |
break; | |
} | |
$errors = $this->checkObjectInitialization($item); | |
\array_push($initializationErrors, ...$errors); | |
} | |
} | |
elseif (\is_object($deserializedItem)) | |
{ | |
$initializationErrors = $this->checkObjectInitialization($deserializedItem); | |
} | |
return $initializationErrors; | |
} | |
/** | |
* @return list<ConstraintViolationInterface> | |
*/ | |
private function checkObjectInitialization(object $object): array | |
{ | |
$reflectionObject = new \ReflectionObject($object); | |
$reflectionProperties = $reflectionObject->getProperties(); | |
$initializationErrors = []; | |
foreach ($reflectionProperties as $reflectionProperty) | |
{ | |
$reflectionType = $reflectionProperty->getType(); | |
if ($reflectionType instanceof \ReflectionNamedType && $reflectionProperty->isInitialized($object)) | |
{ | |
if (in_array($reflectionType->getName(), ['array', 'iterable'], true)) | |
{ | |
$errors = $this->checkArrayInitialization($reflectionProperty, $object); | |
\array_push($initializationErrors, ...$errors); | |
} | |
elseif (\class_exists($reflectionType->getName())) | |
{ | |
$item = $reflectionProperty->getValue($object); | |
if ($item !== null) | |
{ | |
$errors = $this->checkObjectInitialization($item); | |
\array_push($initializationErrors, ...$errors); | |
} | |
} | |
} | |
if (!$reflectionProperty->isInitialized($object)) | |
{ | |
$initializationErrors[] = new ConstraintViolation( | |
message: "Property '{$reflectionProperty->getName()}' of class '{$reflectionObject->getName()}' was not initialized during deserialization.", | |
messageTemplate: '', | |
parameters: [], | |
root: '', | |
propertyPath: null, | |
invalidValue: null, | |
); | |
} | |
} | |
return $initializationErrors; | |
} | |
/** | |
* @return list<ConstraintViolationInterface> | |
*/ | |
private function checkArrayInitialization(\ReflectionProperty $reflectionProperty, object $object): array | |
{ | |
$value = $reflectionProperty->getValue($object); | |
if (!\is_iterable($value)) | |
{ | |
return []; | |
} | |
$initializationErrors = []; | |
foreach ($value as $item) | |
{ | |
if (!\is_object($item)) // array should contain children of the same type - if the first one is not object we can break the loop | |
{ | |
break; | |
} | |
$errors = $this->checkObjectInitialization($item); | |
\array_push($initializationErrors, ...$errors); | |
} | |
return $initializationErrors; | |
} | |
private function setDefaultValues(mixed $deserializedItem): void | |
{ | |
if (\is_array($deserializedItem)) | |
{ | |
foreach ($deserializedItem as $item) | |
{ | |
if (!\is_object($item)) // array should contain children of the same type - if the first one is not object we can break the loop | |
{ | |
break; | |
} | |
$this->setDefaultValuesInObject($item); | |
} | |
} | |
elseif (\is_object($deserializedItem)) | |
{ | |
$this->setDefaultValuesInObject($deserializedItem); | |
} | |
} | |
private function setDefaultValuesInObject(object $object): void | |
{ | |
$reflectionObject = new \ReflectionObject($object); | |
$reflectionProperties = $reflectionObject->getProperties(); | |
$constructor = $reflectionObject->getConstructor(); | |
if ($constructor === null) | |
{ | |
return; | |
} | |
$defaultValueParameters = []; | |
foreach ($constructor->getParameters() as $parameter) | |
{ | |
if ($parameter->isDefaultValueAvailable()) | |
{ | |
$defaultValueParameters[$parameter->getName()] = $parameter->getDefaultValue(); | |
} | |
} | |
foreach ($reflectionProperties as $reflectionProperty) | |
{ | |
$propertyName = $reflectionProperty->getName(); | |
$reflectionType = $reflectionProperty->getType(); | |
if ($reflectionType instanceof \ReflectionNamedType && $reflectionProperty->isInitialized($object)) | |
{ | |
if (in_array($reflectionType->getName(), ['array', 'iterable'], true)) | |
{ | |
$this->setDefaultValuesInArray($reflectionProperty, $object); | |
} | |
elseif (\class_exists($reflectionType->getName())) | |
{ | |
$item = $reflectionProperty->getValue($object); | |
if ($item !== null) | |
{ | |
$this->setDefaultValuesInObject($item); | |
} | |
} | |
} | |
if ($reflectionProperty->isPromoted() && \array_key_exists($propertyName, $defaultValueParameters) && !$reflectionProperty->isInitialized($object)) | |
{ | |
$reflectionProperty->setAccessible(true); | |
$reflectionProperty->setValue($object, $defaultValueParameters[$propertyName]); | |
$reflectionProperty->setAccessible(false); | |
} | |
} | |
} | |
private function setDefaultValuesInArray(\ReflectionProperty $reflectionProperty, object $object): void | |
{ | |
$value = $reflectionProperty->getValue($object); | |
if (!\is_iterable($value)) | |
{ | |
return; | |
} | |
foreach ($value as $item) | |
{ | |
if (!\is_object($item)) // array should contain children of the same type - if the first one is not object we can break the loop | |
{ | |
break; | |
} | |
$this->setDefaultValuesInObject($item); | |
} | |
} | |
/** | |
* @param list<ConstraintViolationInterface> $validationErrors | |
*/ | |
private function getMessageForErrors(array $validationErrors): string | |
{ | |
return 'Validation of deserialized JSON failed:'.\PHP_EOL.\implode( | |
\PHP_EOL, | |
\array_map( | |
static fn (ConstraintViolationInterface $violation) => $violation->getPropertyPath() === '' | |
? (string) $violation->getMessage() | |
: \rtrim((string) $violation->getMessage(), '.').' ('.$violation->getPropertyPath().')', | |
$validationErrors | |
) | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App\Serializer\Service; | |
use App\Serializer\Exception\DeserializationException; | |
use JMS\Serializer\ArrayTransformerInterface; | |
use JMS\Serializer\DeserializationContext; | |
use JMS\Serializer\SerializationContext; | |
use JMS\Serializer\SerializerInterface; | |
interface MyJMSSerializerInterface extends SerializerInterface, ArrayTransformerInterface | |
{ | |
/** | |
* @template T of object | |
* @phpstan-return ($type is class-string<T> ? T : mixed) | |
*/ | |
public function deserializeWithoutValidation(string $data, string $type, string $format = 'json', ?DeserializationContext $context = null): mixed; | |
/** | |
* @template T of object | |
* @phpstan-return ($type is class-string<T> ? T : mixed) | |
* @throws DeserializationException | |
*/ | |
public function deserialize(string $data, string $type, string $format = 'json', ?DeserializationContext $context = null): mixed; | |
public function serialize(mixed $data, string $format = 'json', ?SerializationContext $context = null, ?string $type = null): string; | |
/** | |
* @template T of object | |
* @param array<mixed> $data | |
* @phpstan-return ($type is class-string<T> ? T : mixed) | |
*/ | |
public function fromArrayWithoutValidation(array $data, string $type, ?DeserializationContext $context = null): mixed; | |
/** | |
* @template T of object | |
* @param array<mixed> $data | |
* @phpstan-return ($type is class-string<T> ? T : mixed) | |
* | |
* @throws DeserializationException | |
*/ | |
public function fromArray(array $data, string $type, ?DeserializationContext $context = null): mixed; | |
/** | |
* @return array<mixed> | |
*/ | |
public function toArray(mixed $data, ?SerializationContext $context = null, ?string $type = null): array; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
services: | |
_defaults: | |
autowire: true | |
autoconfigure: true | |
App\Serializer\Service\MyJMSSerializer: ~ | |
app_serializer: | |
alias: 'App\Serializer\Service\MyJMSSerializer' | |
App\Serializer\Service\MyJMSSerializerInterface: '@app_serializer' | |
JMS\Serializer\ArrayTransformerInterface&JMS\Serializer\SerializerInterface: '@jms_serializer.serializer' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment