如何正确实施 RESTful API PATCH 方法以基于 Symfony 4 更新具有 OneToMany 关系的多个实体?

How to properly implement RESTful API PATCH method to update multiple entities with OneToMany relation based on Symfony 4?

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

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

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

  1. 如果我使用端点添加或更新单个子项,一切正常。
  2. 如果我按照完全对齐的顺序(例如 GET 请求检索到的子项的顺序)在 PATCH 请求中提供所有现有项,一切正常。
  3. 如果我提供项目的混合顺序,它会尝试根据 PATCH 请求中提供的数据的原始顺序(例如 GET 请求检索到的子项目的顺序)更新项目 - 这意味着我无法修补,例如第三项,如果我没有按确切顺序提供前两项的有效详细信息。如果我在请求中提供 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"
        }
    ]
}

所以如果我向 /api/v1/parent-entity/22

发出 PATCH 请求
{
    "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 表单系统会按原始顺序更新现有元素,而不是添加新元素。

感谢您的任何建议。

我结束了对子元素的迭代,通过 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 代码、内容类型:application/problem+json 和有效负载中的帮助消息(基于定义的问题的类型)。