DEV Community

Cover image for Clean controllers in Symfony (III): request handling
Rubén Rubio
Rubén Rubio

Posted on

Clean controllers in Symfony (III): request handling

Introduction

In the previous posts of this series, we have seen how to simplify controllers that act as queries, i.e., that only return data. But what about controllers that represent commands, namely, actions that modify our system?

Suppose we have the following endpoint to create a book:

  • Url: POST /books
  • Request:

    • Content:

      {
          "title": "Life among the Indians",
          "author": "George Catlin"
      }
      
  • Valid response

    • HTTP code: 201 (created)
    • No content
  • Invalid response

    • Codi HTTP: 422
    • Content:

      {
          "error": "Provided Title provided is empty"
      }
      

The controller that handles the request to create a book is:

<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Command\Book\CrearBookCommand;
use rubenrubiob\Infrastructure\CommandBus\CommandBus;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

use function array_key_exists;
use function is_string;

final readonly class CreateBookController
{
    private const KEY_TITLE = 'title';
    private const KEY_AUTHOR = 'author';

    public function __construct(private CommandBus $commandBus)
    {
    }

    public function __invoke(Request $request): void
    {
        $requestContent = $this->parseAndGetRequesContent($request);

        $this->commandBus->__invoke(
            new CrearBookCommand(
                $requestContent[self::KEY_TITLE],
                $requestContent[self::KEY_AUTHOR],
            )
        );
    }

    /**
     * @return array{
     *      title: string,
     *      author: string,
     *     ...
     * }
     *
     * @throws BadRequestHttpException
     */
    private function parseAndGetRequesContent(Request $request): array
    {
        $requestContent = $request->toArray();

        if (!array_key_exists(self::KEY_TITLE, $requestContent)) {
            throw new BadRequestHttpException('Missing "title"');
        }

        if (!is_string($requestContent[self::KEY_TITLE])) {
            throw new BadRequestHttpException('"title" format is not valid');
        }

        if (!array_key_exists(self::KEY_AUTHOR, $requestContent)) {
            throw new BadRequestHttpException('Missing "author"');
        }

        if (!is_string($requestContent[self::KEY_AUTHOR])) {
            throw new BadRequestHttpException('"author" format is not valid');
        }

        return $requestContent;
    }
}
Enter fullscreen mode Exit fullscreen mode

We see that we need to validate the JSON of the request to ensure that it has all the required fields and that they all have a valid format.

Thus, we need to perform two operations on the request: deserialize it and validate it.

In this post, we will take advantage again of Symfony kernel events to centralize request deserialization and validation to keep our controllers clean.

[DISCLAIMER] Starting in version 6.3, you can directly use the MapRequestPayload attribute and skip this post, as it solves the problem at hand. Keep reading if you want to learn more about the internals of Symfony's kernel.

For previous Symfony versions, it is important to note that the implementation we will see varies between Symfony versions.

Resolve arguments event

As we explained in previous posts, Symfony's kernel is driven by events where we can act:

Source: Symfony documentation

We see that the kernel calls point 4 before executing the controller: it is the place where the controller arguments are resolved.

Internally, the kernel executes a controller, that is a callable, passing it an array of arguments. For each of these arguments, Symfony calculates its value using services that implement the ValueResolverInterface1.

For example, Symfony includes an implementation of ValueResolver that checks if any of the arguments of the controller is of type Request, and injects the current request in case it is.

What we will do is extend this resolution of arguments to inject objects that represent our requests, so they will already be validated using the Validator Component.

Implementation

APIRequestBody

We will have an empty interface that represents all our command requests:

<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Symfony\Http\Request;

interface APIRequestBody
{
}
Enter fullscreen mode Exit fullscreen mode

CreateBookRequestBody

For this concrete case, we will have an object that represents the request for creating a book:

<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Request;

use rubenrubiob\Infrastructure\Symfony\Http\Request\APIRequestBody;
use Symfony\Component\Validator\Constraints as Assert;

final readonly class CreateBookRequestBody implements APIRequestBody
{
    public function __construct(
        #[Assert\NotBlank(normalizer: 'trim')]
        public string $title,
        #[Assert\NotBlank(normalizer: 'trim')]
        public string $author
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

Both properties of the request have a NotBlank attribute with a trim normalizer to validate their values. As this class is in fact a DTO, both attributes are readonly and public.

APIRequestResolver

Now it is time to implement our ValueResolverInterface, which will transform an arbitrary request into an APIRequestBody‌.

We will use the cuyz/valinor library to deserialize the request2. We will also need to install Symfony's Validator Component.

The implementation is:

<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Symfony\Http\Request;

use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Mapper\Source\Exception\InvalidSource;
use CuyZ\Valinor\Mapper\Source\Source;
use CuyZ\Valinor\Mapper\TreeMapper;
use ReflectionClass;
use ReflectionException;
use rubenrubiob\Infrastructure\Symfony\Http\Exception\InvalidRequest;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Validator\Validator\ValidatorInterface;

use function count;

final readonly class APIRequestResolver implements ValueResolverInterface
{
    public function __construct(
        private TreeMapper $treeMapper,
        private ValidatorInterface $validator,
    ) {
    }

    /**
     * @return iterable<APIRequestBody>|iterable<null>
     *
     * @throws InvalidRequest
     */
    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        /** @var class-string|null $class */
        $class = $argument->getType();

        if (! $this->supports($class)) {
            return [null];
        }

        try {
            $request = $this->treeMapper->map(
                $class,
                Source::json($request->getContent())->camelCaseKeys(),
            );
        } catch (MappingError|InvalidSource) {
            throw InvalidRequest::createFromBadMapping();
        }

        $errors = $this->validator->validate($request);

        if (count($errors) > 0) {
            throw InvalidRequest::fromConstraintViolationList($errors);
        }

        yield $request;
    }

    /**
     * @param class-string|null $class
     *
     * @psalm-assert-if-true class-string<APIRequestBody> $class
     * @phpstan-assert-if-true class-string<APIRequestBody> $class
     */
    private function supports(?string $class): bool
    {
        if ($class === null) {
            return false;
        }

        try {
            $reflection = new ReflectionClass($class);

            if ($reflection->implementsInterface(APIRequestBody::class)) {
                return true;
            }
        } catch (ReflectionException) {
        }

        return false;
    }
}

Enter fullscreen mode Exit fullscreen mode

The supports method is where we tell the framework that this ArgumentValueResolver should be used to transform all objects that implement the APIRequestBodyInterface.

The transformation and validation is performed in the resolve method:

  • The call to $this->treeMapper->map converts a JSON source (the request) to our object. If it fails, we throw an exception.
  • The call to $this->validator->validate validates the APIRequestBody object. If it fails, we throw an exception.

If we use autoconfigure in our services' definition, our ValueResolverInterface will already be added to the framework.

Simplified controller

In the last place, we can proceed to simplify our controller:

<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Command\Book\CrearBookCommand;
use rubenrubiob\Infrastructure\CommandBus\CommandBus;
use rubenrubiob\Infrastructure\Ui\Http\Request\CreateBookRequestBody;

final readonly class CreateBookController
{
    public function __construct(private CommandBus $commandBus)
    {
    }

    public function __invoke(CreateBookRequestBody $createBookRequestBody): void
    {
        $this->commandBus->__invoke(
            new CrearBookCommand(
                $createBookRequestBody->title,
                $createBookRequestBody->author,
            )
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

By type-hinting the argument as a CreateBookRequestBody, we receive the object correctly mapped and validated. As we throw an exception if the transformation or the validation fail in the ValueResolverInterface, we avoid receiving invalid requests in our controller, so we can focus on the valid path of execution. Exceptions are handled in the ExceptionSubscriber we saw in the first post of this series.

Conclusions

As we did with exception and response handling, by delegating the request handling to the framework, we end up with a simple controller.

Thus, in the controller, we can focus only on semantically valid requests. It is important to note that this does not avoid having an error during the execution of the command.

Summary

  • We saw the problem of validating requests in the controller.
  • We introduced the APIRequestBody to represent all requests we will handle in our controllers.
  • We created an implementation of an APIRequestBody with property validation.
  • We implemented a ValueResolverInterface that transforms JSON requests to an APIRequestBody and validates its properties.
  • We simplified our controllers, so they have the minimum amount of logic.

  1. Prior to Symfony 6.2, the interface to implement is ArgumentValueResolverInterface

  2. They recently released a Symfony bundle of the library: cuyz/valinor-bundle

Top comments (0)