Symfony Advent Calendar 2017 12日目の記事です。 枠が空いていたので3度めですが書いちゃいます。

REST APIで「フォームにsubmitした値がセットされない!」と思ったら

下記のように、ユーザーの編集を行うREST APIを書いたとします。

<?php


namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Class User
 *
 * @ORM\Entity
 * @package App\Entity
 */
class User
{
    /**
     * @var integer
     * @ORM\Id()
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     */
    public $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=255)
     */
    public $name = 'my name';

    /**
     * @var array|string[]
     * @ORM\Column(type="json_array")
     */
    public $roles = [];
}

フォーム

<?php

namespace App\Form\Type;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserUpdateType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('roles', ChoiceType::class, [
                'choices' => [
                    'Admin' => 'ROLE_ADMIN',
                    'User' => 'ROLE_USER',
                    'Tester' => 'ROLE_TESTER',
                ],
                'multiple' => true,
                'expanded' => true,
            ])
        ;
    }

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

}

コントローラ

<?php


namespace App\Controller;

use App\Entity\User;
use App\Form\Type\UserUpdateType;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class UserController
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var FormFactoryInterface
     */
    private $formFactory;

    /**
     * @param EntityManagerInterface $em
     * @param FormFactoryInterface $formFactory
     */
    public function __construct(EntityManagerInterface $em, FormFactoryInterface $formFactory)
    {
        $this->em = $em;
        $this->formFactory = $formFactory;
    }

    /**
     * @ParamConverter(name="user", class="App:User")
     * @Route("/user/{id}")
     * @Method("PATCH")
     */
    public function updateAction(Request $request, User $user)
    {
        $form = $this->formFactory->createNamed('', UserUpdateType::class, $user, [
            'method' => 'PATCH',
        ]);
        $form->handleRequest($request);

        if ($form->isValid()) {
            $this->em->flush();

            return new Response('success!');
        }

        return new Response('failed!');
    }
}

一見何の問題もありませんね。

では、このコントローラの機能テストを書いてみましょう。

App\Entity\User:
  user1:
    name: "user1"
    roles: ["ROLE_ADMIN", "ROLE_USER", "ROLE_TESTER"]
<?php

namespace App\Controller;

use App\Entity\User;
use Liip\FunctionalTestBundle\Test\WebTestCase;

class UserControllerTest extends WebTestCase
{
    public function setUp()
    {
        parent::setUp();

        $this->loadFixtureFiles([
            __DIR__.'/../fixtures/user.yml',
        ]);
    }

    public function test()
    {
        $client = static::createClient();
        $client->request('PATCH', '/user/1', [
            'name' => 'user1',
            'roles' => ['ROLE_ADMIN', 'ROLE_USER'],
        ]);

        $response = $client->getResponse();
        $this->assertTrue($response->isOk(), $response->getStatusCode());

        $em = $this->getContainer()->get('doctrine')->getManager();
        $em->clear();
        $updatedUser = $em->find(User::class, 1);
        $this->assertEquals(['ROLE_ADMIN', 'ROLE_USER'], $updatedUser->getRoles());
    }
}

実行してみます。

There was 1 failure:

1) App\Controller\UserControllerTest::test
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
     0 => 'ROLE_ADMIN'
     1 => 'ROLE_USER'
+    2 => 'ROLE_TESTER'

.../tests/Controller/UserControllerTest.php:33

FAILURES!
Tests: 3, Assertions: 8, Failures: 1.

$client->request()で送ったパラメータ上ではroleを減らしたはずなのに、保存されたエンティティには反映されていません。 なぜでしょうか?

<?php

namespace Symfony\Component\Form;

// ...

class Form implements FormInterface
{
    // ...
    
    public function submit($submittedData, $clearMissing = true)
    {
        // ...
        if ($this->config->getCompound()) {
            // ...
            foreach ($this->children as $name => $child) {
                $isSubmitted = array_key_exists($name, $submittedData);
                if ($isSubmitted || $clearMissing) {
                    $child->submit($isSubmitted ? $submittedData[$name] : null, $clearMissing);
                    unset($submittedData[$name]);
                    if (null !== $this->clickedButton) {
                        continue;
                    }
                    if ($child instanceof ClickableInterface && $child->isClicked()) {
                        $this->clickedButton = $child;
                        continue;
                    }
                    if (method_exists($child, 'getClickedButton') && null !== $child->getClickedButton()) {
                        $this->clickedButton = $child->getClickedButton();
                    }
                }
            }
        }    
        // ...
    }
    
    // ...
}

https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Form/Form.php#L571

なんと、値がsubmitされてない場合(multipleかつexpandedなChoiceTypeで値が減った場合)は $clearMissing=true でないとsubmitした値は無視されてしまいます。つまり、rolesの ROLE_TESTER がsubmitされなかったのは無視されたということです。 clearMissingにfalseを指定した覚えはありませんが、どこでfalseになったのでしょうか?

そもそも Form::submit() でなく Form::handleRequest() でsubmitしたはずなので、Form::handleRequest()を見ると…

<?php

namespace Symfony\Component\Form\Extension\HttpFoundation;

// ...
/**
 * A request processor using the {@link Request} class of the HttpFoundation
 * component.
 *
 * @author Bernhard Schussek <bschussek@gmail.com>
 */
class HttpFoundationRequestHandler implements RequestHandlerInterface
{
    // ...
    
    /**
     * {@inheritdoc}
     */
    public function handleRequest(FormInterface $form, $request = null)
    {
        // ...
        
        $form->submit($data, 'PATCH' !== $method);
    }
    
    // ...
}

https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationRequestHandler.php#L108

HTTPメソッドがPATCHの場合は強制的にclearMissingがfalseになっています。

解決策: PATCHを使わない

コントローラと機能テストをPATCHを使わない形に書き換えてみます。

<?php


namespace App\Controller;

use App\Entity\User;
use App\Form\Type\UserUpdateType;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class UserController
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var FormFactoryInterface
     */
    private $formFactory;

    /**
     * @param EntityManagerInterface $em
     * @param FormFactoryInterface $formFactory
     */
    public function __construct(EntityManagerInterface $em, FormFactoryInterface $formFactory)
    {
        $this->em = $em;
        $this->formFactory = $formFactory;
    }

    /**
     * @ParamConverter(name="user", class="App:User")
     * @Route("/user/{id}")
-     * @Method("PATCH")
+     * @Method("PUT")
    */
    public function updateAction(Request $request, User $user)
    {
        $form = $this->formFactory->createNamed('', UserUpdateType::class, $user, [
-            'method' => 'PATCH',
+            'method' => 'PUT',
        ]);
        $form->handleRequest($request);

        if ($form->isValid()) {
            $this->em->flush();

            return new Response('success!');
        }

        return new Response('failed!');
    }
}
<?php

namespace App\Controller;

use App\Entity\User;
use Liip\FunctionalTestBundle\Test\WebTestCase;

class UserControllerTest extends WebTestCase
{
    public function setUp()
    {
        parent::setUp();

        $this->loadFixtureFiles([
            __DIR__.'/../fixtures/user.yml',
        ]);
    }

    public function test()
    {
        $client = static::createClient();
-        $client->request('PATCH', '/user/1', [
+        $client->request('PUT', '/user/1', [
            'name' => 'user1',
            'roles' => ['ROLE_ADMIN', 'ROLE_USER'],
        ]);

        $response = $client->getResponse();
        $this->assertTrue($response->isOk(), $response->getStatusCode());

        $em = $this->getContainer()->get('doctrine')->getManager();
        $em->clear();
        $updatedUser = $em->find(User::class, 1);
        $this->assertEquals(['ROLE_ADMIN', 'ROLE_USER'], $updatedUser->getRoles());
    }
}

テストを実行してみます。

$ vendor/bin/phpunit -c ./
PHPUnit 6.5.2 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 2.49 seconds, Memory: 30.00MB

OK (3 tests, 8 assertions)

通るようになりました!

余談: なぜそうなっているか?

私の推測ですが、フォームについて

  • エンティティに対してFormTypeは一つ
  • PATCHで部分更新したり、POST, PUTで全体更新したりする
  • PATCHのときはsubmit()される値が不完全だが、submitされてない値をクリアしたくない

という使い方が想定されているのではないでしょうか? 今回のサンプルコードのように、編集時の項目構成で作ったフォームにPATCHで値を送信する使い方はイレギュラーなのかもしれません。

まとめ

なぜかうまくいかないなーと思った時、ソースをどんどん読み込んでいけば解決できるのがオープンソースの良いところです。
Symfonyで開発していて「謎現象?!」と思ったら、怖がらずソースを読んでみましょう。(手動で追っていくのは骨が折れるのでIDEの定義ジャンプ機能を活用したいですね)