如何使用FOSRestBundle处理REST API中的实体更新(PUT请求)

3
我正在使用Symfony2和FOSRestBundle以及JMSSerializerBundle构建REST API的原型,用于实体序列化。通过GET请求,我可以使用SensioFrameworkExtraBundle的ParamConverter功能来获取基于id请求参数的实体实例;而使用POST请求创建新实体时,我可以使用FOSRestBundle body converter根据请求数据创建新实体实例。但是,在更新现有实体时,使用FOSRestBundle转换器会得到一个没有id的实体(即使id已经与请求数据一起发送),因此如果我将其持久化,它将创建一个新实体。而使用SensioFrameworkExtraBundle转换器会给我原始实体而不包括新数据,因此我必须手动从请求中获取数据并调用所有setter方法来更新实体数据。
所以我的问题是,如何处理这种情况是首选方式?感觉应该有一些方法可以使用请求数据的(反)序列化来处理这种情况。我是否遗漏了与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
6个回答

3
如果您正在使用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)

编辑:最好有两个实体进行注入。一个是当前数据库状态,另一个是来自客户端的发送数据。您可以使用两个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中。现在您可以根据需要合并数据。
如果只想覆盖数据而不进行合并/检查,则使用第一个选项。但请记住,如果您没有防止客户端发送尚未使用的ID,则这将允许创建新实体。

我不同意“应该使用PUT路由进行更新”的说法,PUT主要用于替换。您可以使用PATCH进行部分更新。 - Alcalyn
这里有一点误解。我的意思是,“如果你使用PUT路由,REST建议按照我描述的方式进行”,这是指URL结构。我已经澄清了这一点。除此之外,根据确切的用例,PUT或POST仍然是很好的解决方案。 - marsbear
当前数据库状态如何注入到您的编辑中的$entity变量中?它是如何确定必须检索URL中给定ID的实体的?我自己无法使其工作。 - Bob

1

1
最好的方法是使用JMSSerializerBundle
问题在于,JMSSerializer会使用默认的ObjectConstructor进行反序列化(将请求中不存在的字段设置为null,并使合并方法也将null属性持久化到数据库中)。因此,您需要将其切换为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();

Credits to: Symfony2 Doctrine2 De-Serialize and Merge Entity issue 的作者。

这看起来是一个很有前途的解决方案。我得试一下,看看它的工作原理如何。 - Cvuorinen
1
如果有人遇到这个问题并尝试使用doctrine_object_constructor:您必须在请求正文中提供id参数,而不仅仅是路由参数! - johnkork

1
我也曾遇到使用JMS序列化处理PUT请求的问题。首先,我想通过序列化器自动化查询处理。PUT请求可能不包含完整数据。部分数据必须映射到实体上。您可以使用我的简单解决方案:
/**
 * @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 可以在某些trait或您的中间控制器类中找到。这是该方法的实现:

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

0

我遇到了你描述的同样问题,我只是手动执行实体合并:

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

这个调用一个执行验证的方法,然后手动调用所有必需的setter方法。不管怎样,我在思考为这种情况编写自己的参数转换器。


-1

不要仅仅分享一个链接,而是解释一下该链接中的哪个代码部分可以帮助 OP。 - GusDeCooL

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接