首页 > 解决方案 > 如何正确实现 RESTful API PATCH 方法以基于 Symfony 4 更新具有 OneToMany 关系的多个实体?

问题描述

我正在尝试找到一种方法来适当地实现更新一个或多个子实体的机制。父实体与没有一个或多个子实体有 OneToMany 关系,我需要一种方法来仅更新 PATCH 请求提供的那些。

到目前为止,我还没有找到任何足够具体的例子来为我指明正确的方向。

问题是,使用当前代码,它的工作方式如下:

  1. 如果我使用端点添加或更新单个子项,一切正常。
  2. 如果我按照完全对齐的顺序(例如 GET 请求检索的子项的顺序)提供 PATCH 请求中的所有现有项目,一切正常。
  3. 如果我提供项目的混合顺序,它会尝试根据 PATCH 请求中提供的数据根据​​原始顺序(例如,GET 请求检索到的子项目的顺序)更新项目 - 这意味着如果我不修补,我无法修补例如第 3 个项目' t 按确切顺序提供前两项的有效详细信息。如果我在请求中提供 ID,它会尝试更新这些 ID,而不是使用它们来处理特定项目。

这是一个代码示例:

实体的实现:

class ParentEntity
{
    /** @var int */
    private $id;

    /**
     * @var ArrayCollection|ChildEntity[]
     *
     * @ORM\OneToMany(targetEntity="ChildEntity", mappedBy="parentEntity")
     */
    private $clindEntities;
}

class ChildEntity
{
    /** @var int */
    private $id;

    /**
     * @var ParentEntity
     *
     * @ORM\ManyToOne(targetEntity="ParentEntity", inversedBy="clindEntities")
     */
    private $parentEntity;

    /** @var string */
    private $someProperty;
}

表格类型:

class ParentEntityFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('clindEntities', Type\CollectionType::class, [
                'required' => true,
                'entry_type' => ChildEntityFormType::class,
                'by_reference' => true,
                'allow_add' => false,
                'allow_delete' => false,
            ]);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => ParentEntity::class,
            'csrf_protection' => false,
            'allow_extra_fields' => false
        ]);
    }
}

class ChildEntityFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('id', Type\TextType::class, [
                'required' => false,
                'trim' => true,
            ])
            ->add('someProperty', Type\TextType::class, [
                'required' => true,
                'trim' => true,
            ]);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => ChildEntity::class,
            'csrf_protection' => false,
            'allow_extra_fields' => false
        ]);
    }
}

控制器:

class EntityController extends ApiBaseController
{
    /**
     * @Route("/api/v1/parent-entity/{id}", methods={"PATCH"}, name="api.v1.parent_entity.update")
     */
    public function updateParentEntity(Request $request, EntityManagerInterface $em, string $id)
    {
        $parentEntityRepository = $em->getRepository(ParentEntity::class);
        $parentEntity = $parentEntityRepository->find($id);
        if (!$parentEntity) {
            $apiProblemInterface = new Ae\ApiProblemInterface(Ae\ApiProblemType::ENTITY_NOT_FOUND);
            throw new Ae\ApiProblemException($apiProblemInterface);
        }

        // Validate payload
        $form = $this->createForm(ParentEntityFormType::class, $parentEntity);
        $this->submitAndValidateForm($form, $request, false, false);

        // Save changes
        $em->flush();

        return $this->response($parentEntity, ['api_entity_metadata', 'api_parent_entity', 'api_clhild_entity'], Response::HTTP_OK);
    }
}

原始数据结构和顺序(ParentEntity 示例):

{
    "id": 22,
    "clindEntities": [
        {
            "id": 1,
            "someProperty": "Some test string 1"
        },
        {
            "id": 2,
            "someProperty": "Some test string 2"
        },
        {
            "id": 3,
            "someProperty": "Some test string 3"
        }
    ]
}

因此,如果我执行 PATCH 请求/api/v1/parent-entity/22

{
    "clindEntities": [
        {
            "id": 3,
            "someProperty": "Updated test string 3"
        },
        {
            "id": 1,
            "someProperty": "Updated test string 1"
        }
    ]
}

这将导致尝试按如下方式更改数据(当然,由于非唯一 ID 而失败):

{
    "id": 22,
    "clindEntities": [
        {
            "id": 3,
            "someProperty": "Updated test string 3"
        },
        {
            "id": 1,
            "someProperty": "Updated test string 1"
        },
        {
            "id": 3,
            "someProperty": "Some test string 3"
        }
    ]
}

无论提供的顺序如何,我应该使用什么方法来实现只有具有确切 ID 的子项才能得到更新?

是否有任何简化的方法来遍历请求提供的项目并将它们分别通过 Symfony 表单系统?

PS:相似性这适用于使用 POST 方法添加子项的端点。虽然没有提供子 ID,但 Symfony 表单系统会以原始顺序更新现有元素,而不是添加新元素。

谢谢你的任何建议。

标签: symfonysymfony4

解决方案


我最终通过子元素进行迭代,通过 Symfony 的表单系统单独提交和验证它们:

class EntityController extends ApiBaseController
{
    /**
     * @Route("/api/v1/parent-entity/{id}", methods={"PATCH"}, name="api.v1.parent_entity.update")
     */
    public function updateParentEntity(Request $request, EntityManagerInterface $em, string $id)
    {
        $parentEntityRepository = $em->getRepository(ParentEntity::class);
        $parentEntity = $parentEntityRepository->find($id);
        if (!$parentEntity) {
            $apiProblemInterface = new Ae\ApiProblemInterface(Ae\ApiProblemType::ENTITY_NOT_FOUND);
            throw new Ae\ApiProblemException($apiProblemInterface);
        }

        $childEntityRepository = $em->getRepository(ChildEntity::class);
        foreach ($data['childEntities'] as $childEntity) {
            if (!isset($childEntity['id'])) {
                $apiProblemInterface = new Ae\ApiProblemInterface(Ae\ApiProblemType::ENTITY_NOT_FOUND);
                throw new Ae\ApiProblemException($apiProblemInterface);
            }

            $childEntity = $childEntityRepository->find($childEntity['id']);
            if (!$childEntity) {
                $apiProblemInterface = new Ae\ApiProblemInterface(Ae\ApiProblemType::ENTITY_NOT_FOUND);
                throw new Ae\ApiProblemException($apiProblemInterface);
            }

            $form = $this->createForm(AccountCryptoType::class, $childEntity);
            $this->submitArrayAndValidateForm($form, $accountData, false, false);
        }

        // Validate payload
        $form = $this->createForm(ParentEntityFormType::class, $parentEntity);
        $this->submitAndValidateForm($form, $request, false, false);

        // Save changes
        $em->flush();

        return $this->response($parentEntity, ['api_entity_metadata', 'api_parent_entity', 'api_clhild_entity'], Response::HTTP_OK);
    }

    /**
     * Reusable helper method for form data submission and validation.
     */
    protected function submitArrayAndValidateForm(FormInterface $form, array $formData, bool $clearMissing = true)
    {
        $form->submit($formData, $clearMissing);

        // Validate
        if ($form->isSubmitted() && $form->isValid()) {
            return;
        }
        $formErrors = $form->getErrors(true);

        // Object $formErrors can be string casted but we rather use custom stringification for more details
        if (count($formErrors)) {
            $errors = [];
            foreach ($formErrors as $formError) {
                $fieldName = $formError->getOrigin()->getName();
                $message = implode(', ', $formError->getMessageParameters());
                $message = str_replace('"', "*", $message);
                $messageTemplate = $formError->getMessageTemplate();
                $errors[] = sprintf('%s: %s %s', $fieldName, $messageTemplate, $message);
            }
        }

        $apiProblemInterface = new Ae\ApiProblemInterface(Ae\ApiProblemType::MISSING_OR_INVALID_PAYLOAD, join("; ", $errors));
        throw new Ae\ApiProblemException($apiProblemInterface);
    }
}

这工作得很好。但是,是的......如果有更好的实现等效功能,那么请让我知道并可能帮助其他可能有类似困难的人。

PS:

$apiProblemInterface = new Ae\ApiProblemInterface(Ae\ApiProblemType::MISSING_OR_INVALID_PAYLOAD, "...");
throw new Ae\ApiProblemException($apiProblemInterface);

只是 \Throwable 和异常处理机制的一些自定义实现方式。可以理解为:

throw new \Exception('Some message ...');

不同之处在于它会导致 API 响应带有错误 HTTP 代码、内容类型:应用程序/问题 + json 和有效负载中的帮助消息(基于定义的问题类型)。


推荐阅读