Skip to content

[RFC] Add request specific annotations #36037

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

Closed
tsantos84 opened this issue Mar 12, 2020 · 29 comments
Closed

[RFC] Add request specific annotations #36037

tsantos84 opened this issue Mar 12, 2020 · 29 comments
Labels
Feature RFC RFC = Request For Comments (proposals about features that you want to be discussed) Stalled

Comments

@tsantos84
Copy link
Contributor

tsantos84 commented Mar 12, 2020

Description

Currently Symfony provides natively @Route an @Method annotations to map requests to controllers. In addition, it injects automatically request attributes to controller in a convenient way. I think that it could be improved to inject another request data through annotations. I know that this can be accomplished through ParamConverters, but I see that feature as a generic way to inject any type of information to controllers (e.g DateTime). The examples bellow shows exactly what I'm proposing to be part of Symfony's Core.

One of the benefits of this proposal is this more expressive and transparent to newcomers instead of ParamConverters.

Please, don't miss understand this proposal as this is not a replace to ParamConverters. Is just to add specific annotations to deal with request data. It means that DateTime, granted user and etc will be converter through ParamConverter as well as dev's custom converters.

Proposal

Request Body

  • Pass the request content to $content variable.
/**  
 * @RequestBody("content") 
 */
 public function show(string $content) {}  
All the bellow examples uses only one argument in controllers, so omitting the argument 
name from annotation will work properly. Otherwise, the argument name should be provided.
  • A Bad Request exception will be thrown if the request content is empty if the argument is required. If the content is optional, you can make your argument to allow null and the exception will not be raised. The same principle will work for all converters.
 /** 
  * @RequestBody() 
  */
  public function show(?string $content) {}
  • If the request is a JSON/XML content (e.g the headers has application/[json/xml]) the converter will decode the content and pass it to controller. If the json/xml passed is mal formed, the proper http status code could be thrown automatically.
/**  
 * @RequestBody()
 */
 public function show(array $post) {}  
  • If the argument is type-hinted to some complex type, the converter will deserialize the content and inject the result automatically. FOS has an annotation that makes this, but I think it could be part of Symfony's core.
/**  
 * @RequestBody() 
 */
 public function show(Post $post) {}  

Query Param

  • Inject a single query param when the route /foo?foo=bar is matched.
/**  
 * @QueryParam() 
 */
 public function show(string $foo) { echo $foo; // will echo "bar"}  
  • Default value if the query param isn't passed.
/**  
 * @QueryParam() 
 */
 public function show(string $foo = 'bar') {}
  • Inject two query params
/**  
 * @QueryParam("foo")
 * @QueryParam("bar") 
 */
 public function show(string $foo, string $bar) {}  
  • Inject all query params as ParameterBag instance
/**  
 * @QueryParam()
 */
 public function show(ParameterBag $query) {}  
  • Inject all query params as plain array
/**  
 * @QueryParam(all=true)
 */
 public function show(array $query) {}  
  • Inject a complex type from query param
/**  
 * @Route("/posts?id=1")
 * @QueryParam()
 */
 public function show(Post $post) {}  

Request Header

  • Inject a single header
/**  
 * @RequestHeader(name="Content-Type") 
 */
 public function show(string $contentType) { }  
  • Default value if the header isn't passed.
/**  
 * @RequestHeader() 
 */
 public function show(string $contentType = 'application/json') {}
  • Inject two headers
/**  
 * @RequestHeader("userId", name="X-USER-ID")
 * @RequestHeader("userAgent", name="User-Agent") 
 */
 public function show(string $userId, string $userAgent) {}  
  • Inject all headers as HeaderBag
/**  
 * @RequestHeader(all=true)
 */
 public function show(HeaderBag $headers) {}  
  • Inject a complex type from header
/** 
 * @RequestHeader(name="X-POST-ID")
 */
 public function show(Post $post) {}  
@ro0NL
Copy link
Contributor

ro0NL commented Mar 12, 2020

IMHO, if you need something from the request .. then inject the request :)

it injects automatically request attributes to controller in a convenient way.

matched route parameters semantically may belong to the controller action method, thus route parameters may map to method arguments.

@tsantos84
Copy link
Contributor Author

IMHO, if you need something from the request .. then inject the request :)

Most of the times we need to get request content, decode (or deserialize) and make some validations. Take a look at FOS Request Body Converter, it does exactly what is being proposed here and the question is: why not to support that feature natively?

Spring Framework for Java also implements this feature natively.

@tsantos84
Copy link
Contributor Author

matched route parameters semantically may belong to the controller action method, thus route parameters may map to method arguments.

I agree with you that the default behavior is to map request attributes to controller arguments. Why not to extend that feature through annotations?

@ro0NL
Copy link
Contributor

ro0NL commented Mar 12, 2020

in general i like the flexibility of a param converter, rather than some fixed annotation concept.

currently, using API platform, i wrote a generic param converter that deserializes the request body. As such, im able to reuse the param converter annotation to transfer any related config (e.g. serialization groups), while still having all flexibility regarding the actual implementation (i.e. i can still compute serialization groups dynamicly in code as well).

Nevertheless, i agree deserializing and injecting the request body is a common thing. Yet we can solve it using param converters or core argument resolvers already 🤷‍♂️

I agree with you that the default behavior is to map request attributes to controller arguments. Why not to extend that feature through annotations?

because it works out-the-box already :) request headers etc. and route parameters are different things, im still 👎 for a feature to be able to inject a single header (in this case just use the request).

So for deserialzing request bodies, i tend to like

/**
 * @DeserializedRequestBody('payload', groups={"some"})
 */
function action(Request $request, SomeDto $payload) {}

or propose a generic param converter upstream.

@tsantos84
Copy link
Contributor Author

tsantos84 commented Mar 12, 2020

The proposal is not to only inject a single header or any other request param. All common validation could be performed automatically and save dev time.

public function searchAction(Request $request) {
    $term = $request->query->get('term');
    if (null === $term) {
        throw new HttpException(400);
   }

   $limit = $request->query->getInt('limit', 10);

  // perform search with $term and $limit
}

The same behavior could be accomplish with:

/**
 * @QueryParam("term")
 * @QueryParam("limit")
 */
public function searchAction(string $term, int $limit = 10) {
     // perform search with $term and $limit
}

If $term is not presented on query param, a bad request exception could be thrown and done.

Another benefit is that is clear what the controller expects and to test unit it is better.

So for deserialzing request bodies, i tend to like

I like this approach, but I really think that it isn't necessary to have low level details (e.g Deserialized) to it and, again, it should be a native feature.

@ro0NL
Copy link
Contributor

ro0NL commented Mar 12, 2020

agree, nullability (required/optional params) is another common thing we could solve. Yet we can argue a none-nullable query param should be a route argument (path param) instead ...

nevertheless, i still dont think it deserves annotations. IMHO working with the request value object / designing services around it is a better practice (Iess framework coupled).

That said, #34363 adds a new InputBag + BadRequestException to the component, and is automaticaly converted to HTTP 400 in the framework. Perhaps we can leverage this exception:

$limit = (int) $request->getOrThrow('limit');

And done. I'd prefer such an approach.

I like this approach, but I really think that it isn't necessary to have low level details (e.g Deserialized) to it and, again, it should be a native feature.

@DeserializedRequestBody would be a native feature. What your proposing is magic.. it works but we are clueless why. Also it doesnt scale in terms of being able to specify serialization groups.

It would also be unclear we're actually de-serializing from the request body.

@tsantos84
Copy link
Contributor Author

tsantos84 commented Mar 12, 2020

agree, nullability (required params) is another common thing we could solve. Yet we can argue a nullable query param should be a route argument (path param) instead.

Not sure if required query param should always path param. Multiples params is more suitable on query params than in path params. Arrays also should be query param. /?terms[]=t1&terms=[]=t2

/**
 * @QueryParam("terms")
 */
function search(array $terms) {}

nevertheless, i still dont think it deserves annotations. IMHO working with the request value object / designing services around it is a better practice (Iess framework coupled).

I don't see any problem with controller coupled with frameworks. Actually, Request $request is a form of coupling and that is okay in this perspective.

@DeserializedRequestBody would be a native feature. What your proposing is magic.. it works but we are clueless why. Also it doesnt scale in terms of being able to specify serialization groups.
It would also be unclear we're actually de-serializing from the request body.

For deserialization, the @RequestBody would have a deserialization option to inform such information, even which library will be used:

/**
 * @RequestBody(serialization_groups={})
 */
function saveAction(Post $post) {}

So, it isn't magic. Will work exactly the same way I've commented here.

Thinking in the future os PHP, if we really have native annotations, we could have:

function saveAction(<<RequestBody>> Post $post) {}

@ro0NL
Copy link
Contributor

ro0NL commented Mar 12, 2020

ok, we agree an annotation is required :) i thought you considered it optional. Whether to include Deserialize yes/no is a naming concern .. i guess we can assume it's expected it to be deserialized.

in general i think injecting deserialized request bodies sounds like a good feature 👍

@tsantos84
Copy link
Contributor Author

tsantos84 commented Mar 12, 2020

in general i think injecting deserialized request bodies sounds like a good feature 👍

That's my point. The annotation can be flexible to inject the data depending on the argument type:

/** @RequestBody("post") */
function save(string $post) {} // will inject $request->getContent()

/** @RequestBody("post") */
function save(array $post) {} // will decode to array and inject it

/** @RequestBody("post") */
function save(Post $post) {} // will deserialize to Post and inject it

All the above statements are valid. That's why I prefer to not have Deserialized word on annotation name

@ro0NL
Copy link
Contributor

ro0NL commented Mar 12, 2020

clever :) that should solve ambiguity.

Raises the question if we propose a specialized param converter in SF core, or framework-extra bundle 😁

@tsantos84
Copy link
Contributor Author

tsantos84 commented Mar 12, 2020

Raises the question if we propose a specialised param converter in SF core, or framework-extra bundle 😁

I thought about this and looked at Framework Extra Bundle repository but realised that framework-extra-bundle deprecated @route and @method in favor of core annotations. That's why I came and propose here as part of core and not as dependency. Deal with request/response should be part of framework's core, IMHO.

About specialised converter I don't know if it will clear and easy to memorize enough. Do you have some options?

Specialised annotations is much more expressive and deals only with request.

@linaori
Copy link
Contributor

linaori commented Mar 12, 2020

Basically what Symfony is missing in the core, is the ability to have dynamic configuration on the controller. In the framework extra bundle this feature is present, albeit only for annotations (see: https://github.com/sensiolabs/SensioFrameworkExtraBundle/blob/master/src/Configuration/ConfigurationAnnotation.php). ParamConverters are merely an implementation of this.

If this feature would be added in core, these annotations/yaml/xml/php configurations could be added to the Request object in an early stage, and be exposed via the attributes in the same way. This implies that ArgumentValueResolvers could (almost) fully replace the ParameterConverters, and creating listeners to act upon these configurations would be relatively simple.

In addition to that, vendor packages could provide additional annotations and listeners without having to rely on the extra bundle.

@tsantos84
Copy link
Contributor Author

Totally agree with you, @linaori.

With this proposal, we could have validation on top of Symfony Validator component like this:

/**
 * @QueryParam("email", constraints={@Assert\Email})
 */
function search(string $email) {}

And raise bad request in any constraint violation.

@ro0NL
Copy link
Contributor

ro0NL commented Mar 13, 2020

an alternative route we should consider is to use dedicated request models:

function resp(MyRequest $r) {
   $r->getPayloadDto()->getSomething();
   $r->getEmail();
}

then we need some infra to tell request body maps to getPayloadDto and should be deserialized, whereas query param email maps to getEmail.

Such request models can be obtained from some factory, based on the origin request. Thus symfonty could autowire/provide it in controllers.

This avoids proliferation of per-case framework annotations, or features coupled to framework controllers / http spec.

Personally, im fine either way :) though i tend to prefer using VOs/services actually for better reuseability.

@tsantos84
Copy link
Contributor Author

Such request models can be obtained from some factory, based on the origin request. Thus symfonty could autowire/provide it in controllers.

Such factories should be written by devs or some kind of automation?

@tsantos84
Copy link
Contributor Author

tsantos84 commented Mar 13, 2020

Personally, im fine either way :) though i tend to prefer using VOs/services actually for better reuseability.

Could you show us some examples of request DTO reusability? Each request has its particularity and that's why each one has a dedicated controller to handle it, IMHO. I'm not against your idea, just want more concrete examples to create my opinion. 😁

@linaori
Copy link
Contributor

linaori commented Mar 13, 2020

Perhaps an odd idea... What about a RequestDenormalizer? (I think). This could populate objects based on request data 🤔

@tsantos84
Copy link
Contributor Author

tsantos84 commented Mar 13, 2020

Perhaps an odd idea... What about a RequestDenormalizer? (I think). This could populate objects based on request data 🤔

Looks good, but I can't see a way to do this out-of-box.

@nicolas-grekas nicolas-grekas added the RFC RFC = Request For Comments (proposals about features that you want to be discussed) label Mar 16, 2020
@nicolas-grekas nicolas-grekas changed the title Add request specific annotations [RFC] Add request specific annotations Mar 16, 2020
@nicolas-grekas
Copy link
Member

nicolas-grekas commented Mar 16, 2020

Looking at the reactions, this is not immediately obvious for everyone :)

What I like is the possibility to mix this with additional declarations.
Eg QueryParam+Assert is nice, because that replaces code with declarations.
What is likely a problem is when it just provides another way to access some info: we don't need several ways to do what is already possible, because that calls for confusion.

Maybe start with a single annotation, eg QueryParam?

@javiereguiluz
Copy link
Member

I agree with Nicolas. We've also discussed about this a lot in the Symfony Docs team. I think we're adding some features that provide "bad flexibility" instead of "good flexibility":

  • "good flexibility": let me do anything and let me change everything.
  • "bad flexibility": let me do the exact same thing in 10 different ways.

This may be one of those features 😢

@tsantos84
Copy link
Contributor Author

tsantos84 commented Mar 16, 2020

Hi guys. Thanks for replying this RFC. Before going ahead, I agree that the initial description of the proposal is little confuse and let me improve it with in a resumed way. This RFC is not intended to add a new way to access request information only:

  1. Access request data through annotations
  2. Automatic exceptions (e.g BadRequest) when a required argument is missing on parameter bag or request body.
  3. Combine those annotations with Symfony's Validator component so we can improve the way request data are validated.
  4. Automatic deserialization of request body as commented here

In resume, a code like this:

/**
 * @Route("/search")
 */
function searchAction(Request $request)
{
    $term = $request->query->get('term');
     
    if (null === $term || length($term) < 3) {
        throw new HttpException(400);
    }
    
    $limit = $request->query->getInt('limit', 10);

    // perform search with $term and $limit
}

could be written like this:

/**
 * @Route("/search")
 *
 * @QueryParam("term", constraints={@Assert\Length(min=3)})
 * @QueryParam("limit")
 */
function searchAction(string $term, int $limit = 10)
{
    // perform search with $term and $limit
}

In this example, if $term is missing or is less than 3 characters, a BadRequestException is automatically thrown and could save time of devs on trivial validations.

The same principle can be applied to any request data source: @RequestBody, @RequestCookie, @PathParam and @RequestHeader.

@piku235
Copy link
Contributor

piku235 commented Jun 3, 2020

Hi guys, I've just stumbled upon this topic. I was looking for something similar in the past but I didn't find anything relevant. I started then working on this and now slowly preparing for the release, but still, a few things to finish (for interested link to the repository).
I did it in the style of "SensioFrameworkExtraBundle", I don't mean here the "ParamConverter" as I know it's something to be dropped, I based everything on the "ArgumentValueResolverInterface". I like the idea of "SensioFrameworkExtraBundle" as being a set with extra features, where you're not forced to use it if you just don't like it/don't need it. Over time some of the initial annotations like "Route" now have become part of the Symfony core, I think because of their verified usefulness.

I found this annotation style interesting, mainly thanks to the Spring Framework from which these annotations mostly originates from and also inspired by VLINGO/HTTP.

I agree in general with @tsantos84, because for instance why we access now route parameters through arguments? Before it was only possible through the traditional way of accessing the request instance:

/**
 * @Route("/posts/{postId}", methods={"GET"})
 */
function getPost(Request $request)
{
    $postId = $request->attributes->get('postId');
    // ...
}

Thanks to the "RequestAttributeValueResolver" we can intercept path params through arguments:

/**
 * @Route("/posts/{postId}", methods={"GET"})
 */
function getPost(int $postId)
{
    // ...
}

I think of this style as a convenient and handy way of accessing request parameters of any kind, especially when having a service with REST API.
I also like to look at it from a different perspective. Just imaging a simple application that was written in favor of eg the transaction script pattern. A simple application service could be turned easily to handle HTTP requests and be completely unaware of that:

class ApplicationService
{
  /**
   * @Route("/topics/{topicId}/posts", methods={"POST"})
   * @RequestBody("data")
   */
  public function postTo(string $topicId, PostData $data): void;

  /**
   * @Route("/topics/{topicId}/posts", methods={"GET"})
   * @ResponseBody
   */
  public function allPostsOf(string $topicId): iterable;

  /**
   * @Route("/posts/{postId}", methods={"GET"})
   * @ResponseBody
   */
  public function postOf(string $postId): ?PostData;
}

I know the example may be a bit exaggerated, in reality, it'd need more annotations to support all possible cases, so in the end, it may not be worth the price. :) However, I think about this as a good example of what these annotations are capable of.

IMHO it brings:

  • clear API - through explicit arguments, we know what an action method from the outside world exactly needs, it's easier to make tests for. We don't pass the whole "Request" instance with unknown parameters.
  • boilerplate code reduction,
  • initial validation before the action method execution,

@tsantos84
Copy link
Contributor Author

Hi @piku235 , thanks for adding your thoughts here. I've opened a PR for this and would be great if you add some technical additions there as you've written something similar to this. #36135

@carsonbot
Copy link

Thank you for this suggestion.
There has not been a lot of activity here for a while. Would you still like to see this feature?

@carsonbot
Copy link

Hello? This issue is about to be closed if nobody replies.

@carsonbot
Copy link

Hey,

I didn't hear anything so I'm going to close it. Feel free to comment if this is still relevant, I can always reopen!

@B-Galati
Copy link
Contributor

B-Galati commented May 24, 2022

Now that PHP attributes exists it would be really cool to have these ideas implemented in Symfony.

For example:

...
    public function __invoke(
        #[RequestBody]
        PostResource $postResource
    )
...

Should not be to difficult to implement since ArgumentResolver already reads PHP attributes.

@piku235
Copy link
Contributor

piku235 commented May 24, 2022

@B-Galati I too would like to see it implemented in Symfony someday, but what you wrote is already implemented in one of my bundles JungiFrameworkExtraBundle, plus there're more attributes. Not so long ago I released next version of my bundle where I focused only on the attributes and to support the latest version of Symfony v6+. Before that, my bundle provided both annotations and attributes for projects with Symfony 5.3+.

@derrabus
Copy link
Member

@B-Galati see #45628

nicolas-grekas added a commit that referenced this issue Apr 14, 2023
…and `#[MapQueryString]` to map Request input to typed objects (Koc)

This PR was merged into the 6.3 branch.

Discussion
----------

[HttpKernel] Create Attributes `#[MapRequestPayload]` and `#[MapQueryString]` to map Request input to typed objects

| Q             | A
| ------------- | ---
| Branch?       | 6.3
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | #36037, #36093, #45628, #47425,  #49002, #49134
| License       | MIT
| Doc PR        | TBD

Yet another variation of how we can map raw Request data to typed objects with validation. We can even build OpenApi Specification based on this DTO classes using [NelmioApiDocBundle](https://github.com/nelmio/NelmioApiDocBundle).

## Usage Example 🔧
### `#[MapRequestPayload]`

```php
class PostProductReviewPayload
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(min: 10, max: 500)]
        public readonly string $comment,
        #[Assert\GreaterThanOrEqual(1)]
        #[Assert\LessThanOrEqual(5)]
        public readonly int $rating,
    ) {
    }
}

class PostJsonApiController
{
    public function __invoke(
        #[MapRequestPayload] PostProductReviewPayload $payload,
    ): Response {
        // $payload is validated and fully typed representation of the request body $request->getContent()
        //  or $request->request->all()
    }
}
```

### `#[MapQueryString]`

```php
class GetOrdersQuery
{
    public function __construct(
        #[Assert\Valid]
        public readonly ?GetOrdersFilter $filter,
        #[Assert\LessThanOrEqual(500)]
        public readonly int $limit = 25,
        #[Assert\LessThanOrEqual(10_000)]
        public readonly int $offset = 0,
    ) {
    }
}

class GetOrdersFilter
{
    public function __construct(
        #[Assert\Choice(['placed', 'shipped', 'delivered'])]
        public readonly ?string $status,
        public readonly ?float $total,
    ) {
    }
}

class GetApiController
{
    public function __invoke(
        #[MapQueryString] GetOrdersQuery $query,
    ): Response {
        // $query is validated and fully typed representation of the query string $request->query->all()
    }
}
```

### Exception handling 💥
- Validation errors will result in an HTTP 422 response, accompanied by a serialized `ConstraintViolationList`.
- Malformed data will be responded to with an HTTP 400 error.
- Unsupported deserialization formats will be responded to with an HTTP 415 error.

## Comparison to another implementations 📑

Differences to #49002:
- separate Attributes for explicit definition of the used source
- no need to define which class use to map because Attributes already associated with typed argument
- used ArgumentValueResolver mechanism instead of Subscribers
- functional tests

Differences to #49134:
- it is possible to map whole query string to object, not per parameter
- there is validation of the mapped object
- supports both `$request->getContent()` and `$request->request->all()` mapping
- functional tests

Differences to #45628:
- separate Attributes for explicit definition of the used source
- supports `$request->request->all()` and `$request->query->all()` mapping
- Exception handling opt-in
- functional tests

## Bonus part 🎁
- Extracted `UnsupportedFormatException` which thrown when there is no decoder for a given format

Commits
-------

d987093 [HttpKernel] Create Attributes `#[MapRequestPayload]` and `#[MapQueryString]` to map Request input to typed objects
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature RFC RFC = Request For Comments (proposals about features that you want to be discussed) Stalled
Projects
None yet
Development

No branches or pull requests

9 participants