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);
}
// ...
}
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の定義ジャンプ機能を活用したいですね)