schmittjoh / serializer

Library for (de-)serializing data of any complexity (supports JSON, and XML)
http://jmsyst.com/libs/serializer
MIT License
2.32k stars 588 forks source link

Allow Constructed Object to be Passed to Deserialize #79

Closed cmorelli closed 7 years ago

cmorelli commented 11 years ago

It doesn't appear that there's a way to give an already constructed object to the deserialize method (in which case the serializer would just skip the object construction phase and begin mapping properties).

My use case is simple: In a REST API, I want to allow a user to create or update objects. Each type of operation (create, update, delete, read) has a different set of security rules. However, with the current implementation of the deserializer (in which we are using the Doctrine object constructor), the user can issue a create request, but specify an "id" as part of the payload. The Doctrine object constructor will then return a mapped entity before deserialization begins, allowing the end user to perform an update (while the server still thinks it's doing a create).

Also, on this point: I was considering making a separate object constructor - but I need to give some attributes to the object constructor on each use (such as the ID which it should construct an object for). Right now, the only way to get those attributes to the deserializer is to include them in the payload string that gets passed to it - which is precisely what I'd like to avoid.

There are ways to handle this in the actual REST endpoint, but none of them are really ideal.What I'd like to be able to do is pass an already-constructed object (which my REST endpoint will handle fetching) to the deserializer and just have it do it's property mapping onto the object. This way, I can always pass a blank object in create calls, while I can pass a constructed object in update calls.

Would this be possible? I could submit a PR if this is something you'd be willing to implement.

stof commented 11 years ago

I would suggest you to use the Symfony Form component for this instead. Binding values in an existing object correspond to 80% of its codebase (the remaining part being the rendering of the HTML). See https://github.com/simplethings/SimpleThingsFormSerializerBundle for an implementation of the Form used in this way.

Binding data in an existing object requires totally different work than deserializing.

cmorelli commented 11 years ago

Forms could be used - but I lose a lot of control over the serialization/deserialization process that I currently have (and love) with JMS.

Second, I can't say I agree that it's totally different work. Isn't the whole purpose of the object constructor to allow one to return already existing objects rather than creating new ones? For example, the Doctrine object constructor works by accepting identifiers in the payload and returning managed entities from that data. I don't see how this request falls outside the realm of that?

The object constructor paradigm could also work for me, if I could easily pass arbitrary attributes to it. The only catch here is that I need to be able to pass my identifiers to construct the object from separate from the actual payload. I could hack it to work, I'd just prefer to not hack if there could be an API-supported way of doing it.

To comment again on your final sentence: binding data to an existing object should really be the exact same as binding data to a new object. The only difference being that the "existing object" presumably has some attributes already set on it. If, instead of constructing a new object, the current serializer took an already existing object - the rest of the process would be the exact same.

schmittjoh commented 11 years ago

You can set the attributes on the DeserializationContext which you can access from your object constructor. Did you consider that?

cmorelli commented 11 years ago

Am I missing how to get the context from the object constructor? I did consider that approach - and it would work fine - but it doesn't appear to get passed down into the constructor.

schmittjoh commented 11 years ago

You're right. I think we should add it there.

cmorelli commented 11 years ago

I definitely support that approach. Except injecting the context would be a BC break.

Thoughts?

schmittjoh commented 11 years ago

The ObjectConstructorInterface is not exactly user-facing. I think we can slightly change the arguments and add the context.

cmorelli commented 11 years ago

+1 from me. I should have some time today to get this working and submit a PR if you're busy.

stof commented 11 years ago

@cmorelli The issue is that you would need to handle exisitng objects in many places of the graph, not only at the root. And did you look at the link I gave ? It supports different serialization formats

cmorelli commented 11 years ago

@stof That is true - I was only considering the passing of existing objects at the root (not considering the rest of the graph). That being said, I think the solution that @schmittjoh posted is the best way to go. The object constructor can choose if it wants to use any attributes from the DeserializationContext to create its objects. This would work at any level of the graph.

evillemez commented 11 years ago

+1 Any news on this? Same use case here, and I'm currently using a hacky solution where I parse the JMS metadata myself to manually recurse and set properties. It makes much more sense to be able to pass in an already constructed object to handle the same way as creation.

Using the form component would work, sure, but... it's a lot of extra code to load for doing something that's almost already doable.

I'm not super familiar with the internals, but if someone points me in the right direction I'd be happy to work on this if no one else currently is. I'm about to go on vacation for two weeks, but as soon as I get back I would have time to start on this around June 26th.

eugene-dounar commented 11 years ago

Hi there! What about adding a "target" property to DeserializationContext which contains a constructed object to serialize data into? As far as I can see it would not break BC and is super simple to implement.

@schmittjoh would you accept a PR with that?

schmittjoh commented 11 years ago

What we can do easily is to add the context as an argument for the object constructor. Then, you can easily implement your own constructor as you need it.

evillemez commented 10 years ago

It looks like this issue was closed from #160, but are there any docs for it anywhere?

What I'm curious about is, it looks like in order to deserialize into a preexisting object, I need to change the object constructor. In the SerializerBundle I can do this in the config - but I don't necessarily want to always use that object constructor - I only want to use it in the contexts where I'm receiving data via an API. Should I configure a new serializer service for this case?

Edit: Also, does this only handle objects at the root?

eugene-dounar commented 10 years ago

@evillemez you can pass a fallback constructor to your own constructor and call it when no object is passed with serialization context. See DoctrineObjectConstructor - it does the same thing. I think it would be much cleaner than having 2 serializer services .

evillemez commented 10 years ago

@eugene-dounar Ah, ok... I see. From the code, this looks like it only handles serializing into a pre-existing object at the root level of the graph, is that actually the case or am I misunderstanding it?

eschwartz commented 10 years ago

Thanks @eugene-dounar -- this is so great! We were going crazy in my office trying to figure out how to do partial updates to Doctrine-ORM-managed entities.

Using the deserializer for partial updates (eg PUT requests) seems like a common enough use case that the InitializedObjectConstructor (test fixture) should be made a usable part of the serializer service. At the very least, could we add some documentation on how to accomplish this?

We ended up copy/pasting the InitializedObjectConstructor into our code base. For anyone else banging their heads against their desks, here's what we ended up doing (using ZF2):

// module.config.php
'service_manager' => array(
  'factories' => array(
    // Create a jms serializer as a ZF2 service,
    // configured to use the InitializedObjectCtor
    'serializer' => function() {
      // ...
      $defaultObjCtor = new \JMS\Serializer\Construction\UnserializeObjectConstructor();
      $initializedObjCtor = new \MyApp\path\to\copy\and\pasted\InitializedObjectCtor($defaultObjCtor);

      return \JMS\Serializer\SerializerBuilder::create()
        ->setObjectConstructor($initializedObjCtor)
        ->build();
    }
  )
)

// MyApp\Controller\UserRestController
// ...
public function update($id, array $data) {
  // Grab the existing user from the ORM
  $user = $this->doctrineEntityManager->findById($id);

  // Create a deserialization context, targeting the existing user
  $context = new \JMS\Serializer\DeserializationContext();
  $context->attributes->set('target', $user);

  // Deserialize the data "on to" to the existing user
  $this->serializer->deserialize(json_encode($data), 'MyApp\Model\User', 'json', $context); 

  // Save the updated user
  $this->doctrineEntityManager->persist($user);
  $this->doctrineEntityManager->flush();

  // return ... 
}

The whole context injection thing is a little rough on the eyes, so we'll probably end up wrapping the JMS Serializer to accept an object as a second param (I'm a JS dev, so I'm totally cool with mixing up my parameter types :p )

terwey commented 9 years ago

I followed @eschwartz recommendation and just did a

cp vendor/jms/serializer/tests/JMS/Serializer/Tests/Fixtures/InitializedObjectConstructor.php src/myApp/

Modified the namespace in the InitializedObjectConstructor.php and it all works like a charm after this.

Can this class be moved into a more proper and permanent location so I don't have to maintain it myself?

RedEdgeKatelyn commented 9 years ago

For anyone trying to do this in Symfony, I copied vendor/jms/serializer/tests/JMS/Serializer/Tests/Fixtures/InitializedObjectConstructor.php to a Services folder in my Bundle and added this to config.yml:

services:
    jms_serializer.object_constructor:
        alias: jms_serializer.initialized_object_constructor
        public: false
    jms_serializer.initialized_object_constructor:
         class:        VendorName\Bundle\ApiBundle\Services\InitializedObjectConstructor
         arguments:    ["@jms_serializer.doctrine_object_constructor"]
faheemhameed commented 8 years ago

Note about the Symfony helped a lot. Thanks!

tom10271 commented 8 years ago

@RedEdgeKatelyn I am not sure code changed or not but your code snippet is not work.

Here is the workable version:

    jms_serializer.object_constructor:
        alias: jms_serializer.initialized_object_constructor
        public: false

    jms_serializer.initialized_object_constructor:
         class:        Acme\YourBundle\Serializer\InitializedObjectConstructor
         arguments:    ["@jms_serializer.unserialize_object_constructor"] 
bendbennett commented 8 years ago

Thanks @RedEdgeKatelyn and @tom10271 as a Symfony user your solution worked for me. But I couldn't help but feel that this should be soluble through configuration. Stumbled across a SO post and followed the suggested configuration change (in app/config/services.yml):

services:
    jms_serializer.object_constructor:
        alias: jms_serializer.doctrine_object_constructor

This worked for me when I then used JMS serializer:

$this->serializer->deserialize($request->getContent(), 'MyApp\Entity\User', 'json');

Only thing to watch out for is that the id of the object must appear in the body of the request, otherwise instead of updating a pre-hydrated instance of User, a new instance of User will be created.

Can also confirm that the additional changes suggested in the SO post suggested for Mongo also work:

services:
    jms_serializer.doctrine_object_constructor:
        class:        %jms_serializer.doctrine_object_constructor.class%
        public:       false
        arguments:    ["@doctrine_mongodb", "@jms_serializer.unserialize_object_constructor"]

    jms_serializer.object_constructor:
        alias: jms_serializer.doctrine_object_constructor

Kudos to Heyflynn and con

Works with PHP 7 too :)

goetas commented 7 years ago

ObjectConstructorInterface has been updated and is possible to get objects from the context attributes

karousn commented 6 years ago

@goetas : if possible to have an example of implementation for that, i looking to use context attributes but until now $context->attributes->all() have an empty array, i don't know how can i set my object there.

goetas commented 6 years ago

@karousn you can have a look at it on https://github.com/schmittjoh/serializer/blob/ec09e10524d371f40f095dd801eb374fa0d11a56/tests/Serializer/BaseSerializationTest.php#L714

yakobabada commented 5 years ago

@goetas I still confused. Could you provide with an example for Symfony 4.2, please?

philippeamar commented 2 years ago

@goetas Could you provide with an example for Symfony 5 please?

nnmer commented 2 years ago

https://stackoverflow.com/a/56128315/3419751 https://jmsyst.com/libs/serializer/master/cookbook/object_constructor#deserialize-on-existing-objects

@yakobabada this does work for my SF 4.4

@philippeamar I guess it may still work for SF 5

jeandonaldroselin commented 1 year ago

@philippeamar, all

In short : I have a simple working snippet to deserialize an object into another in Symfony 6 🙂 !! You can go to Snippet part directly or read the long version, it's up to you ;).

In long : I was reading this Open Classrooms great course to learn how to create an API with latest Symfony (6).

During the course we needed to replace the Symfony native Serializer by JMS/Serializer in order to make Hateos (autodiscovery) component work properly. So we replaced the native Symfony serializer but with no concrete solution to deserialize an object in another when performing an entity update.

The solution given in the course was to assign manually each attribute of the updated object to the existing objet. So I did it to go ahead in the course. But now I have finished the course, I cannot use the course advice in real life for updating objets. (it is not at all optimized)

After testing many solutions, I found this which is working and I use it in real life, enjoy 🙂 !!

Snippet

<?php

namespace App\Service;

use JMS\Serializer\Construction\ObjectConstructorInterface;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\Metadata\ClassMetadata;
use JMS\Serializer\Visitor\DeserializationVisitorInterface;

class InitializedObjectConstructor implements ObjectConstructorInterface
{
    private $fallbackConstructor;

    /**
     * @param ObjectConstructorInterface $fallbackConstructorClassName Fallback object constructor
     */
    public function __construct($fallbackConstructorClassName)
    {
        $this->fallbackConstructor = new $fallbackConstructorClassName();
    }

    /**
     * {@inheritdoc}
     */
    public function construct(
        DeserializationVisitorInterface $visitor,
        ClassMetadata $metadata,
        $data,
        array $type,
        DeserializationContext $context
    ): ?object {
        if ($context->hasAttribute('target') && 1 === $context->getDepth()) {
            return $context->getAttribute('target');
        }

        return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context);
    }
}
services:
    ...
    jms_serializer.object_constructor:
        class: App\Service\InitializedObjectConstructor
        arguments: ['\JMS\Serializer\Construction\UnserializeObjectConstructor']
    #[Route('/api/todos/{id}', name: 'updateOneTodo', methods: 'PUT')]
    #[IsGranted('ROLE_ADMIN', message: "You has no sufficient rights to perform this operation")]
    public function updateOne(Request $request,
                              SerializerInterface $serializer,
                              Todo $todo,
                              EntityManagerInterface $em): JsonResponse
    {
        $context = new DeserializationContext();
        $context->setAttribute('target', $todo);
        $updatedTodo = $serializer->deserialize($request->getContent(), Todo::class, 'json', $context);
        $em->persist($updatedTodo);
        $em->flush();
        return new JsonResponse($serializer->serialize($updatedTodo, 'json'), JsonResponse::HTTP_OK, [], true);
    }