Create a custom JMS Serializer handler for mapping values

php, jmsserializer, api, symfony

In the context of my article Create a custom Symfony Normalizer for mapping values, I also wanted to see if a custom normaliser could be implemented for the JMS Serializer, which I also use in many projects. For the JMS Serializer this is a handler.

Create the handler

For the custom handler, the interface SubscribingHandlerInterface must be implemented. In the method getSubscribingMethods() the methods for the direction and formats are defined.

<?php declare(strict_types=1);

namespace App\Handler;

use JMS\Serializer\GraphNavigatorInterface;
use JMS\Serializer\Handler\SubscribingHandlerInterface;
use JMS\Serializer\SerializationContext;
use JMS\Serializer\Visitor\DeserializationVisitorInterface;
use JMS\Serializer\Visitor\SerializationVisitorInterface;

class MappingTableHandler implements SubscribingHandlerInterface
{
    public const HANDLER_TYPE = 'MappingTable';

    public static function getSubscribingMethods(): array
    {
        $methods = [];

        foreach (['json', 'xml'] as $format) {
            $methods[] = [
                'type' => self::HANDLER_TYPE,
                'format' => $format,
                'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION,
                'method' => 'serialize',
            ];

            $methods[] = [
                'type' => self::HANDLER_TYPE,
                'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION,
                'format' => $format,
                'method' => 'deserialize',
            ];
        }

        return $methods;
    }

    public function normalize(
        SerializationVisitorInterface $visitor,
        string|bool|null $value,
        array $type,
        SerializationContext $context
    ): ?string
    {
        $mappingTable = $this->getMappingTable($type);

        foreach ($mappingTable as $mKey => $mValue) {
            if ($value === $mValue) {
                return (string)$mKey; // Force string
            }
        }
        return null;
    }

    public function denormalize(
        DeserializationVisitorInterface $visitor,
        $value,
        array $type
    ): mixed
    {
        $mappingTable = $this->getMappingTable($type);

        foreach ($mappingTable as $mKey => $mValue) {
            if ((string)$value === (string)$mKey) {
                return $mValue;
            }
        }
        return null;
    }

    private function getMappingTable(array $type): array
    {
        $mappingTable = [];

        if (!isset($type['params'][0])) {
            throw new \InvalidArgumentException('mapping_table param not defined');
        }

        if ($array = json_decode($type['params'][0], true)) {
            $mappingTable = $array;
        }

        return $mappingTable;
    }
}

Define the FieldID and MappingTable

The FieldID is defined with the attribute #[SerializedName].

The #[Type] attribute is used to define the MappingTable on the $salutation and $marketingInformation properties.

The MappingTables must be noted as a string.

Here is an example with JSON as MappingTable.

<?php declare(strict_types=1);

namespace App\Dto;

use JMS\Serializer\Annotation\SerializedName;
use JMS\Serializer\Annotation\Type;

class ContactDto
{
    #[SerializedName('1')]
    #[Type('string')]
    private ?string $firstname = null;

    #[SerializedName('2')]
    #[Type('string')]
    private ?string $lastname = null;

    #[SerializedName('3')]
    #[Type('string')]
    private ?string $email = null;

    #[SerializedName('4')]
    #[Type("DateTime<'Y-m-d'>")]
    private ?\DateTimeInterface $birthdate = null;

    #[SerializedName('46')]
    #[Type("MappingTable<'{\"1\": \"MALE\", \"2\": \"FEMALE\", \"6\": \"DIVERS\"}'>")]
    private ?string $salutation = null;

    #[SerializedName('100674')]
    #[Type("MappingTable<'{\"1\": true, \"2\": false}'>")]
    private ?bool $marketingInformation = null;

    /* getter and setter */
}

I find the notation of masked JSON cumbersome and difficult to maintain.

Therefore, I have also implemented the possibility of notating a constant as a string in my implementation. It is still a string but easier to maintain.

In Symfony Forms, for example, we can use these constants.

I wrote a UnitTest that checks if the constants are available. Despite a very good IDE, it can happen that the string in Attribute #[Type] is not renamed when the ContactDto or Constants are renamed.

<?php declare(strict_types=1);

namespace App\Dto;

use JMS\Serializer\Annotation\SerializedName;
use JMS\Serializer\Annotation\Type;

class ContactDto
{
    /* other properties */
    
    public const SALUTATION  = ['1' => 'MALE', '2' => 'FEMALE', '3' => 'DIVERS'];

    public const MARKETING_INFORMATION  = ['1' => true, '2' => false];

    #[SerializedName('46')]
    #[Type("MappingTable<'App\Dto\ContactDto::SALUTATION'>")]
    private ?string $salutation = null;

    #[SerializedName('100674')]
    #[Type("MappingTable<'App\Dto\ContactDto::MARKETING_INFORMATION'>")]
    private ?bool $marketingInformation = null;

    /* getter and setter */
}

Register the handler

Symfony JMSSerializerBundle

If you are using Symfony and the JMSSerializerBundle, the handler still needs to be registered if you are not using the default services.yaml configuration.

services:
    app.handler.mapping_handler:
        class: 'App\Handler\MappingTableHandler'
        tags:
            - { name: 'jms_serializer.handler', type: 'MappingTable', format: 'json' }

JMS Serializer standalone

If you use the JMS Serializer as a standalone library, you must register the handler as follows:

$serializer = SerializerBuilder::create()
    ->configureHandlers(function(HandlerRegistry $registry) {
        $registry->registerSubscribingHandler(
            new MappingTableHandler()
        );
    })
    ->build();

Normalize and denormalize

With the Serializer you can normalize (toArray()), denormalize (fromArray()), serialize (serialize()) and deserialize (deserialize())

I use toArray() and fromArray() because I need an array for the API client:

<?php declare(strict_types = 1);

use JMS\Serializer\Serializer;

private $serializer Serializer

$contactDto = new ContactDto();
$contactDto->setSalutation('FEMALE');
$contactDto->setFirstname('Jane');
$contactDto->setLastname('Doe');
$contactDto->setEmail('jane.doe@example.com');
$contactDto->setBirthdate(new \DateTime('1989-11-09'));
$contactDto->setMarketingInformation(true);

// Normalize
$fields = $this->serializer->toArray($contactDto);
/*
    Array
    (
        [1] => Jane
        [2] => Doe
        [3] => jane.doe@example.com
        [4] => 1989-11-09
        [46] => FEMALE
        [100674] => true
    )
*/

// Denormalize
$contactDto = $this->serializer->fromArray($fields, ContactDto::class);
/*
    App\Dto\ContactDto Object
    (
        [firstname:App\Dto\ContactDto:private] => Jane
        [lastname:App\Dto\ContactDto:private] => Doe
        [email:App\Dto\ContactDto:private] => jane.doe@example.com
        [birthdate:App\Dto\ContactDto:private] => DateTime Object
            (
                [date] => 1989-11-09 15:23:49.000000
                [timezone_type] => 3
                [timezone] => UTC
            )

        [salutation:App\Dto\ContactDto:private] => FEMALE
        [marketingInformation:App\Dto\ContactDto:private] => 1
    )
*/

Links

Full JMS Serializer handler on github

Updates

  • Series name defined (May 5th 2023)
  • Update series name (May 8th 2023)
  • Fix broken links (Dez 30th 2023)
  • Change GitHub Repository URL (Sep 4th 2024)