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
- HTTP code:
-
Invalid response
- Codi HTTP:
422
-
Content:
{ "error": "Provided Title provided is empty" }
- Codi HTTP:
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;
}
}
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:
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 ValueResolverInterface
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.
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
{
}
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
) {
}
}
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 is3:
<?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 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
{
return is_subclass_of($class, APIRequestBody::class);
}
}
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,
)
);
}
}
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 anAPIRequestBody
and validates its properties. - We simplified our controllers, so they have the minimum amount of logic.
-
Prior to Symfony 6.2, the interface to implement is
ArgumentValueResolverInterface
. ↩ -
They recently released a Symfony bundle of the library: cuyz/valinor-bundle. ↩
-
Updated in August of 2024 to use
is_subclass_of
instead of reflection insupports
method. ↩
Top comments (0)