Search code examples
symfonyjson-deserialization

Handling imporper data during deserialization when using Symfony Serializer Component


I am new to the Symfony serializer component. I am trying to properly deserialize a JSON body to the following DTO:

class PostDTO
{
    /** @var string */
    private $name;

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @param string $name
     */
    public function setName(string $name): void
    {
        $this->name = $name;
    }
}

The controller method as follows:

/**
 * @Route (path="", methods={"POST"}, name="new_post")
 * @param Request $request
 * @return Response
 */
public function create(Request $request): Response
{
    $model = $this->serializer->deserialize($request->getContent(), PostDTO::class, 'json');
    // call the service with the model
    return new JsonResponse();
}

My problem is that I wanted to handle business-validation after the body was deserialized. However, if i specify an invalid value for the name, such as false or [], the deserialization will fail with an exception: Symfony\Component\Serializer\Exception\NotNormalizableValueException: "The type of the "name" attribute for class "App\Service\PostDTO" must be one of "string" ("array" given)..

I do understand that it is because I intentionally set "name": []. However, I was looking for a way to set the fields to a default value or even perform some validation pre-deserialization.


Solution

  • I have found the proper way to handle this. That exception was thrown because the serializer was not able to create the PostDTO class using the invalid payload I have provided.

    To handle this, I have created my custom denormalizer which kicks in only for this particular class. To do this, I have implemented the DenormalizerInterface like so:

    use App\Service\PostDTO;
    use Symfony\Component\Serializer\Exception\ExceptionInterface;
    use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
    use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
    
    class PostDTODeserializer implements DenormalizerInterface
    {
        /** @var ObjectNormalizer */
        private $normalizer;
    
        /**
         * PostDTODeserializer constructor.
         * @param ObjectNormalizer $normalizer
         */
        public function __construct(ObjectNormalizer $normalizer)
        {
            $this->normalizer = $normalizer;
        }
    
        public function denormalize($data, string $type, string $format = null, array $context = [])
        {
            return $type === PostDTO::class;
        }
    
        /**
         * @param mixed $data
         * @param string $type
         * @param string|null $format
         * @return array|bool|object
         * @throws ExceptionInterface
         */
        public function supportsDenormalization($data, string $type, string $format = null)
        {
            // validate the array which will be normalized (you should write your validator and inject it through the constructor)
            if (!is_string($data['name'])) {
                // normally you would throw an exception and leverage the `ErrorController` functionality
    
                // do something
            }
    
            // convert the array to the object
            return $this->normalizer->denormalize($data, $type, $format);
        }
    }
    

    If you want to access the context array, you can implement the DenormalizerAwareInterface. Normally, you would create your custom validation and inject it into this denormalizer and validate the $data array.

    Please not that I have injected the ObjectNormalizer here so that when the data successfully passed the validation, I can still construct the PostDTO using the $data.

    PS: in my case, the autowiring has automatically registered my custom denormalizer. If yours is not autowired automatically, go to services.yaml and add the following lines:

     App\Serializer\PostDTODeserializer:
            tags: ['serializer.normalizer']
    

    (I have tagged the implementation with serializer.normalizer so as it is recognized during the deserialization pipeline)