Want to map requests to typed objects automatically? Have a look at argument resolvers and give our implementation a try!
- Autor
- Michael Zangerle
- Datum
- 5. August 2021
- Lesedauer
- 4 Minuten
Symfony allows to inject the current request in a controller action and can thereby easily access the current payload and process it. But wouldn’t it be great to have typed objects instead of the generic arrays from the request to work with? And wouldn’t it be even better if those objects get generated automatically so you don’t even need to write the mapping logic manually? While we are at it, could we also validate the generated objects before we work with them?
Turns out that’s possible and Symfony provides all the tools we need for it.
Argument resolver
In Symfony there exists a thing called argument resolvers. They can be used to set the value of controller action arguments before the actions get called. There exists for example the RequestValueResolver which will inject the current request as an argument in the called action. Similar to this we created our own argument resolver to create endpoint specific objects for the given request.
How does it work?
- Install
fusonic/http-kernel-extensions
- Register the argument resolver and thereby let Symfony know that you have an additional one that should be used when trying to inject the right object.
1 services:
2 Fusonic\HttpKernelExtensions\Controller\RequestDtoResolver:
3 tags:
4 - { name: controller.argument_value_resolver, priority: 50 }
3. Create your transfer object. Here is an example
<?php
// ...
final class UpdateFooDto
{
private int $id;
private string $clientVersion;
private array $browserInfo;
public function getClientVersion(): string
{
return $this->clientVersion;
}
public function setClientVersion(string $clientVersion): void
{
$this->clientVersion = $clientVersion;
}
public function getBrowserInfo(): array
{
return $this->browserInfo;
}
public function setBrowserInfo(array $browserInfo): void
{
$this->browserInfo = $browserInfo;
}
public function getId(): int
{
return $this->id;
}
public function setId(int $id): void
{
$this->id = $id;
}
}
What’s happening when this action is called is that Symfony will go through all argument resolvers and ask them if they do support an argument with the type UpdateFooDto. All but the RequestDtoResolver will say no and for that, the attribute #[FromRequest]
is used.
With this information, the resolver now tries to construct an object with the given argument type from the request. The serializer component of Symfony is used for that. If the serializer can successfully do its work you will end up with a new object. If not, it will throw an error and tell you what’s wrong.
Does it work for all requests?
No, it won’t work magically for all and every request. There are some limitations and decisions we made to make it work for our use cases. Currently, the following things are supported:
- Strong type checks will only be done for
PUT
,POST
,PATCH
andDELETE
during serialization and it will result in an error if the types in the request body don’t match the expected ones in the DTO - Type enforcement is disabled for all other requests e.g.
GET
as query parameters will always be transferred as strings. - The request body will be combined with route parameters for
PUT
,POST
,PATCH
andDELETE
requests (query parameters will be ignored in this case). - The query parameters will be combined with route parameters for all other requests (the request body will be ignored in this case).
- Route parameters will always override query parameters or request body values with the same name.
- Currently, only JSON is supported as payload format.
But what about validation?
To add validation it is enough to add the normal validation annotations to the DTO as you are used to. If the mapping from the request to the object was successful, the validation will be done and return an error if it’s not valid before it’s injected into your controller action.
<?php
// ...
final class UpdateFooDto
{
#[Assert\NotNull]
private int $id;
#[Assert\NotNull]
private string $clientVersion;
#[Assert\NotNull]
private array $browserInfo;
// ...
}
A default error handling is provided but it can be customized by implementing the ErrorHandlerInterface and register the implementation as a service.
Interested?
If you are interested in the details and want to know how it works have a look at the docs and the code here.