core icon indicating copy to clipboard operation
core copied to clipboard

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

Open joelwurtz opened this issue 10 months ago • 4 comments

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 https://github.com/api-platform/core/issues/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

joelwurtz avatar Apr 24 '25 14:04 joelwurtz

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.

mrossard avatar Apr 25 '25 07:04 mrossard

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.

joelwurtz avatar Apr 25 '25 07:04 joelwurtz

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.

mrossard avatar Apr 25 '25 08:04 mrossard

  • 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)

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).

soyuka avatar Apr 30 '25 08:04 soyuka

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Jun 29 '25 09:06 stale[bot]

There are good news on this with #7200 you can now use a parameter provider that calls an IriConverter or actually any Provider you want (link an operation and it's call its provider, works best with single identifiers though everything is customizable).

soyuka avatar Jun 29 '25 12:06 soyuka