symfony: [HttpKernel] #[MapQueryString] with an empty query string always returns null
Symfony version(s) affected
6.3.0
Description
RequestPayloadValueResolver always returns null when mapping query strings and query string is empty.
This is problematic when using a DTO where all arguments have default values, e.g.
class MyDto {
public function __construct(public string $foo = "bar")
{}
}
How to reproduce
Controller:
class MyDto {
public function __construct(public string $foo = "bar")
{}
}
class FooController extends AbstractController
{
#[Route("/foo", name: "foo", methods: ["GET"])]
public function getPaginated(
#[MapQueryString] MyDto $myDto,
): JsonResponse
{
return $this->json($myDto);
}
}
GET /foo -> 404 HttpException
GET /foo?foo=baz -> 200 {"foo": "baz"}
Possible Solution
Remove this early return and let the serializer decide what to do with an empty query string.
Additional Context
No response
About this issue
- Original URL
- State: closed
- Created a year ago
- Reactions: 3
- Comments: 37 (22 by maintainers)
I think the confusion is due to
MapRequestPayload.As everyone has already said in this thread, with
MapQueryStringand the following:The request on
GET /getwill result in a404 HttpExceptionerror.But, in case of a POST request with
MapRequestPayloadand the following:The request on
POST /postwith an empty JSON object will correctly respond:This different behavior between
MapQueryStringandMapRequestPayloadis pretty confusing.@nicolas-grekas Of course. We have class PaginationDTO with 2 properties page and limit. And we wanna use it as request object with validation constraints in Controller. Let’s look at a few situations:
#[MapQueryString] PaginationDTO $queryand we can’t set default value for PaginationDTO because properties need values in constructor.#[MapQueryString] PaginationDTO $queryand we can’t set default value for PaginationDTO because property limit needs value in constructor.#[MapQueryString] PaginationDTO $query = new PaginationDTO()If we used PaginationDTO in many controllers, then in situation 3 we should set the default value everywhere. And if in the future we add a new property into PaginationDTO without default value, then we will have to remove the default value for PaginationDTO in all these controllers.
As the
MapQueryStringfunction description says:[...] map the query string of the request to typed object and validate it.So I expect that the validator will run, and that I will get the appropriate violations based on asserts in DTO class. But now asserts and custom validators don’t run when there are no GET parameters.I wouldn’t let the serializer define the behavior to have when empty query-string or payload happens. This would be unexpected and too implicit.
Instead, I’d suggest to fallback to the default value of the argument (which can be an empty array, object or whatever).
Here is what I have in mind. Anyone to turn that into a PR with a test case?
I still don’t understand sorry:
use
#[MapQueryString] PaginationDTO $query = new PaginationDTO(page: 1, limit: 10)use
#[MapQueryString] PaginationDTO $query = new PaginationDTO(limit: 10)Yes. That’s the only issue that I understand from your description: the repetition. I’m not sure it deserves changing the way controllers work. Auto-creating a DTO as you describe would also make it more complex to express that providing a query string is mandatory.
@blaugueux Yes, I will try =)
The fact that there’s a serializer behind the scene is an implementation detail. What matters is that this is a regular controller. Default values on controller arguments are used for that.
Why is that unexpected and too implicit? It would align with how the serializer works everywhere else. As per the serializer component documentation, all you need to deserialize is:
So the criteria would be fully met here when you have an empty array + a class with all default values, especially when adding skipping of undefined properties to the default deserialization context.
And in my opinion, that is more explicit than having to remember the special case of having to add a default value to the controller arguments for when the query is empty.
I understood what you mean, I am just of the opinion that the serializer should have the say what happens here. Otherwise you assume that nullable + empty query means you always want to get null. But that might now always be the case.
Then you should model your DTO to not be creatable from an empty array, or configure the serializer accordingly to not accept this input. By returning null however the choice on how to handle this is taken away.
Because in this way we always must control the situation - Does our request class have only optional properties or not? And when we add a new property without a default value, we won’t be able to define that class as the default value in the controller.
Why don’t you use the default value?
#[MapQueryString] PaginationDTO $query = new PaginationDTOI tested it with your code and it works. Maybe you changed the code in the wrong place? I changed this block https://github.com/symfony/http-kernel/blob/6.3/Controller/ArgumentResolver/RequestPayloadValueResolver.php#L138
#I have the same problem as @blaugueux here. Why is this happening? - Because if you have a request class with only optional parameters, then anyway you will get here null when call endpoint without parameters.
A code example
I think the solution would be to try to initialise the request object if the payload is null. And if we can do it, then our object has a default value. For example like this
There we go, took your implementation @nicolas-grekas and added some tests that should cover everything I think.
sure, but it implies null can be given somehow, which never happens
please go ahead 🙏
DefaultValueResolver has the answer: it should take the default value.