Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Serializer] Default Context in Serializer isn't honoured #47012

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
schodemeiss opened this issue Jul 21, 2022 · 12 comments
Closed

[Serializer] Default Context in Serializer isn't honoured #47012

schodemeiss opened this issue Jul 21, 2022 · 12 comments

Comments

@schodemeiss
Copy link

schodemeiss commented Jul 21, 2022

Symfony version(s) affected

6.1.2 - 6.13

Description

The Q&A sums it up perfectly: #46117

I noticed it as I was trying to set the below, but noticed it wasn't being honored by the serializer or it's friends:

framework:
    serializer:
        default_context:
            skip_null_values: true

Problem:

I have been learning Symfony 6 and I got stuck on a weird behaviour of the Serializer component.

Default instance of Serializer service seems to be ignoring default context set in config file. I am not sure if this is a bug in the framework or rather my misunderstanding how the component and the framework work, but the situation can be easily reproduced in a fresh project.

How to reproduce

Steps to reproduce:

Environment: PHP 8.1.4, Symfony CLI 5.4.7, Symfony 6.0.7.

  1. Create a fresh project by executing symfony local:new --webapp demo.
  2. Generate a skeleton console command by executing bin/console make:command app:demo.
  3. Open the generated config/packages/framework.yaml and configure Serializer to pretty-print JSON:
framework:
    # ...
    serializer:
        default_context:
            json_encode_options: 128  # value of JSON_PRETTY_PRINT
  1. Open the generated App\Command\DemoCommand class and request dependency on Serializer in constructor:
public function __construct(
    private \Symfony\Component\Serializer\SerializerInterface $serializer,
) {
    parent::__construct();
}
  1. Modify command's execute method to demonstrate the bug:
protected function execute(InputInterface $input, OutputInterface $output): int
{
    $io = new SymfonyStyle($input, $output);

    $data = [
        'foo' => 'bar',
        'baz' => ['a' => 'b'],
    ];
    $context = [
        // exactly what was configured in default context
        'json_encode_options' => JSON_PRETTY_PRINT,
    ];
    $io->info($this->serializer->serialize($data, 'json')); // this should use default context
    $io->info($this->serializer->serialize($data, 'json', $context)); // this uses manually configured context

    return Command::SUCCESS;
}
  1. Execute the command to see the bug:
$ bin/console -vvv app:wat 

                                                                                                                        
 [INFO] {"foo":"bar","baz":{"a":"b"}}                                                                                   
                                                                                                                        

                                                                                                                        
 [INFO] {                                                                                                               
            "foo": "bar",                                                                                               
            "baz": {                                                                                                    
                "a": "b"                                                                                                
            }                                                                                                           
        }                                                                                                               

If my understanding of Symfony's documentation is correct, both calls to serialize should return the same output. Is this really a bug or did I misunderstood something?

Possible Solution

No response

Additional Context

No response

@mpiot
Copy link
Contributor

mpiot commented Jul 22, 2022

Same issue here, I just try to set:

    serializer:
        default_context:
            json_encode_options: 1024 # value of JSON_PRESERVE_ZERO_FRACTION

I see that run symfony console cache:clear trigger the SerializerPass that bind this default context to all serializer.normalizer tagged servies. But, all the services do not use that default context.

@schodemeiss
Copy link
Author

Same issue here

Yes, that's as far as my investigation got me. I can see it's 'trying' to set the default context, but nothing appears to correctly use them. Sadly, I don't have time to investigate further, but the issue contains a bunch of easy steps for a reproduction.

@mpiot
Copy link
Contributor

mpiot commented Jul 22, 2022

I've dump $encoders from the __construct in Serializer.php, it seems some encoders receive the context, other none.

For exemple: XmlEncoder receive the defaultContext but JsonEncoder not.
Inside the JsonEncoder, it receive $encodingImpl and $decodingImpl that are instance of JsonEncode inside them the logic is okay, it looks for json_encode_options in the defaultContext, else it defined 0 as default value for this param.
But because it receive no infos, it always use json_encode_options = 0.

Inside JsonEncoder, if no $encodingImpl or $decodingImpl are passed, it create new one per default without $defaultContext

class XmlEncoder implements EncoderInterface, DecoderInterface, NormalizationAwareInterface, SerializerAwareInterface
{
    public function __construct(array $defaultContext = [])
    {
        $this->defaultContext = array_merge($this->defaultContext, $defaultContext);
    }
}
class JsonEncoder implements EncoderInterface, DecoderInterface
{
    public function __construct(JsonEncode $encodingImpl = null, JsonDecode $decodingImpl = null)
    {
        $this->encodingImpl = $encodingImpl ?? new JsonEncode();
        $this->decodingImpl = $decodingImpl ?? new JsonDecode([JsonDecode::ASSOCIATIVE => true]);
    }
}

=> I think, the problem is here: it creates new instance of JsonEncode instance without pass them the defaultContext.

Then :

class JsonEncode implements EncoderInterface
{
    public const OPTIONS = 'json_encode_options';

    private $defaultContext = [
        self::OPTIONS => 0,
    ];

    public function __construct(array $defaultContext = [])
    {
        $this->defaultContext = array_merge($this->defaultContext, $defaultContext);
    }
}

=> Because the defaultContext is not passed, these options are never used in the case of that Encoder.

It appear we can't use defaultContext inside the JsonEncoder, due to the design:

  • Impossible to add array $defaultContext = [] at thte end of the constructor (not know why), but add it as first argument break code that call it.

[Symfony\Component\DependencyInjection\Exception\RuntimeException]
Invalid constructor argument 3 for service "debug.serializer.encoder.json.inner": argument 1 must be defined before. Check your service definition.

  • JsonEncode and JsonDecode are not defined as service and not Injected inside the JsonEncoder. They implement the EncoderInterface and DecoderInterface that can permit them to receive the defaultContext as DI if they're defined as Service. But, what when third party create their own JsonEncode and JsonDecode, the defaultContext will still not be passed.

@schodemeiss schodemeiss changed the title Default Context in Serializer isn't honoured [Serializer] Default Context in Serializer isn't honoured Jul 29, 2022
@schodemeiss
Copy link
Author

This behavior is still true on Symfony 6.1.3.

@dbu
Copy link
Contributor

dbu commented Aug 5, 2022

i think the base problem indeed is

JsonEncode and JsonDecode are not defined as service and not Injected inside the JsonEncoder. They implement the EncoderInterface and DecoderInterface that can permit them to receive the defaultContext as DI if they're defined as Service. But, what when third party create their own JsonEncode and JsonDecode, the defaultContext will still not be passed.

so the context does not get forwarded into the encode and decode classes. i see two solutions:
a) allow to pass a $defaultContext to JsonEncoder and when the JsonEncode and JsonDecode are null, have the JsonEncoder forward the default context to the objects it creates
b) define services for encode and decode at

->set('serializer.encoder.xml', XmlEncoder::class)
, configure the JsonEncoder with those services and tag encode and decode with a new tag (not serializer.encoder but some new tag, as encode and decode can not be registered with the serializer) that we also find in
foreach (array_keys(array_merge($container->findTaggedServiceIds('serializer.normalizer'), $container->findTaggedServiceIds('serializer.encoder'))) as $service) {
so they get the default context injected

@nicolas-grekas which approach do you prefer? and is that a bugfix or a new feature? i could do a pull request for this.

@upyx
Copy link
Contributor

upyx commented Aug 5, 2022

That's funny, because it looks like it has never worked...

Think about it. It hasn't worked for years, and nobody cares. Maybe it would be better to remove this feature instead of fixing it?

@dbu it's trivial to fix:

===================================================================
--- a/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php	(revision 5e9664d72f1f3677796de6349ac7440fa15041a6)
+++ b/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php	(date 1659725170208)
@@ -23,10 +23,10 @@
     protected $encodingImpl;
     protected $decodingImpl;
 
-    public function __construct(JsonEncode $encodingImpl = null, JsonDecode $decodingImpl = null)
+    public function __construct(JsonEncode $encodingImpl = null, JsonDecode $decodingImpl = null, array $defaultContext = [])
     {
-        $this->encodingImpl = $encodingImpl ?? new JsonEncode();
-        $this->decodingImpl = $decodingImpl ?? new JsonDecode([JsonDecode::ASSOCIATIVE => true]);
+        $this->encodingImpl = $encodingImpl ?? new JsonEncode($defaultContext);
+        $this->decodingImpl = $decodingImpl ?? new JsonDecode([JsonDecode::ASSOCIATIVE => true] + $defaultContext);
     }
 
     /**
===================================================================
--- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php	(revision 5e9664d72f1f3677796de6349ac7440fa15041a6)
+++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php	(date 1659725170216)
@@ -177,6 +177,7 @@
             ->tag('serializer.encoder')
 
         ->set('serializer.encoder.json', JsonEncoder::class)
+            ->args([null, null])
             ->tag('serializer.encoder')
 
         ->set('serializer.encoder.yaml', YamlEncoder::class)

That's it.

@upyx
Copy link
Contributor

upyx commented Aug 5, 2022

@mpiot error "Invalid constructor argument 3 for service" means that the third argument is set but the first two are not. They can be implictly set to nulls.

@mpiot
Copy link
Contributor

mpiot commented Aug 5, 2022

@upyx thank you for the information, I’ve tried a fix like your, by passing the context as third argument but don’t understand at all why this error was triggered πŸ˜…

@dbu
Copy link
Contributor

dbu commented Aug 6, 2022

@upyx yep exactly, that is what i formulated as variant "a)" in my comment above. i am waiting for @nicolas-grekas to tell if he prefers that or variant b).

(tiny note on your patch: the symfony compiler pass uses parameter name matching, so i think you would not need to set the encode and decode parameters to null) => edit: aparently you are right, i do get an error when not setting the null parameters in #47243

That's funny, because it looks like it has never worked...
Think about it. It hasn't worked for years, and nobody cares.

well, some people seem to care, but it is rather hidden that this can be done...

Maybe it would be better to remove this feature instead of fixing it?

on the serializer code level it worked, but you had to pass explicit encode / decode instances to the encoder class. the thing that is broken is only the symfony framework bundle configuration for those options. in the interest of least astonishment, i think its better to fix it than to remove it.

@nicolas-grekas
Copy link
Member

What about adding a withDefaultContext() method? Would that work?

@upyx
Copy link
Contributor

upyx commented Aug 8, 2022

@nicolas-grekas

What about adding a withDefaultContext() method? Would that work?

I think not. The context is bind by SerializerPass. Hovewer, it's easy to add an agrument to the constructor.

@dbu

... i think its better to fix it than to remove it.

IMO, a global context for all serializers isn't a good thing. I'd rather add a service with propper settings. It as easy as set default. However, I don't know all use cases the framework, so we should ask @nicolas-grekas

How to fix it...

After thinking about it, JsonEncode and JsonDecode are pure implementations, they no need in a context. IMO we shoud move the $defaultContext from them to JsonEncoder (with "r"). The YamlEncoder is a good example. Dumper and Parser are pure implementations without context.

@dbu @nicolas-grekas WDYT?

@dbu
Copy link
Contributor

dbu commented Aug 10, 2022

imo the global context is useful. if i want all my serializers to treat e.g. JSON_PRESERVE_ZERO_FRACTION one way or the other, it makes sense to configure that globally. when i want multiple serializers with different configuration, i need to set up my own services anyways, the framework bundle only allows to configure one instance of the serializer.

looking at the YamlEncoder example, i agree it would be consistent to have the default context in the JsonEncoder and merge it with the method call context when forwarding to the implementations. yaml parser has no config, yaml dumper has indentation which is not configurable for the same reason as with json encode.

the json encode and decode implementations do accept a default context of their own, which we will need to keep for BC, but passing them through the method call each time seems more correct to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants