简体   繁体   中英

How to handle entity update (PUT request) in REST API using FOSRestBundle

I am prototyping a REST API in Symfony2 with FOSRestBundle using JMSSerializerBundle for entity serialization. With GET request I can use the ParamConverter functionality of SensioFrameworkExtraBundle to get an instance of an entity based on the id request parameter and when creating a new entity with POST request I can use the FOSRestBundle body converter to create a new instance of the entity based on the request data. But when I want to update an existing entity, using the FOSRestBundle converter gives an entity without id (even when the id is sent with the request data) so if I persist it, it will create a new entity. And using SensioFrameworkExtraBundle converter gives me the original entity without the new data so I would have to manually get the data from the request and call all the setter methods to update the entity data.

So my question is, what is the preferred way to handle this situation? Feels like there should be some way to handle this using the (de)serialization of the request data. Am I missing something related to the ParamConverter or JMS serializer that would handle this situation? I do realize that there are many ways to do this kind of things and none of them are right for every use case, just looking for something that fits this kind of rapid prototyping you can do by using the ParamConverter and minimal code required to be written in the controllers/services.

Here is an example of a controller with the GET and POST actions as described above:

namespace My\ExampleBundle\Controller;

use My\ExampleBundle\Entity\Entity;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\View\View;

class EntityController extends Controller
{
    /**
     * @Route("/{id}", requirements={"id" = "\d+"})
     * @ParamConverter("entity", class="MyExampleBundle:Entity")
     * @Method("GET")
     * @Rest\View()
     */
    public function getAction(Entity $entity)
    {
        return $entity;
    }

    /**
     * @Route("/")
     * @ParamConverter("entity", converter="fos_rest.request_body")
     * @Method("POST")
     * @Rest\View(statusCode=201)
     */
    public function createAction(Entity $entity, ConstraintViolationListInterface $validationErrors)
    {
        // Handle validation errors
        if (count($validationErrors) > 0) {
            return View::create(
                ['errors' => $validationErrors],
                Response::HTTP_BAD_REQUEST
            );
        }

        return $this->get('my.entity.repository')->save($entity);
    }
}

And in config.yml I have the following configuration for FOSRestBundle:

fos_rest:
    param_fetcher_listener: true
    body_converter:
        enabled: true
        validate: true
    body_listener:
        decoders:
            json: fos_rest.decoder.jsontoform
    format_listener:
        rules:
            - { path: ^/api/, priorities: ['json'], prefer_extension: false }
            - { path: ^/, priorities: ['html'], prefer_extension: false }
    view:
        view_response_listener: force

If you are using PUT, according to REST, you should use a route for the update with the id of the entity in question in the route itself like /entity/{entity}. FOSRestBundle does it that way too.

In your case this should be something like:

/**
 * @Route("/{entityId}", requirements={"entityId" = "\d+"})
 * @ParamConverter("entity", converter="fos_rest.request_body")
 * @Method("PUT")
 * @Rest\View(statusCode=201)
 */
public function putAction($entityId, Entity $entity, ConstraintViolationListInterface $validationErrors)

EDIT: It would actually be even better to have two entities injected. One being the current database state and one being the sent data from the client. You can achieve this with two ParamConverter-annotations:

/**
 * @Route("/{id}", requirements={"id" = "\d+"})
 * @ParamConverter("entity")
 * @ParamConverter("entityNew", converter="fos_rest.request_body")
 * @Method("PUT")
 * @Rest\View(statusCode=201)
 */
public function putAction(Entity $entity, Entity $entityNew, ConstraintViolationListInterface $validationErrors)

This will load the current db state into $entity and the uploaded data into $entityNew. Now you can merge the data as you see fit.

If it's fine for you to just overwrite the data without merging/checking, then use the first option. But keep in mind that this would allow creating a new entity if the client sends a not yet used id if you do not prevent that.

Seems one way would be to use Symfony Form component (with SimpleThingsFormSerializerBundle ) as described in http://williamdurand.fr/2012/08/02/rest-apis-with-symfony2-the-right-way/#post-it

Quote from SimpleThingsFormSerializerBundle README:

Additionally all the current serializer components share a common flaw: They cannot deserialize (update) into existing object graphs. Updating object graphs is a problem the Form component already solves (perfectly!).

I also had a problem with the processing of PUT requests using JMS serializer. First of all I would like to automate the processing of queries using the serializer. The put request may not contain the complete data. Part of the data must be map on entity. You can use my simple solution:

/**
 * @Route(path="/edit",name="your_route_name", methods={"PUT"})
 *
 * This parameter is using for creating a current fields of request
 * @RequestParam(
 *     name="id",
 *     requirements="\d+",
 *     nullable=false,
 *     allowBlank=true,
 *     strict=true,
 * )
 * @RequestParam(
 *     name="some_field",
 *     requirements="\d{13}",
 *     nullable=true,
 *     allowBlank=true,
 *     strict=true,
 * )
 * @RequestParam(
 *     name="some_another_field",
 *     requirements="\d{13}",
 *     nullable=true,
 *     allowBlank=true,
 *     strict=true,
 * )
 * @param Request $request
 * @param ParamFetcher $paramFetcher
 * @return Response
 */
public function editAction(Request $request, ParamFetcher $paramFetcher)
{
    //validate parameters
    $paramFetcher->all();
    /** @var EntityManager $em */
    $em = $this->getDoctrine()->getManager();
    $yourEntity = $em->getRepository('YourBundle:SomeEntity')->find($paramFetcher->get('id'));
    //get request params (param fetcher has all params, but we need only params from request)
    $data = $request->request->all();
    $this->mapDataOnEntity($data, $yourEntity, ['some_serialized_group','another_group']);

    $em->flush();

    return new JsonResponse();
}

Method mapDataOnEntity you can locate in some trait or in you intermediate controller class. Here is his implementation of this method:

/**
 * @param array $data
 * @param object $targetEntity
 * @param array $serializationGroups
 */
public function mapDataOnEntity($data, $targetEntity, $serializationGroups = [])
{
    /** @var object $source */
    $sourceEntity = $this->get('jms_serializer')
        ->deserialize(
            json_encode($data),
            get_class($targetEntity),
            'json',
            DeserializationContext::create()->setGroups($serializationGroups)
        );
    $this->fillProperties($data, $targetEntity, $sourceEntity);
}

/**
 * @param array $params
 * @param object $targetEntity
 * @param object $sourceEntity
 */
protected function fillProperties($params, $targetEntity, $sourceEntity)
{
    $propertyAccessor = new PropertyAccessor();
    /** @var PropertyMetadata[] $propertyMetadata */
    $propertyMetadata = $this->get('jms_serializer.metadata_factory')
        ->getMetadataForClass(get_class($sourceEntity))
        ->propertyMetadata;
    foreach ($propertyMetadata as $realPropertyName => $data) {
        $serializedPropertyName = $data->serializedName ?: $this->fromCamelCase($realPropertyName);
        if (array_key_exists($serializedPropertyName, $params)) {
            $newValue = $propertyAccessor->getValue($sourceEntity, $realPropertyName);
            $propertyAccessor->setValue($targetEntity, $realPropertyName, $newValue);
        }
    }
}

/**
 * @param string $input
 * @return string
 */
protected function fromCamelCase($input)
{
    preg_match_all('!([A-Z][A-Z0-9]*(?=$|[A-Z][a-z0-9])|[A-Za-z][a-z0-9]+)!', $input, $matches);
    $ret = $matches[0];
    foreach ($ret as &$match) {
        $match = $match == strtoupper($match) ? strtolower($match) : lcfirst($match);
    }

    return implode('_', $ret);
}

The best way is using JMSSerializerBundle

The problem is JMSSerializer initializes with the default ObjectConstructor for deserialization (setting the fields that are not in the request as null, and making that merge method will also persist null properties to database). So you need to switch this one with the DoctrineObjectConstructor .

services:
    jms_serializer.object_constructor:
        alias: jms_serializer.doctrine_object_constructor
        public: false

Then just deserialize and persist the entity, and it will be filled with the missing fields. When you save to database only the attributes that have changed will be updated on the database:

$foo = $this->get('jms_serializer')->deserialize(
            $request->getContent(), 
            'AppBundle\Entity\Foo', 
            'json');
$em = $this->getDoctrine()->getManager();
$em->persist($foo);
$em->flush();

Credits to: Symfony2 Doctrine2 De-Serialize and Merge Entity issue

I'm having the same issue as you described, I just do the entity merging manually:

public function patchMembersAction($memberId, Member $memberPatch)
{
    return $this->members->updateMember($memberId, $memberPatch);
}

This calls method that does the validation, and then manually calls all the required setter methods. Anyway, I'm wondering about writing my own param converter for such cases.

Another resource which helped me a lot is http://welcometothebundle.com/symfony2-rest-api-the-best-2013-way/ . A step by step tutorial which filled in the blanks I had after the resource in the previous comment. Good luck!

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM