DEV Community

AL
AL

Posted on • Updated on

Create a custom Symfony Normalizer for mapping values

The requirements and history

The task was to integrate a CRM (Emarsys) into the e-commerce platform.

The CRM provides predefined system fields and field types. Custom fields can also be created. The label for the field and the field name can be assigned by the user. The FieldID for the field and the FieldValueID for the choices are assigned by the CRM when the field is created. The field names are not relevant when interacting with the Emarsys API.

Here are some examples:

Label Fieldname FieldID Type: FieldValue => FieldValueID
Salutation salutation 46 Single-choice:
Male => 1
Female => 2
Divers => 6
Firstname firstName 1 Short text
Lastname lastName 2 Short text
Email email 3 Long text
Birthdate birthday 4 Date: YYYY-MM-DD
Marketing Information * marketing_information 100674 Single-choice:
Yes* => 1
No* => 2

*custom

For a creation of a contact in CRM, with this data

  • Salutation: Female
  • Name: Jane Doe
  • Email: jane.doe@example.com
  • Birthday: 1989-11-09
  • Marketing Information: Yes

the payload must be:

{
    "1": "Jane",
    "2": "Doe",
    "3": "john.doe@example.com",
    "4": "1989-11-09",
    "46": "2",
    "100674": "1"
}
Enter fullscreen mode Exit fullscreen mode

The response body, for example, when searching for a contact, has the same structure. However, all the fields of the contact are provided.

Built-in mapping in the API-Client

From the beginning, the snowcap/emarsys package has been used as the API client. This package is good, extensible and provides a simple mapping (Fieldname <-> FieldId). The system fields are already stored in the package.

The built-in mapping is sufficient if you have only a few custom fields. However, if you have 30 custom fields and multiple accounts in use, it becomes difficult to maintain and manage.

Use a custom Attribute

So it would be better to configure the FieldID and the mapping to the FieldValueID directly on the properties. So I implemented a custom attribute and a simple PropertyReader and PropertyWriter.

I have taken the solution with the custom attribute out of the project and made it available on github.

Using the Symfony serializer component and creating a custom normalizer

I wanted to see if it was possible to replace the custom attribute with features provided by the Symfony serializer component.

FieldIDs with attribute #[SerializedName]

First the FieldIDs. This is easily done with the #[SerializedName] attribute.

<?php

namespace App\Dto;

use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;

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

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

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

    #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
    #[SerializedName("4")]
    private ?\DateTimeInterface $birthdate = null;

    #[SerializedName("46")]
    private ?string $salutation = null;

    #[SerializedName("100674")]
    private ?boolean $marketingInformation = null;

    /* getter and setter */
}
Enter fullscreen mode Exit fullscreen mode

Normalize/Denormalize and Serialize/Deserialize

With the Serializer you can normalize, denormalize, serialize and deserialize, encode und decode. With serialize(), the normalize() and encode() are called, and with a deserialize(), the denormalize() and decode() are called.

I use normalize() and denormalize() because I need an array for the API client:

<?php

use Symfony\Component\Serializer\Serializer;

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

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

// Denormalize
$contactDto = $this->serializer->denormalize($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] => DateTimeImmutable Object
            (
                [date] => 1989-11-09 13:54:11.000000
                [timezone_type] => 3
                [timezone] => UTC
            )

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

This cannot yet be sent to CRM via the API because CRM cannot handle the FieldValue FEMALE for $salutation and true for $marketingInformation.

For this I create a custom normalizer and denormalizer.

⚠️ Note: #[SerializedName] with a number as name in Symfony 6.2

Denormalize FieldIDs does not work in Symfony 6.2. This has to do with an array_merge() when denormalizing in the symfony/serializer package in version 6.2. I have created a pull request for a fix. The pull request has already been reviewed. I guess the fix will be merged soon.

✅ This bug is fixed in version 6.3.5.

Create a custom Normalizer

The Symfony serializer component provides several built-in normalizers for transforming data. For example, there is the DateTimeNormalizer to transform a DateTime object into a date format and a date format into a DateTime object.

For my case, I need a normalizer that transforms FEMALE into FieldValueID 2 for $salutation and a denormalizer that transforms FieldValueID 2 into FEMALE.

To do this, I create the MappingTableNormalizer (Normalizer and Denormalizer), which implements the NormalizerInterface and DenormalizerInterface interfaces.

The serializer calls the supportsNormalization() and supportsDenormalization() functions of all registered normalizers and denormalizers to determine which normalizer or denormalizer to use to transform an object.

A custom type must be created to ensure that the MappingTableNormalizer is applied to the $salutation and $marketingInformation properties.

I have created StringValue ($salutation) and BooleanValue ($marketingInformation). The supportsNormalization() and supportsDenormalization() methods check whether the MappingTableNormalizer is responsible for the object.

Here is the full implementation of the MappingTableNormalizer:

<?php

namespace App\Normalizer;

use App\Normalizer\Value\BooleanValue;
use App\Normalizer\Value\StringValue;
use App\Normalizer\Value\ValueInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class MappingTableNormalizer implements NormalizerInterface, DenormalizerInterface
{
    public const TABLE = 'mapping_table';

    private const SUPPORTED_TYPES = [
        StringValue::class,
        BooleanValue::class
    ];

    public function normalize(mixed $object, string $format = null, array $context = []): ?string
    {
        $mappingTable = $this->getMappingTable($context);

        $key = array_search($object->getValue(), $mappingTable);
        if($key) {
            return (string)$key; // Force string
        }

        return null;
    }

    public function supportsNormalization(mixed $data, string $format = null): bool
    {
        return $data instanceof ValueInterface;
    }

    public function denormalize($data, $type, $format = null, array $context = array()): mixed
    {
        $mappingTable = $this->getMappingTable($context);

        foreach ($mappingTable as $key => $value) {
            if ((string)$key === $data) {
                return new $type($value);
            }
        }

        return new $type(null);
    }

    public function supportsDenormalization($data, $type, $format = null): bool
    {
        return in_array($type, self::SUPPORTED_TYPES);
    }

    private function getMappingTable(array $context): array
    {
        if (!isset($context[self::TABLE]) || !is_array($context[self::TABLE])) {
            throw new \InvalidArgumentException('mapping_table not defined');
        }

        return $context[self::TABLE];
    }
}

Enter fullscreen mode Exit fullscreen mode

Attribute #[Context] for the MappingTable

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

<?php

namespace App\Dto;

use App\Normalizer\MappingTableNormalizer;
use App\Normalizer\Value\BooleanValue;
use App\Normalizer\Value\StringValue;
use Symfony\Component\Serializer\Annotation\Context;
use Symfony\Component\Serializer\Annotation\SerializedName;

class ContactDto
{
    // Other properties

    #[Context([MappingTableNormalizer::TABLE => ['1' => 'MALE', '2' => 'FEMALE', '6' => 'DIVERS']])]
    #[SerializedName("46")]
    private ?StringValue $salutation = null;

    #[Context([MappingTableNormalizer::TABLE => ['1' => true, '2' => false]])]
    #[SerializedName("100674")]
    private ?BooleanValue $marketingInformation = null;

    // Other getter and setter

    public function getSalutation(): ?StringValue
    {
        return $this->salutation;
    }

    public function setSalutation(?StringValue $salutation): void
    {
        $this->salutation = $salutation;
    }

    public function getMarketingInformation(): ?BooleanValue
    {
        return $this->marketingInformation;
    }

    public function setMarketingInformation(?BooleanValue $marketingInformation): void
    {
        $this->marketingInformation = $marketingInformation;
    }
}
Enter fullscreen mode Exit fullscreen mode

Register the custom normalizer

The normalizer still needs to be registered if you are not using the default services.yaml configuration.

services:
  serializer.normalizer.mapping_table_normalizer:
    class: 'App\Normalizer\MappingTableNormalizer'
    tags: ['serializer.normalizer']
Enter fullscreen mode Exit fullscreen mode

Skip null values

Then there should be no normalization of properties that are null. These properties will also not be included in the normalized array. Fields that are not in the payload will not be updated in CRM.

framework:
  serializer:
    default_context: '%serializer_default_context%'

parameters:
  serializer_default_context:
    skip_null_values: true
Enter fullscreen mode Exit fullscreen mode

Normalize the ContactDto to an array

<?php

use Snowcap\Emarsys\Client;
use Symfony\Component\Serializer\Serializer;

private Client $client;
private Serializer $serializer;

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

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

// Create Contact (API-Request)
$this->client->createContact($fields)
Enter fullscreen mode Exit fullscreen mode

Denormalize the array to a ContactDto

<?php

use Snowcap\Emarsys\Client;
use Symfony\Component\Serializer\Serializer;

private Client $client;
private Serializer $serializer;

// Get Contact (API-Request)
$response = $this->client->getContact(['3' => 'jane.doe@example.com']);
$fields = $response->getData()
/*
    Array
    (
        [1] => Jane
        [2] => Doe
        [3] => jane.doe@example.com
        [4] => 1989-11-09
        // other fields
        [46] => 2
        // other fields
        [100674] => 1
        // other fields
    )
*/

// Denormalize Array
$conatctDto = $this->serializer->denormalize($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] => DateTimeImmutable Object
            (
                [date] => 1989-11-09 13:54:11.000000
                [timezone_type] => 3
                [timezone] => UTC
            )

        [salutation:App\Dto\ContactDto:private] => App\Normalizer\Value\StringValue Object
            (
                [value:App\Normalizer\Value\StringValue:private] => FEMALE
            )

        [marketingInformation:App\Dto\ContactDto:private] => App\Normalizer\Value\BooleanValue Object
            (
                [value:App\Normalizer\Value\BooleanValue:private] => 1
            )

    )
*/
Enter fullscreen mode Exit fullscreen mode

Serializer default service configuration

The getters and setters for $salutation and $marketingInformation have StringValue and BooleanValue as the type.

This is because the ObjectNormalizer and ReflectionExtractor are enabled by default in symfony applications.

This ObjectNormalizer read and write in the object with the PropertyAccess component. This means that the ObjectNormalizer can access properties directly and through getters, setters, haters, issers, canners, adders and removers.

In the ReflectionExtractor (PropertyInfo component) it tries to get the type declaration from the mutator (set, add, remove), accessor (get, is, has, can) or constructor (in that order) based on the name of the property. If this is not possible, it will get the type from the property's type declaration.

So the ReflectionExtractor gets the type declaration from setSalutation(). If we were to define string as a type hint, $type for supportsDenormalization() would be of type string. Thus, we can no longer ensure that MappingTableNormalizer is applied to $salutation. This is because when the serialiser applies the normaliser to the properties, the property is not disclosed, just the value.

In my opinion, this default service configuration cannot be disabled or controlled by configuration parameters.

PropertyNormalizer and custom PropertyTypeExtractor

I am not fan of this Serializer default service configuration, because I would prefer that the getter and setter for $salutation and $marketingInformation does not have the ValueInterface as the type declaration.

Instead, I want the getters and setters to have the type declaration string or bool:

<?php

class ContactDto
{
    private ?StringValue $salutation = null;

    private ?BooleanValue $marketingInformation = null;

    public function getSalutation(): ?string
    {
        return $this->salutation?->getValue();
    }

    public function setSalutation(?string $salutation): void
    {
        $this->salutation = new StringValue($salutation);
    }

    public function isMarketingInformation(): ?bool
    {
        return $this->marketingInformation?->getValue();
    }

    public function setMarketingInformation(?bool $marketingInformation): void
    {
        $this->marketingInformation = new BooleanValue($marketingInformation);
    }
}
Enter fullscreen mode Exit fullscreen mode

Therefore I need the PropertyNormalizer to read and write directly on the properties and a custom PropertyTypeExtractor to read only the type declaration from the properties.

Serializer service configuration for my custom requirements

services:
    'App\Service\CrmSerializerService':
        arguments:
            - '@crm_serializer'

    crm_serializer:
        class: 'Symfony\Component\Serializer\Serializer'
        arguments:
            $normalizers:
                - '@serializer.normalizer.datetime'
                - '@app.normalizer.mapping_table_normalizer'
                - '@crm_serializer.property_normalizer'
            $encoders: []

    crm_serializer.property_normalizer:
        class: 'Symfony\Component\Serializer\Normalizer\PropertyNormalizer'
        arguments:
            $nameConverter: '@serializer.name_converter.metadata_aware'
            $propertyTypeExtractor: '@crm_serializer.reflection_extractor'
            $defaultContext: '%serializer_default_context%'

    crm_serializer.reflection_extractor:
        class: 'Symfony\Component\PropertyInfo\PropertyInfoExtractor'
        arguments:
            $typeExtractors:
                - '@app.service.property_info.property_type_extractor'

    app.service.property_info.property_type_extractor:
        class: 'App\Service\PropertyInfo\PropertyTypeExtractor'

    app.normalizer.mapping_table_normalizer:
        class: 'App\Normalizer\MappingTableNormalizer'   
Enter fullscreen mode Exit fullscreen mode

I use the PropertyNormalizer instead of ObjectNormalizer, because it only reads and writes the value from the property.

Instead of the ReflectionExtractor I use a custom PropertyTypeExtractor (PropertyTypeExtractorInterface) which reads the type declaration only from the property.

The AdvancedNameConverterInterface is also needed to convert the name of the property (salutation <-> 46).

The $defaultContext is the serializer configuration with skip null values.

Then I populate the serializer with the required objects:

  • Normalizer
    • DateTimeNormalizer
    • MappingTableNormalizer
    • PropertyNormalizer
  • Encoders
    • Encoders are not needed at all, because only normalization and denormalization are done.

For the service configuration I use named arguments, because the other arguments are taken care of by the autowire and the default serializer service configuration can be used here.

I also have an example as an custum serializer object (CrmSerializer) that you can look at on github.

<?php

use App\Dto\ContactDto;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;

class CrmSerializerService
{
    private Serializer $serializer; // Only Symfony Serializer

    public function __construct(SerializerInterface $serializer) {
        $this->serializer = $serializer;
    }

    public function normalize(ContactDto $contactDto): array
    {
        return $this->serializer->normalize($contactDto);
    }

    public function denormalize(array $data): ContactDto
    {
        return $this->serializer->denormalize($data, ContactDto::class);
    }
}

$contactDto = new ContactDto();
$contactDto->setSalutation('FEMALE');
/* other setter */
$contactDto->setMarketingInformation(true);

// Normalize ContactDto
$fields = $this->crmSerializerService->normalize($contactDto);
/*
    Array
    (
        [1] => Jane
        [2] => Doe
        [3] => jane.doe@example.com
        [4] => 1989-11-09
        [46] => 2
        [100674] => 1
    )
*/
Enter fullscreen mode Exit fullscreen mode

Conclusion

The Symfony Serializer component is a powerful tool. I have many ideas on how to improve and simplify existing implementations with custom normalisers, especially in existing projects.

Full examples

Updates

  • Series name defined (May 5th 2023)
  • Update series name (May 8th 2023)
  • Add anchor for a deeplink (May 7th 2023)
  • Add bugfix note. (Nov 10th 2023)
  • Fix broken links (Dez 30th 2023)

Top comments (0)