Skip to content

[Serializer] Add name_converter flag and use an array of name converters #35374

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed

Conversation

alanpoulain
Copy link
Contributor

Q A
Branch? master
Bug fix? no
New feature? yes
Deprecations? yes
Tickets N/A
License MIT
Doc PR To do

The aim of this PR is to bring the possibility to modify the name converter to use at runtime.

Imagine you use the classical serializer for your (de)normalization needs (if you use some DTOs for instance). You also need to call an API using snake case and you want to use the serializer too.

In the current state, you need to do something like this:

services:
    app.serializer.normalizer.object.snake_case:
        class: Symfony\Component\Serializer\Normalizer\ObjectNormalizer
        autoconfigure: false
        arguments:
            $nameConverter: '@serializer.name_converter.camel_case_to_snake_case'

    app.serializer.denormalizer.array.snake_case:
        class: Symfony\Component\Serializer\Normalizer\ArrayDenormalizer
        autoconfigure: false

    app.serializer.normalizer.datetime.snake_case:
        class: Symfony\Component\Serializer\Normalizer\DateTimeNormalizer
        autoconfigure: false

    app.serializer.encoder.json.snake_case:
        class: Symfony\Component\Serializer\Encoder\JsonEncoder
        autoconfigure: false

    app.serializer.snake_case:
        class: Symfony\Component\Serializer\Serializer
        autoconfigure: false
        arguments:
            - ['@app.serializer.normalizer.datetime.snake_case', '@app.serializer.normalizer.object.snake_case', '@app.serializer.denormalizer.array.snake_case']
            - ['@app.serializer.encoder.json.snake_case']

You have to reconfigure all the (de)normalizers because if you use the ones from Symfony in the declaration of your snake case serializer, they can use the snake case serializer instead of the classical one when you (de)normalize your data (mainly because of this:

if ($normalizer instanceof SerializerAwareInterface) {
$normalizer->setSerializer($this);
}
if ($normalizer instanceof DenormalizerAwareInterface) {
$normalizer->setDenormalizer($this);
}
if ($normalizer instanceof NormalizerAwareInterface) {
$normalizer->setNormalizer($this);
}
).

With this PR, you can just use the classical serializer with a flag given to the context:

$data = $this->normalizer->normalize($object, null, [
    AbstractNormalizer::NAME_CONVERTER => CamelCaseToSnakeCaseNameConverter::class,
]);

/**
* Sets the {@link ClassMetadataFactoryInterface} to use.
*/
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, array $defaultContext = [])
public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, /* array */ $nameConverter/*s*/ = [], array $defaultContext = [])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it's possible to change or deprecate the name of a constructor argument since it could be used by DI with a named argument.

$this->nameConverter = $nameConverter;
} else {
foreach ($nameConverter as $nameConverterInstance) {
$this->nameConverters[\get_class($nameConverterInstance)] = $nameConverterInstance;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it a good idea to use the FQCN for referencing a name converter?
We could also use the service id.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, it's not: it prevents passing two differently configured instances of the same class
instead, why not pass a service locator?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use a service locator too.
We would keep the name converter and we would add one more argument, am I right?
Or maybe we can replace the name converter with the service locator. I'm not sure to like it since it means not having a clear dependency between the normalizer and the name converter and having to use a default name converter service id.

$key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName;
$key = $paramName;
if (\count($this->nameConverters) > 0) {
$nameConverter = $this->nameConverters[$context[static::NAME_CONVERTER] ?? null] ?? reset($this->nameConverters);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the given name converter in context doesn't correspond to one of the name converters, the first name converter is used instead.
The user is not warned, but I think it's the right approach. WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an exception should be thrown instead to me
having a service locator would naturally throw a service-bot-found exception btw.

@alanpoulain
Copy link
Contributor Author

Low deps tests are failing in Travis but it seems logical to me (since the modified configuration of the serializer cannot be applied to an old version of the code).
Failures in AppVeyor doesn't seem related.

@nicolas-grekas nicolas-grekas added this to the next milestone Jan 20, 2020
Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you consider passing a name converter instance as a context option?

$this->nameConverter = $nameConverter;
} else {
foreach ($nameConverter as $nameConverterInstance) {
$this->nameConverters[\get_class($nameConverterInstance)] = $nameConverterInstance;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, it's not: it prevents passing two differently configured instances of the same class
instead, why not pass a service locator?

$key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName;
$key = $paramName;
if (\count($this->nameConverters) > 0) {
$nameConverter = $this->nameConverters[$context[static::NAME_CONVERTER] ?? null] ?? reset($this->nameConverters);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an exception should be thrown instead to me
having a service locator would naturally throw a service-bot-found exception btw.

@@ -323,7 +390,7 @@ protected function getNormalizerForMaxDepth(): NormalizerInterface
protected function getDenormalizerForObjectToPopulate(): DenormalizerInterface
{
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
$normalizer = new GetSetMethodNormalizer($classMetadataFactory, null, new PhpDocExtractor());
$normalizer = new GetSetMethodNormalizer($classMetadataFactory, [], new PhpDocExtractor());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null should still be accepted, no need to deprecate it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would you want to keep passing null? What would it mean?

Copy link
Member

@nicolas-grekas nicolas-grekas Jan 20, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary deprecations should be avoided, that's my point.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK 👍

Serializer
----------

* Deprecated passing a name converter directly to the second argument of the constructor of `AbstractNormalizer`, pass an array of name converters instead.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? it looks fine to me

@alanpoulain
Copy link
Contributor Author

I did consider passing an instance in the context but it would not seem clean to me. It has the advantage of not deprecating anything though.

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Jan 20, 2020

I did consider passing an instance in the context but it would not seem clean to me. It has the advantage of not deprecating anything though.

Actually it would be cleaner: the current approach requires some "long-distance" correlation between what is provided to the constructor and what string is passed in the context. That's quite fragile, with the same drawbacks as documented in "service locators are bad" articles.

But then, there is this cacheability consideration. To be investigated.

@dunglas
Copy link
Member

dunglas commented Jan 22, 2020

For such cases, instead of introducing such complexity, wouldn't it be better to use several Serializer instances? One instance to serialize your internal data structure, one other to deserialize the data coming from an external API?

I would like to have a factory mechanism to create as many serializers as needed in FrameworkBundle, as we do for Cache. It would also allow to improve the overall performance of the Serializer by registering less useless normalizers for a given instance.

@alanpoulain
Copy link
Contributor Author

@dunglas That's the issue here. Defining a specific serializer is cumbersome and can lead to many issues if not defined properly (the main issue being the necessity to redefine all the normalizers and encoders you need to use, see my explanation in the description of the PR).

Moreover I think it's not very clear for a newcomer to understand how changing the name converter can be done (the name converter is not defined in the serializer but in the object normalizer).

@dunglas
Copy link
Member

dunglas commented Feb 5, 2020

What you're trying to achieve here can already be done user-land by creating a "smart" name converter implementing the AdvancedNameConverterInterface. It can use a chain of responsibility pattern or something similar to select how to normalize the property names depending of the context.

Also, I've the feeling that it's not the good way to fix this issue. What we need is an easy way to create several serializer instances, not introducing unneeded complexity in the component.

I'm closing for now. Thanks for your PR anyway. Don't hesitate to reopen if I'm missing the point.

@krewetka
Copy link

@dunglas :

What we need is an easy way to create several serializer instances, not introducing unneeded complexity in the component.

is there maybe any way to achive it already now in newest version of symfony?

I also have use case with 2-3 separate serializers needed ( with different name converters) -> one for consuming one external API, another for another integration + another one for exposing our own API

fabpot added a commit that referenced this pull request Sep 19, 2024
This PR was squashed before being merged into the 7.2 branch.

Discussion
----------

[Serializer] Introduce named serializers

| Q             | A
| ------------- | ---
| Branch?       | 7.2
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

The idea behind this PR is to allow configuring multiple serializer instances with different default contexts, name converters, and sets of normalizers and encoders. This is useful when your application is communicating with multiple APIs, each with different rules. Similar ideas have been mentioned [before](#35374 (comment)).

```yaml
serializer:
  named_serializers:
    api1:
      name_converter: 'serializer.name_converter.camel_case_to_snake_case'
      default_context:
        enable_max_depth: true
    api2:
      default_context:
        enable_max_depth: false
```

The different serializers can be injected using named aliases:

```php
#[AsController]
class HomeController
{
    #[Route('/', name: 'app_home')]
    public function index(
        SerializerInterface $serializer,     // Default serializer
        SerializerInterface $api1Serializer, // api1 serializer
        #[Target('api2.serializer')]         // api2 serializer
        SerializerInterface $someName,
    ) {
        // ...
    }
}
```

Multiple normalizer/encoder instances with different arguments are created as child services of the default ones. ~~I also ensured that the same normalizer/encoder instances are reused between different serializers that have the same default context and name converter to minimize the number of child services created.~~

Custom normalizers/encoders can target specific serializers using the `serializer` tag attribute:

```yaml
get_set_method_normalizer:
    class: Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer
    autoconfigure: false # This is needed so that it's not included in the default serializer
    tags:
        # single serializer
        - serializer.normalizer: { serializer: 'api1' }
        # or multiple ones
        - serializer.normalizer: { serializer: [ 'api1', 'api2' ] }
        # use * to include the service in all serializers including the default one
        - serializer.normalizer: { serializer: '*' }
```

For BC reasons, not setting the `serializer` tag attribute is the same as setting it to the default one:

```yaml
    tags:
        - serializer.normalizer
        # same as
        - serializer.normalizer: { serializer: 'default' }
```

The profiler has been updated to support multiple serializer instances:

![image](https://github.com/symfony/symfony/assets/2445045/0468ccf3-8d5a-4c60-a639-09cfe918b163)

All normalizers/encoders are tagged with additional named serializer specific tags to help with debugging. To get the priority of normalizers/encoders for a certain serializer, use the `serializer.normalizer.<name>` or `serializer.encoder.<name>` tags:

```shell
$ bin/console debug:container --tag serializer.normalizer.default
Symfony Container Services Tagged with "serializer.normalizer.default" Tag
==========================================================================

 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------
  Service ID                                        priority   Class name
 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------
  serializer.denormalizer.unwrapping                1000       Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer
  serializer.normalizer.flatten_exception           -880       Symfony\Component\Messenger\Transport\Serialization\Normalizer\FlattenExceptionNormalizer
  ...
  serializer.denormalizer.array                     -990       Symfony\Component\Serializer\Normalizer\ArrayDenormalizer
  serializer.normalizer.object                      -1000      Symfony\Component\Serializer\Normalizer\ObjectNormalizer
 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------

$ bin/console debug:container --tag serializer.normalizer.api1

Symfony Container Services Tagged with "serializer.normalizer.api1" Tag
=======================================================================

 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------
  Service ID                                        priority   Class name
 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------
  serializer.denormalizer.unwrapping                1000       Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer
  get_set_method_normalizer                                    Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer
  serializer.normalizer.flatten_exception           -880       Symfony\Component\Messenger\Transport\Serialization\Normalizer\FlattenExceptionNormalizer
  ...
  serializer.denormalizer.array                     -990       Symfony\Component\Serializer\Normalizer\ArrayDenormalizer
  serializer.normalizer.object                      -1000      Symfony\Component\Serializer\Normalizer\ObjectNormalizer
 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------

$ bin/console debug:container --tag serializer.normalizer

Symfony Container Services Tagged with "serializer.normalizer" Tag
==================================================================

 ------------------------------------------------- ---------- ---------- ----------------- -------------------------------------------------------------------------------------------
  Service ID                                        standard   priority   serializer        Class name
 ------------------------------------------------- ---------- ---------- ----------------- -------------------------------------------------------------------------------------------
  serializer.denormalizer.unwrapping                1          1000                         Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer
  get_set_method_normalizer                                               ["api1","api2"]   Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer
  serializer.normalizer.flatten_exception           1          -880                         Symfony\Component\Messenger\Transport\Serialization\Normalizer\FlattenExceptionNormalizer
  ...
  serializer.denormalizer.array                     1          -990                         Symfony\Component\Serializer\Normalizer\ArrayDenormalizer
  serializer.normalizer.object                      1          -1000                        Symfony\Component\Serializer\Normalizer\ObjectNormalizer
 ------------------------------------------------- ---------- ---------- ----------------- -------------------------------------------------------------------------------------------
```

Since Symfony comes with some pre-registered normalizers and encoders, I added options to exclude those in case someone wants to use only custom ones:

```yaml
serializer:
  named_serializers:
    api1:
      include_built_in_normalizers: false
      include_built_in_encoders: true

      name_converter: 'serializer.name_converter.camel_case_to_snake_case'
      default_context:
        enable_max_depth: true
```

TBH, I have doubts about the usefulness of this, please let me know your thoughts.

I've split the PR into two commits to ease reviewing:

- the first commit only rearranges the `SerializerPass` without adding any features
- the second commit implements the feature

Commits
-------

29bd8ce [Serializer] Introduce named serializers
symfony-splitter pushed a commit to symfony/serializer that referenced this pull request Sep 19, 2024
This PR was squashed before being merged into the 7.2 branch.

Discussion
----------

[Serializer] Introduce named serializers

| Q             | A
| ------------- | ---
| Branch?       | 7.2
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

The idea behind this PR is to allow configuring multiple serializer instances with different default contexts, name converters, and sets of normalizers and encoders. This is useful when your application is communicating with multiple APIs, each with different rules. Similar ideas have been mentioned [before](symfony/symfony#35374 (comment)).

```yaml
serializer:
  named_serializers:
    api1:
      name_converter: 'serializer.name_converter.camel_case_to_snake_case'
      default_context:
        enable_max_depth: true
    api2:
      default_context:
        enable_max_depth: false
```

The different serializers can be injected using named aliases:

```php
#[AsController]
class HomeController
{
    #[Route('/', name: 'app_home')]
    public function index(
        SerializerInterface $serializer,     // Default serializer
        SerializerInterface $api1Serializer, // api1 serializer
        #[Target('api2.serializer')]         // api2 serializer
        SerializerInterface $someName,
    ) {
        // ...
    }
}
```

Multiple normalizer/encoder instances with different arguments are created as child services of the default ones. ~~I also ensured that the same normalizer/encoder instances are reused between different serializers that have the same default context and name converter to minimize the number of child services created.~~

Custom normalizers/encoders can target specific serializers using the `serializer` tag attribute:

```yaml
get_set_method_normalizer:
    class: Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer
    autoconfigure: false # This is needed so that it's not included in the default serializer
    tags:
        # single serializer
        - serializer.normalizer: { serializer: 'api1' }
        # or multiple ones
        - serializer.normalizer: { serializer: [ 'api1', 'api2' ] }
        # use * to include the service in all serializers including the default one
        - serializer.normalizer: { serializer: '*' }
```

For BC reasons, not setting the `serializer` tag attribute is the same as setting it to the default one:

```yaml
    tags:
        - serializer.normalizer
        # same as
        - serializer.normalizer: { serializer: 'default' }
```

The profiler has been updated to support multiple serializer instances:

![image](https://github.com/symfony/symfony/assets/2445045/0468ccf3-8d5a-4c60-a639-09cfe918b163)

All normalizers/encoders are tagged with additional named serializer specific tags to help with debugging. To get the priority of normalizers/encoders for a certain serializer, use the `serializer.normalizer.<name>` or `serializer.encoder.<name>` tags:

```shell
$ bin/console debug:container --tag serializer.normalizer.default
Symfony Container Services Tagged with "serializer.normalizer.default" Tag
==========================================================================

 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------
  Service ID                                        priority   Class name
 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------
  serializer.denormalizer.unwrapping                1000       Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer
  serializer.normalizer.flatten_exception           -880       Symfony\Component\Messenger\Transport\Serialization\Normalizer\FlattenExceptionNormalizer
  ...
  serializer.denormalizer.array                     -990       Symfony\Component\Serializer\Normalizer\ArrayDenormalizer
  serializer.normalizer.object                      -1000      Symfony\Component\Serializer\Normalizer\ObjectNormalizer
 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------

$ bin/console debug:container --tag serializer.normalizer.api1

Symfony Container Services Tagged with "serializer.normalizer.api1" Tag
=======================================================================

 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------
  Service ID                                        priority   Class name
 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------
  serializer.denormalizer.unwrapping                1000       Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer
  get_set_method_normalizer                                    Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer
  serializer.normalizer.flatten_exception           -880       Symfony\Component\Messenger\Transport\Serialization\Normalizer\FlattenExceptionNormalizer
  ...
  serializer.denormalizer.array                     -990       Symfony\Component\Serializer\Normalizer\ArrayDenormalizer
  serializer.normalizer.object                      -1000      Symfony\Component\Serializer\Normalizer\ObjectNormalizer
 ------------------------------------------------- ---------- -------------------------------------------------------------------------------------------

$ bin/console debug:container --tag serializer.normalizer

Symfony Container Services Tagged with "serializer.normalizer" Tag
==================================================================

 ------------------------------------------------- ---------- ---------- ----------------- -------------------------------------------------------------------------------------------
  Service ID                                        standard   priority   serializer        Class name
 ------------------------------------------------- ---------- ---------- ----------------- -------------------------------------------------------------------------------------------
  serializer.denormalizer.unwrapping                1          1000                         Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer
  get_set_method_normalizer                                               ["api1","api2"]   Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer
  serializer.normalizer.flatten_exception           1          -880                         Symfony\Component\Messenger\Transport\Serialization\Normalizer\FlattenExceptionNormalizer
  ...
  serializer.denormalizer.array                     1          -990                         Symfony\Component\Serializer\Normalizer\ArrayDenormalizer
  serializer.normalizer.object                      1          -1000                        Symfony\Component\Serializer\Normalizer\ObjectNormalizer
 ------------------------------------------------- ---------- ---------- ----------------- -------------------------------------------------------------------------------------------
```

Since Symfony comes with some pre-registered normalizers and encoders, I added options to exclude those in case someone wants to use only custom ones:

```yaml
serializer:
  named_serializers:
    api1:
      include_built_in_normalizers: false
      include_built_in_encoders: true

      name_converter: 'serializer.name_converter.camel_case_to_snake_case'
      default_context:
        enable_max_depth: true
```

TBH, I have doubts about the usefulness of this, please let me know your thoughts.

I've split the PR into two commits to ease reviewing:

- the first commit only rearranges the `SerializerPass` without adding any features
- the second commit implements the feature

Commits
-------

29bd8ce6c2 [Serializer] Introduce named serializers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants