Skip to content

Instantly share code, notes, and snippets.

@iKsSs
Last active February 21, 2024 14:20
Show Gist options
  • Save iKsSs/f048fd04f28c638a5c3d8b4390976d25 to your computer and use it in GitHub Desktop.
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
<?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()
);
}
}
<?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
)
);
}
}
<?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;
}
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