Skip to content

[RFC] Mapping query, path and header parameters to a DTO #7112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
joelwurtz opened this issue Apr 24, 2025 · 4 comments
Open

[RFC] Mapping query, path and header parameters to a DTO #7112

joelwurtz opened this issue Apr 24, 2025 · 4 comments

Comments

@joelwurtz
Copy link
Contributor

joelwurtz commented Apr 24, 2025

This RFC propose to allow using a class to declare parameters, and map those parameters to an object of this class.

As an example we could have a parameters class like this :

use App\Entity\Store;

use ApiPlatform\Metadata\QueryParameter;
use ApiPlatform\Metadata\PathParameter;
use ApiPlatform\Metadata\HeaderParameter;
use Symfony\Component\Validator\Constraints as Assert;

class GetBookForStoreParameters
{
    #[QueryParameter]
    #[Assert\Range(min: 1, max: 100)] // it is possible to declare constraint
    public int $page; // this parameter has no default value, which means it is required, if it is not present then it will return a 400 response

    #[QueryParameter(name: 'max')] 
    public int $maxItemsPerPage = 100; // there is a default value, so it's not required

    #[PathParameter(name: 'id', security('is_granted("ROLE_GET_BOOK")')]
    public Store $store;  // here the store is a doctrine entity and it would use a provider to fetch this, we could also link security check on a parameter

    #[QueryParameter(provider: AuthorFromNameProvider::class)]
    public ?Author $author = null; // Here we use a custom provider to fetch the author from name

    #[HeaderParameter(name: 'X-Custom-Header')]
    #[Assert\Length(max: 100)]
    public string $customHeader = '';
}

Then we could use this class to declare parameters of an endpoint :

#[ApiResource(
    operations: [
        new GetCollection(
            uriTemplate: '/store/{id}/books',
            parameters: GetBookForStoreParameters::class,
            provider: GetBookForStoreProvider::class,
        ),
    ],
)]
class Book
{
   // ...
}

Then in our provider instead of having to use the $uriVariables or request field in context we could directly use this object

class GetBookForStoreProvider implements ProviderInterface
{
    
    public function provide(Operation $operation, GetBookForStoreParameters: $parameters): array
    {
         // At this point i'm sure that our parameters are valid and that security on them has been checked so i can safely use the store
         $books = $this->service->getBooksForStoreAndMaybeAuthor($parameters->store, $parameters->author);

         return $books;
    }
}

This allow to :

  • unify uriVariables / parameters / headers declaration into the same object
  • add a way to link an object to a parameter (without the fromClass / toProperty which are vague IMO see [RFC] Add a system for getting parent resource and play security #7107)
  • provide security easily on those parameters
  • provide better static analysis (like a parameter has been removed but was still used in a critical place)
  • better reusability of same parameters across operations (by using traits and / or inheritance)

Not sure how BC could be supported, but i'm sure we can find something

@mrossard
Copy link
Contributor

That's a very interesting proposal!

I'm not sure the concept of a provider for the Author object works as is though, or at least it probably makes things work very differently from what happens now.

What i expect to happen at the sql level is something like :

select b.* 
from books b 
join author a on a.id = b.author_id
where a.name = :name

If you use a separate provider to fetch the store first, you'll end up making multiple queries instead. The provider could be a LinksHandler of some sort, but then sharing this "parameter class" between apiresources gets difficult.
Maybe one option could be not to have the GetBookForStoreParameters carry the linking logic, but add an attribute on the member of Books (or any other apiresource that uses GetBookForStoreParameters ). You could have something like (syntax to be worked on...)

#[ApiResource(
    operations: [
        new GetCollection(
            uriTemplate: '/store/{id}/books',
            parameters: GetBookForStoreParameters::class,
            provider: GetBookForStoreProvider::class,
        ),
    ],
)]
class Book
{
//...
#[Parameter(from: 'author', link: self::addJoinForAuthor(...)]
public Author $author;

The remaining difficulty is that security is also likely to be difficult to reuse, and i don't see an easy solution for this one.

@joelwurtz
Copy link
Contributor Author

I'm not sure the concept of a provider for the Author object works as is though, or at least it probably makes things work very differently from what happens now.

In my example i consider that author and book may not comes from the same database, like you are fetching book from elasticsearch that only have the author id, and so it must be fetched before hand

But in your use case, i imagine that it should be up to the user to do that, if both data are in the same database, and that parameters are link (but more filters) then he could just expect a string for author, and use this string in its custom book provider without having to think about link ?

I really don't use that much link so maybe my vision is biased, but IMO you just always need to write a provider that do your own logic when you want something more complex than just a fetch with an id. I find this straightforward and avoid having business logic determined by configuration and more by plain code which is easier to test and understand IMO.

@mrossard
Copy link
Contributor

I'm not sure the concept of a provider for the Author object works as is though, or at least it probably makes things work very differently from what happens now.

In my example i consider that author and book may not comes from the same database, like you are fetching book from elasticsearch that only have the author id, and so it must be fetched before hand

AFAIK the most common use case currently is that links or filters are basically ways to customize the query used to fetch your resources, so introducing "intermediary" objects is a big shift.

But in your use case, i imagine that it should be up to the user to do that, if both data are in the same database, and that parameters are link (but more filters) then he could just expect a string for author, and use this string in its custom book provider without having to think about link ?

You'd be reimplementing filters by hand in custom providers? That would be a major step back!

I really don't use that much link so maybe my vision is biased, but IMO you just always need to write a provider that do your own logic when you want something more complex than just a fetch with an id. I find this straightforward and avoid having business logic determined by configuration and more by plain code which is easier to test and understand IMO.

I have a different experience indeed. I do use custom providers a LOT, but on the "getting data" side most of them only need to call the default provider (sometimes with reworked uriVariables/context). The largest amount of work they do is mapping the result to the actual ApiResource I want.

@soyuka
Copy link
Member

soyuka commented Apr 30, 2025

You don't need toProperty these are only to make Doctrine joins using a path algorithm. Note that there's already everything needed.

  • provide security easily on those parameters

You can declare security on parameters and on uri variables. Using a DTO for these is nice but I'm not sure it's that easy to implement. URI Variables and parameters are quite distinct in their usage and representations across standards (graphql, json-ld etc.). A viable solution would be to have a DTO for parameters and one for uri variables. Though, as of today you already have both as array<string, Parameter> and array<string, Link>.

This is also quite hard to do:

    #[PathParameter(name: 'id', security('is_granted("ROLE_GET_BOOK")')]
    public Store $store;  // here the store is a doctrine entity and it would use a provider to fetch this, we could also link security check on a parameter

    #[QueryParameter(provider: AuthorFromNameProvider::class)]
    public ?Author $author = null; // Here we use a custom provider to fetch the author from name

As you may want more then one uri variable to fetch data, it's what we tried to achieve with the sub-resource automatic system and in the end it's quite hard to use. It's always easier to create a LinksHandler. In API Platform a single URI is tight to a single Provider, using providers from other operations in another URI will likely encourage bad practices and bad performances (it's what GraphQl kinda does).

I'll sleep on it though I like the approach (today it'd mean adding a provider to our Link, it's already available inside Parameter).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants