CHANSHIGELOG

いろんなこと

SymfonyでのRequestをDTOにする

久しぶりの投稿です。業務内外問わずPHPばかり書いておりますちゃんしげです。

SymfonyやLaravelなどのフレームワークでHttpRequestからクエリ(パラメタ)を受け取る際、自前のDTOで受け取りたいと思うことが多いと思います。そうする事で、このController/Actionでは、こういった値を取り扱うと明示的に表現でき、使う時も安全に値を取り出せるメリットがあります。

Requestから直接値を取るとこうですが...

use Symfony\Component\HttpFoundation\Request;

class GreetingController
{
    #[Route(path: '/greeting', methods: ['GET'])]
    public function __invoke(Request $request): JsonResponse
    {
        $name = $request->get('name', '');

        return new JsonResponse(['greeting' => sprintf('Hello %s', $name)]);
    }
}

DTOで値を取り出すようにしたい!

use App\Domain\Greeting\Input;

class GreetingController
{
    #[Route(path: '/greeting', methods: ['GET'])]
    public function __invoke(Input $input): JsonResponse
    {
        return new JsonResponse(['greeting' => sprintf('Hello %s', $input->name())]);
    }
}

Requestから実装で値を詰め替えることなく、呼吸をするように進められそう...

Symfonyの場合は、Formという超便利なコンポーネントがあるのでそれを使えばいいじゃないの!って思いますが、よりライトに実装できないものか?と色々探っていると、公式にアクション引数をカスタムできる方法が載っていました。

symfony.com

ArgumentValueResolverInterface を実装していけばやれそう!ということで実装してみます。

まず、自前のDTOだよ!と認識させるためにInterfaceを用意します。

interface RequestObjectInterface
{
}

続けて、上記例のURI/greetingで取り扱う値'name'DTOを作ります。

use App\Service\RequestObjectInterface;
use Symfony\Component\Validator\Constraints as Assert;

final class Input implements RequestObjectInterface
{
    public function __construct(
        #[Assert\Length(min: 8, max: 40)] private ?string $name
    ) {
    }

    public function name(): ?string
    {
        return $this->name;
    }
}

この段階で /greeting で受け取るパラメタは?name=chanshigeだけとわかり、かつ8-40文字か!ックーーーッとなります。

次が本命 ArgumentValueResolverInterface を実装していきますが、値のバリデートも併せていれちゃいます。

※ バリデートに Symfony Validatorを利用します

$ composer require symfony/validator

※ クエリの配列を、指定したオブジェクトに詰め替える Hydratorも使います

$ composer require chanshige/object-hydrator

実装!

use Chanshige\Hydrator\ObjectHydratorInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

use function is_subclass_of;

class RequestObjectResolver implements ArgumentValueResolverInterface
{
    public function __construct(
        private ValidatorInterface      $validator,
        private ObjectHydratorInterface $hydrator
    ) {
    }

    public function supports(Request $request, ArgumentMetadata $argument): bool
    {
        return is_subclass_of($argument->getType(), RequestObjectInterface::class);
    }

    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        $dto = $this->hydrator->hydrate(
            $request->query->all(),
            sprintf('\\%s', $argument->getType())
        );

        $errors = $this->validator->validate($dto);
        if ($errors->count() > 0) {
            throw new HttpException(404, (string) $errors);
        }

        yield $dto;
    }
}

supports() メソッドはRequestObjectInterfaceを実装したDTOであることを判定し、resolve() ではその場合に処理したい内容を書いています。

これらを有効にするため config/services.yaml に追記すれば完成です。

# config/services.yaml
    App\Service\RequestObjectResolver:
        tags:
            - { name: controller.argument_value_resolver, priority: 50 }

controller.argument_value_resolver というタグをつけ、期待する注入が行われるようにpriorityを設定します。 ※ 優先度の追加はオプションとのこと

こちらは ObjectHydratorInterface の解決

# config/services.yaml
    Chanshige\Hydrator\ObjectHydratorFactory: ~
    Chanshige\Hydrator\ObjectHydratorInterface:
        factory: ['@Chanshige\Hydrator\ObjectHydratorFactory', newInstance]

実際にリクエストしてみると...

# /greeting?name=chanshige

{
"greeting": "Hello chanshige"
}

※ エラーはこうなります。

{
  "status": 404,
  "detail": "Object(App\\Domain\\Greeting\\Input).name:\n    This value is too short. It should have 8 characters or more. (code xxx)\n"
}

素晴らしい!

こうすることで、コントローラーでのリクエスト処理を簡素化することができ、冒頭でも記載した通り非常に仕様把握もしやすく、そして安全に実装ができるようになるなとおもいます。

最後に

このような思いつきを、自前でトリッキーな実装をせずとも公式かつ合理的な方法で表現できるSymfonyはマジですごいです。結果的には素直にForm使えばいいなって思いましたが、今後も色々なアプローチを試していきたいと思います。