Как обрабатывать обновление объекта (запрос PUT) в REST API с помощью FOSRestBundle

Я создаю прототип REST API в Symfony2 с FOSRestBundle, используя JMSSerializerBundle для сериализации сущностей. С запросом GET я могу использовать функциональность ParamConverter SensioFrameworkExtraBundle для получения экземпляра объекта на основе параметра запроса id, а при создании нового объекта с запросом POST я могу использовать преобразователь тела FOSRestBundle для создания нового экземпляра объекта на основе данные запроса. Но когда я хочу обновить существующий объект, использование преобразователя FOSRestBundle дает объект без идентификатора (даже если идентификатор отправляется с данными запроса), поэтому, если я сохраняю его, он создаст новый объект. И использование преобразователя SensioFrameworkExtraBundle дает мне исходный объект без новых данных, поэтому мне пришлось бы вручную получать данные из запроса и вызывать все методы установки для обновления данных объекта.

Итак, мой вопрос: каков предпочтительный способ справиться с этой ситуацией? Похоже, должен быть какой-то способ справиться с этим, используя (де) сериализацию данных запроса. Я упустил что-то, связанное с сериализатором ParamConverter или JMS, который справился бы с этой ситуацией? Я понимаю, что есть много способов делать такие вещи, и ни один из них не подходит для каждого случая использования, просто ищите что-то, что подходит для такого быстрого прототипирования, которое вы можете сделать с помощью ParamConverter и минимального кода, необходимого для написания в контроллерах/сервисах.

Вот пример контроллера с действиями GET и POST, как описано выше:

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);
    }
}

И в config.yml у меня есть следующая конфигурация для 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

person Cvuorinen    schedule 31.03.2014    source источник


Ответы (6)


Если вы используете PUT, согласно REST, вы должны использовать маршрут для обновления с идентификатором рассматриваемого объекта в самом маршруте, например /entity/{entity}. FOSRestBundle делает то же самое.

В вашем случае это должно быть что-то вроде:

/**
 * @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: На самом деле было бы даже лучше ввести две сущности. Один из них — текущее состояние базы данных, а другой — данные, отправленные клиентом. Вы можете добиться этого с помощью двух аннотаций ParamConverter:

/**
 * @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)

Это загрузит текущее состояние БД в $entity и загруженные данные в $entityNew. Теперь вы можете объединить данные по своему усмотрению.

Если вы можете просто перезаписать данные без слияния/проверки, используйте первый вариант. Но имейте в виду, что это позволит создать новую сущность, если клиент отправит еще не использованный идентификатор, если вы не предотвратите это.

person marsbear    schedule 23.05.2016
comment
Я не согласен с тем, что вы должны использовать маршрут PUT для обновления, PUT в основном предназначен для замены. Вы можете использовать PATCH для частичного обновления. - person Alcalyn; 16.01.2017
comment
Здесь небольшое недоразумение. Я имел в виду, что если вы используете маршрут PUT, REST рекомендует делать это так, как я описываю, что относится к структуре URL. Я уточнил это. Кроме того, PUT или POST по-прежнему будут хорошими решениями, в зависимости от конкретного варианта использования. - person marsbear; 23.01.2017
comment
Каким образом текущее состояние базы данных будет введено в $entity при редактировании? Как он выясняет, что должен получить сущность идентификатора, указанного в URL-адресе? Я не могу заставить его работать сам. - person Bob; 24.04.2019

Кажется, одним из способов было бы использование компонента Symfony Form (с SimpleThingsFormSerializerBundle), как описано в http://williamdurand.fr/2012/08/02/rest-apis-with-symfony2-the-right-way/#post-it

Цитата из SimpleThingsFormSerializerBundle README:

Кроме того, все текущие компоненты сериализатора имеют общий недостаток: они не могут десериализоваться (обновляться) в существующие графы объектов. Обновление графов объектов — это проблема, которую уже решает компонент Form (идеально!).

person Cvuorinen    schedule 31.03.2014

У меня также была проблема с обработкой запросов PUT с использованием сериализатора JMS. В первую очередь хотелось бы автоматизировать обработку запросов с помощью сериализатора. Запрос на размещение может не содержать полных данных. Часть данных должна быть сопоставлена ​​с сущностью. Вы можете использовать мое простое решение:

/**
 * @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();
}

Метод mapDataOnEntity вы можете найти в каком-то трейте или в промежуточном классе контроллера. Вот его реализация этого метода:

/**
 * @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);
}
person Роман Сергеевич    schedule 20.08.2016

Лучше всего использовать JMSSerializerBundle.

Проблема заключается в том, что JMSSerializer инициализируется с помощью ObjectConstructor по умолчанию для десериализации (установка полей, которых нет в запросе, как нулевых, и выполнение этого метода слияния также сохранит нулевые свойства в базе данных ). Поэтому вам нужно переключить его с помощью DoctrineObjectConstructor.

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

Затем просто десериализуйте и сохраните сущность, и она будет заполнена недостающими полями. При сохранении в базе данных в базе данных будут обновлены только измененные атрибуты:

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

Авторы: Проблема Symfony2 Doctrine2 De-Serialize and Merge Entity

person Álvaro Peláez    schedule 10.07.2017
comment
Это выглядит многообещающим решением. Я должен буду попробовать и посмотреть, как это работает. - person Cvuorinen; 11.09.2017
comment
В случае, если кто-то наткнется на эту проблему и попытается использовать doctrine_object_constructor : вы ДОЛЖНЫ указать параметр id в теле запроса, а не только в параметрах маршрута! - person johnkork; 15.12.2018

У меня такая же проблема, как вы описали, я просто объединяю сущности вручную:

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

Этот метод вызывает метод, который выполняет проверку, а затем вручную вызывает все необходимые методы установки. Во всяком случае, мне интересно написать свой собственный преобразователь параметров для таких случаев.

person piotr.jura    schedule 08.09.2014

Другой ресурс, который мне очень помог, это http://welcometothebundle.com/symfony2-rest-api-the-best-2013-way/. Пошаговое руководство, которое заполнило пробелы, которые у меня были после ресурса в предыдущем комментарии. Удачи!

person Jeroen Sen    schedule 02.09.2014
comment
вместо того, чтобы просто поделиться ссылкой, объясните, какая часть кода этой ссылки поможет OP. - person GusDeCooL; 26.04.2016