How to create initial Object with Symfony serialization

190 Views Asked by At

For example I have SimpleDto class

class SimpleDto implements GetPhoneInterface
{
    public string $name;

    public int $value;
}

And json

{"name":"Jane"}

When I serialize it, i get not valid object.

$serializer = self::getContainer()->get(SerializerInterface::class);
$dto = $serializer->deserialize($json, $dtoClass);

$dto has not initial variable $value.

How can I make sure that during deserialization an exception occurs that the class has unvalidated values?

If this cannot be solved using a serializer, maybe there is some way to check using a validator?

Upd: I tried to implement it like this, but the code behaves incorrectly. In addition, ObjectNormalizer is a final class. Maybe someone knows a better solution?

class InitialObjectNormalizer extends ObjectNormalizer
{
    // circle check
    private array $visitedObjects = [];
    private array $errors = [];

    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = [])
    {
        $data = parent::denormalize($data, $type, $format, $context);

        $this->handleObject($data);
        if (!empty($this->errors)) {
            throw new PartialDenormalizationException(null, $this->errors);
        }

        return $data;
    }

    public function handleObject($obj): void
    {
        if (in_array($obj, $this->visitedObjects, true)) {
            return;
        }
        $this->visitedObjects[] = $obj;

        $attributes = new ReflectionObject($obj);

        foreach ($attributes->getProperties() as $attribute) {
            if (!$attribute->isInitialized($obj)) {
                $this->errors[] = new NotNormalizableValueException('Attribute ' . $attribute->getName() . ' not initial',);
            } else {
                $value = $attribute->getValue($obj);
                if (is_array($value) || is_object($value)) {
                    $this->handleObject($value);
                }
            }
        }
    }
}
4

There are 4 best solutions below

1
Christoph On

Try rewriting your DTO using a constructor:

class SimpleDto implements GetPhoneInterface
{
    public function __construct(public string $name, public int $value) {}
}

The standard ObjectNormalizer will now throw a MissingConstructorArgumentsException which you can handle in your code.

If you can not rewrite the DTO, you can implement a custom denormalizer and check whether all required fields are present. This is a very basic example, adapt to your own need.

final class SimpleDtoDenormalizer implements DenormalizerInterface
{
    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
    {
        if (!is_array($data)) {
            throw new \InvalidArgumentException('Expected an array, got ' . get_debug_type($data));
        }

        $dto = new SimpleDto();
        $dto->name = $data['name'] ?? throw new \InvalidArgumentException('Missing "name"');
        $dto->value = $data['value'] ?? throw new \InvalidArgumentException('Missing "value"');

        return $dto;
    }

    public function supportsDenormalization(
        mixed $data,
        string $type,
        ?string $format = null,
        array $context = []
    ): bool {
        return $type === SimpleDto::class;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [
            SimpleDto::class => true
        ];
    }
}

You can find more about custom denormalizers in the Symfony documentation.

0
Olivier On

The Serializer class is not final, so you can extend it and override the denormalize() method:

require('vendor/autoload.php');

use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Exception\RuntimeException;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;

class CheckingSerializer extends Serializer
{
    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
    {
        $obj = parent::denormalize($data, $type, $format, $context);
        $ref = new \ReflectionObject($obj);
        foreach($ref->getProperties() as $property)
        {
            if(!$property->isInitialized($obj))
                throw new RuntimeException(sprintf('Missing attribute when denormalizing to %s type: %s', $type, $property->getName()));
        }
        return $obj;
    }
}

class SimpleDto
{
    public string $name;
    public int $value;
}

$json = '{"name":"Jane"}';

$serializer = new CheckingSerializer([new ObjectNormalizer()], [new JsonEncoder()]);
try
{
    $dto = $serializer->deserialize($json, SimpleDto::class, 'json');
}
catch(\Exception $ex)
{
    echo $ex->getMessage();
}

Output:

Missing attribute when denormalizing to SimpleDto type: value
1
cetver On
class Dummy {
    public string $foo = 'initialized';
    public string $bar; // uninitialized
}

$normalizer = new ObjectNormalizer();
$result = $normalizer->normalize(
    new Dummy(), 
    'json', 
    [AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => false]
);
// throws Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException as normalizer cannot read uninitialized properties

1
santiagourregobotero On

It is recommended to rewrite the DTO with a constructor applying the required parameters. If rewriting is not possible, implement a custom denormalization tool. Following approach ensures strict validation during deserialization.

final class SimpleDtoDenormalizer implements DenormalizerInterface
{
    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
    {
        if (!is_array($data)) {
            throw new \InvalidArgumentException('Expected an array, got ' . get_debug_type($data));
        }

        $dto = new SimpleDto();
        $dto->name = $data['name'] ?? throw new \InvalidArgumentException('Missing "name"');
        $dto->value = $data['value'] ?? throw new \InvalidArgumentException('Missing "value"');

        return $dto;
    }

    // Other methods as per Symfony documentation
}