LoginSignup
2
2

More than 1 year has passed since last update.

SymfonyのFormTypeでAPIリクエストを受ける

Posted at

前置き

SymfonyでFormTypeを使うとき、Controllerでは以下のように書くことが多いです。

    #[Route('/', name: 'list', methods: ['POST'])]
    public function index(Request $request): Response
    {
        $data = new Some();
        $form = $this->createForm(SomeType::class, $data)->handleRequest($request);
        if (!$form->isValid()) {
            ...
        }
        ...
        return $this->json(....);
    }

しかし、実はこの形式でデータがSomeDataにバインドできるのは、リクエストのContent-Typeapplication/x-www-form-urlencodedmultipart/form-dataの時だけです。なので、APIリクエストなどでContent-Typeapplication/jsonの時は期待するようにデータがバインドされません。

どうするか

リクエストをDTOにマッピングするためのトリガーは、上の例のhandleRequest($request)この部分です。このhandleRequest()が呼ばれると、もろもろの処理が走り最終的にSymfony\Component\Form\Extension\HttpFoundation\HttpFoundationRequestHandler::handleRequest()が呼び出されます。実装は以下の通りです。

...
public function handleRequest(FormInterface $form, $request = null)
    {

        if (!$request instanceof Request) {
            throw new UnexpectedTypeException($request, 'Symfony\Component\HttpFoundation\Request');
        }

        $name = $form->getName();
        $method = $form->getConfig()->getMethod();

        if ($method !== $request->getMethod()) {
            return;
        }

        // For request methods that must not have a request body we fetch data
        // from the query string. Otherwise we look for data in the request body.
        if ('GET' === $method || 'HEAD' === $method || 'TRACE' === $method) {
            if ('' === $name) {
                $data = $request->query->all();
            } else {
                // Don't submit GET requests if the form's name does not exist
                // in the request
                if (!$request->query->has($name)) {
                    return;
                }

                $data = $request->query->all()[$name];
            }
        } else {
            // Mark the form with an error if the uploaded size was too large
            // This is done here and not in FormValidator because $_POST is
            // empty when that error occurs. Hence the form is never submitted.
            if ($this->serverParams->hasPostMaxSizeBeenExceeded()) {
                // Submit the form, but don't clear the default values
                $form->submit(null, false);

                $form->addError(new FormError(
                    $form->getConfig()->getOption('upload_max_size_message')(),
                    null,
                    ['{{ max }}' => $this->serverParams->getNormalizedIniPostMaxSize()]
                ));

                return;
            }

            if ('' === $name) {
                $params = $request->request->all();
                $files = $request->files->all();
            } elseif ($request->request->has($name) || $request->files->has($name)) {
                $default = $form->getConfig()->getCompound() ? [] : null;
                $params = $request->request->all()[$name] ?? $default;
                $files = $request->files->get($name, $default);
            } else {
                // Don't submit the form if it is not present in the request
                return;
            }

            if (\is_array($params) && \is_array($files)) {
                $data = array_replace_recursive($params, $files);
            } else {
                $data = $params ?: $files;
            }
        }

        // Don't auto-submit the form unless at least one field is present.
        if ('' === $name && \count(array_intersect_key($data, $form->all())) <= 0) {
            return;
        }

        $form->submit($data, 'PATCH' !== $method);
    }
...

このメソッドでは、HTTPメソッドで場合わけをして、リクエストからデータを取得しFormに渡すという処理をしています。
この時、Content-Typeapplication/x-www-form-urlencodedmultipart/form-dataであることを前提としているため、APIリクエストの場合はDTOへのバインドが行えません。

なので、APIリクエストからFormへデータを渡せるようなハンドラーを自作してしまえばOKです。
実装としては以下のような形になるかと思います。

<?php

declare(strict_types=1);

namespace App\Form\Extension;

use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\RequestHandlerInterface;
use Symfony\Component\Form\Util\ServerParams;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\Request;

class ApiRequestHandler implements RequestHandlerInterface
{
    private ServerParams $serverParams;

    public function __construct(ServerParams $serverParams = null)
    {
        $this->serverParams = $serverParams ?? new ServerParams();
    }

    public function handleRequest(FormInterface $form, $request = null)
    {
        if (!$request instanceof Request) {
            throw new UnexpectedTypeException($request, Request::class);
        }

        $method = $form->getConfig()->getMethod();
        if ($method !== $request->getMethod()) {
            return;
        }

        if (in_array($method, ['GET', 'HEAD', 'TRACE'])) {
            $data = $request->query->all();
        } else {
            if ($this->serverParams->hasPostMaxSizeBeenExceeded()) {
                $form->submit(null, false);
                $form->addError(new FormError(
                    $form->getConfig()->getOption('upload_max_size_message')(),
                    null,
                    ['{{ max }}' => $this->serverParams->getNormalizedIniPostMaxSize()]
                ));

                return;
            }

            $data = json_decode($request->getContent(), true);
        }

        $form->submit($data);
    }

    /**
     * {@inheritdoc}
     */
    public function isFileUpload($data): bool
    {
        return $data instanceof File;
    }
}

本家との違いは

  • formのnameによる検証を削除
  • POSTの場合は、リクエストボディをjsonデコードしたものをFormへの入力とする

の2点です。

FormTypeでこのハンドラーを利用する場合は以下のように$builderに設定します。

class SomeType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('hoge', TextType::class);
        ....
        $builder->setRequestHandler(new ApiRequestHandler());
    }

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

実際の運用としては、ApiTypeのようなものを作っておき、parentにそちらを指定するようにすると、使い回しができるのでスッキリすると思います。

ApiType
class ApiType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->setRequestHandler(new ApiRequestHandler());
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(['csrf_protection' => false]);
    }
}
class SomeType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('hoge', TextType::class);
    }

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

    public function getParent()
    {
        return ApiType::class;
    }
}
2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2