Clean controllers in Symfony (III): request handling
Tags: symfony, php, api, backend
Published: 2023-10-11 08:00:00

## 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:
        ```json
        {
            "title": "Life among the Indians",
            "author": "George Catlin"
        }
        ```
- Valid response
    - HTTP code: `201` (created)
    - No content
- Invalid response
    - Codi HTTP: `422`
    - Content:
        ```json
        {
            "error": "Provided Title provided is empty"
        }
        ```

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

```php
<?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;
    }
}
```

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`](https://symfony.com/blog/new-in-symfony-6-3-mapping-request-data-to-typed-objects) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k1oq5b9za7ahqu82ugwz.png)

We see that the kernel calls point 4 before executing the controller: it is the place where [the controller arguments are resolved](https://symfony.com/doc/current/components/http_kernel.html#4-getting-the-controller-arguments).

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 [`ValueResolverInterface`](https://github.com/symfony/symfony/blob/6.2/src/Symfony/Component/HttpKernel/Controller/ValueResolverInterface.php)[^1].

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](https://symfony.com/doc/current/validation.html).

## Implementation

### `APIRequestBody`

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

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Symfony\Http\Request;

interface APIRequestBody
{
}
```

### `CreateBookRequestBody`

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

```php
<?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
    ) {
    }
}
```

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`](https://valinor.cuyz.io/latest/) library to deserialize the request[^2]. We will also need to install [Symfony's Validator Component](https://symfony.com/doc/current/validation.html).

The implementation is:

```php
<?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;
    }
}

```

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
<?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,
            )
        );
    }
}
```

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`](https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/HttpKernel/Controller/ArgumentValueResolverInterface.php).

[^2]: They recently released a Symfony bundle of the library: [cuyz/valinor-bundle](https://github.com/CuyZ/Valinor-Bundle).