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という超便利なコンポーネントがあるのでそれを使えばいいじゃないの!って思いますが、よりライトに実装できないものか?と色々探っていると、公式にアクション引数をカスタムできる方法が載っていました。
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使えばいいなって思いましたが、今後も色々なアプローチを試していきたいと思います。