Skip to content

[HttpKernel] Adding new #[MapRequestHeader] attribute and resolver #51379

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
wants to merge 1 commit into
base: 7.4
Choose a base branch
from

Conversation

StevenRenaux
Copy link
Contributor

@StevenRenaux StevenRenaux commented Aug 14, 2023

Q A
Branch? 6.4
Bug fix? no
New feature? yes
Deprecations? no
Tickets #51342
License MIT
Doc PR #20541

Hi,

Related to this #51342 I tried to work on this PR.
To use this new attribute, you just need to add it in your method of your controller.

namespace App\Controller;

class TestingController extends AbstractController
{
    #[Route('/', name: 'home', methods: 'GET')]
    public function home(
        #[MapRequestHeader] array $accept,
        #[MapRequestHeader('accept')] string $foo,
        #[MapRequestHeader('user-agent')] ?string $bar,
        #[MapRequestHeader(name: 'accept-encoding')] AcceptHeader $encoding,
    )
    {
            dump($accept);
            //        array:7 [▼
            //          0 => "text/html"
            //          1 => "application/xhtml+xml"
            //          2 => "image/avif"
            //          3 => "image/webp"
            //          4 => "image/apng"
            //          5 => "application/xml"
            //          6 => "*/*"
            //        ]
            
            dump($foo);
            //  "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
            
            dump($bar);
            //  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
            
            dump($encoding);
            //        Symfony\Component\HttpFoundation\AcceptHeader {#4727 ▼
            //            -items: array:4 [▼
            //                "gzip" => 
            //                    Symfony\Component\HttpFoundation\AcceptHeaderItem {#2437 ▼
            //                        -value: "gzip"
            //                        -quality: 1.0
            //                        -index: 0
            //                        -attributes: []
            //                }
            //                "deflate" => 
            //                    Symfony\Component\HttpFoundation\AcceptHeaderItem {#4882 ▼
            //                        -value: "deflate"
            //                        -quality: 1.0
            //                        -index: 1
            //                        -attributes: []
            //                }
            //                "br" => 
            //                    Symfony\Component\HttpFoundation\AcceptHeaderItem {#4549 ▼
            //                        -value: "br"
            //                        -quality: 1.0
            //                        -index: 2
            //                        -attributes: []
            //                }
            //                "zstd" => 
            //                    Symfony\Component\HttpFoundation\AcceptHeaderItem {#4773 ▼
            //                        -value: "zstd"
            //                        -quality: 1.0
            //                        -index: 3
            //                        -attributes: []
            //                }
            //            ]
            //              -sorted: false
            //        }
    }

For some parameters as User-Agent we need to add the name for the resolver, because the conversion of userAgent is not interpreted by the resolver and return an exception as Missing header parameter "userAgent".

And for some others parameters as cookie I don't know what to do, because at the moment it only return string for all of them and is it really useful 🤷

@carsonbot
Copy link

Hey!

I see that this is your first PR. That is great! Welcome!

Symfony has a contribution guide which I suggest you to read.

In short:

  • Always add tests
  • Keep backward compatibility (see https://symfony.com/bc).
  • Bug fixes must be submitted against the lowest maintained branch where they apply (see https://symfony.com/releases)
  • Features and deprecations must be submitted against the 6.4 branch.

Review the GitHub status checks of your pull request and try to solve the reported issues. If some tests are failing, try to see if they are failing because of this change.

When two Symfony core team members approve this change, it will be merged and you will become an official Symfony contributor!
If this PR is merged in a lower version branch, it will be merged up to all maintained branches within a few days.

I am going to sit back now and wait for the reviews.

Cheers!

Carsonbot

@StevenRenaux StevenRenaux force-pushed the map-request-header-attribute branch from 527c461 to e85136d Compare August 14, 2023 11:06
@AurelienPillevesse
Copy link
Contributor

AurelienPillevesse commented Aug 14, 2023

Maybe I'm wrong but seems that something related to mapQueryString and mapRequestPayload is in this file and I see nothing from yours :

public function onKernelControllerArguments(ControllerArgumentsEvent $event): void

Why did not add in the same file?

Copy link

@robert-sykes robert-sykes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this not be best to also allow it to map to an object, like how the MapRequestPayload currently works, as I can pass an Object as a reference the serializer will deserialize to this object? That way you can have a Header class that the headers deserialize into.

You can merge it with your current concept and have a bit of best of both? Map individual Headers to an string, or an object or even just an array if required?

Something potentially like this?

    private function mapRequestHeader(Request $request, string $type, MapRequestHeader $attribute): ?object
    {
        $headers = [];

        $requestHeaders = $request->headers->all();

        array_walk($requestHeaders, function ($value, $key) use (&$headers) {
            $key = $this->snakeCaseToCamelCase($key);
            $headers[$key] = $value[0];
        });

        $headers = json_encode($headers);

        $format = 'json';

        try {
            return $this->serializer->deserialize($headers, $type, $format, []);
        } catch (UnsupportedFormatException $e) {
            throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format: "%s".', $format), $e);
        } catch (NotEncodableValueException $e) {
            throw new HttpException(Response::HTTP_BAD_REQUEST, sprintf('Request payload contains invalid "%s" data.', $format), $e);
        }
    }

and you could have a class like this:

class HeaderRequest
{
    public function __construct(
        public readonly string $userAgent,
        public readonly string $accept,
        public readonly string $contentType,
        public readonly string $acceptEncoding,
    ) {
    }
}

An advantage is you can use the Validator too and enforce any header types if needed or create a HeaderRequest class per request as needed.

You can add this as a new method to the existing RequestPayloadValueResolver as already mentioned by @AurelienPillevesse so it will tie nicely into the existing process.

This is a rough example but just food for thought.

Very useful PR this and something we need, so looking forward to seeing how it progresses :)

@StevenRenaux StevenRenaux force-pushed the map-request-header-attribute branch from 261f6e6 to 72cb1ee Compare August 17, 2023 14:32
@StevenRenaux StevenRenaux force-pushed the map-request-header-attribute branch 2 times, most recently from afbdd35 to 90f1015 Compare August 17, 2023 14:50
@ruudk
Copy link
Contributor

ruudk commented Aug 21, 2023

Too bad the PR was changed after @robert-sykes suggestion. I think these are 2 distinct ideas. The original PR description mentions a single attribute per header, same as how MapQueryParameter works. There are valid cases for that, where you only want and need a single header scalar.

The updated code now requires you to map a header to a header object.

Would be great to also keep supporting the original one for just a single header.

See this comment where a similar situation happened: #49134 (comment)

@robert-sykes
Copy link

robert-sykes commented Aug 21, 2023

Too bad the PR was changed after @robert-sykes suggestion. I think these are 2 distinct ideas. The original PR description mentions a single attribute per header, same as how MapQueryParameter works. There are valid cases for that, where you only want and need a single header scalar.

The updated code now requires you to map a header to a header object.

Would be great to also keep supporting the original one for just a single header.

See this comment where a similar situation happened: #49134 (comment)

I'm all for this, to be fair. I did imply this in my original comment where I said You can merge it with your current concept and have a bit of best of both? Map individual Headers to an string, or an object or even just an array if required? That said - I didn't push it any further in my commentary, apologies.

@StevenRenaux You could check the type and if it's a string map to that similar to your original commit, otherwise if it's object map to those fields instead. We get best of both worlds this way. What do you think?

@StevenRenaux
Copy link
Contributor Author

StevenRenaux commented Aug 21, 2023

Hey, I will try to do it and I didn't understand well for sure but we are there to share and go further 😉

@robert-sykes
Copy link

Hey, I will try to do it and I didn't understand well for sure but we are there to share and go further 😉

No problem at all. If you need a hand let me know, more than happy to pair on this with you. :)

@StevenRenaux StevenRenaux force-pushed the map-request-header-attribute branch from ff6b41f to 0bda0bf Compare August 22, 2023 15:02
@StevenRenaux
Copy link
Contributor Author

StevenRenaux commented Aug 22, 2023

To get back to the original Issue I suggest this

namespace App\Controller;

class TestController extends AbstractController
{
    #[Route(path: '/', name: 'test')]
    public function test(
        #[MapRequestHeader] HeaderRequestDto $headerRequestDto,
        #[MapRequestHeader(name: 'user-agent')] string $agent,
        #[MapRequestHeader] array $accept
    ): Response
    {
        dd(
            $headerRequestDto,
//      App\Entity\HeaderRequestDto {
//        accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
//        agent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/116.0"
//        acceptEncoding: "gzip, deflate, br"
//        host: "localhost:8000"
//      }
           
            $agent,
//      "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/116.0"

            $accept
//      array:6 [
//          0 => "text/html"
//          1 => "application/xhtml+xml"
//          2 => "application/xml;q=0.9"
//          3 => "image/avif"
//          4 => "image/webp"
//          5 => "*/*;q=0.8"
//      ]
        );
    }
}

And a Dto could look like this :

namespace App\Entity;

use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

class HeaderRequestDto
{
    public function __construct(
        public readonly string $accept,
        #[Assert\EqualTo('test', groups: ['test'])]
        #[SerializedName('user-agent')] public readonly string $agent = 'agent',
        #[SerializedName('accept-encoding')] public readonly string $acceptEncoding,
        #[Assert\EqualTo('localhost', groups: ['valid'])]
        public readonly string $host
    ) {
    }
}

We only use kebab-case (as returned by HeaderBag) now to get some property like 'user-agent'.
And if we want an array rather than a string for the property as accept it will return an array after an explode with a coma delimiter.

@StevenRenaux StevenRenaux force-pushed the map-request-header-attribute branch from 0bda0bf to cced8c1 Compare August 22, 2023 15:30
@carsonbot carsonbot changed the title Adding a new Attribute MapRequestHeader class and resolver [HttpKernel] Adding a new Attribute MapRequestHeader class and resolver Aug 23, 2023
@xabbuh xabbuh added this to the 7.2 milestone May 15, 2024
@alexandre-daubois
Copy link
Member

Hi @StevenRenaux! What's the current state of this PR? 🙂

@StevenRenaux
Copy link
Contributor Author

Hi @StevenRenaux! What's the current state of this PR? 🙂

@alexandre-daubois Waiting for review, since the last requested update.

@fabpot fabpot modified the milestones: 7.2, 7.3 Nov 20, 2024
@StevenRenaux StevenRenaux changed the title [HttpKernel] Adding new #[MapRequestHeader] and #[MapRequestHeaders] attributes and resolver [HttpKernel] Adding new #[MapRequestHeader] attribute and resolver Jan 7, 2025
@StevenRenaux StevenRenaux force-pushed the map-request-header-attribute branch 3 times, most recently from 7494a85 to 0f38cde Compare January 7, 2025 13:11
@StevenRenaux
Copy link
Contributor Author

StevenRenaux commented Jan 7, 2025

Back to the basics of PR, using only the MapRequestHeader attribute. Mapping to a DTO does not seem like a relevant or common use case or if it is really necessary could be done in a future PR.

The allowed types are therefore:

  • string
  • array
  • AcceptHeader

When the header parameter cannot be determined by the variable name, we can specify the name argument in the attribute.

public function test(
    #[MapRequestHeader('user-agent')] ?string $bar,
): Response
{
  dump($bar);
  //  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
}

If we are looking for a header of type Accept-* with an array type, we will use the dedicated methods to retrieve this data.

https://symfony.com/doc/current/components/http_foundation.html#accessing-accept-headers-data

public function test(
    #[MapRequestHeader] array $accept,
): Response
{
  dump($accept);
  //        array:7 [▼
  //          0 => "text/html"
  //          1 => "application/xhtml+xml"
  //          2 => "image/avif"
  //          3 => "image/webp"
  //          4 => "image/apng"
  //          5 => "application/xml"
  //          6 => "*/*"
  //        ]
}

And with AcceptHeader type

public function test(
    #[MapRequestHeader(name: 'accept-encoding')] AcceptHeader $encoding,
): Response
{
  dump($encoding);
  //        Symfony\Component\HttpFoundation\AcceptHeader {#4727 ▼
  //            -items: array:4 [▼
  //                "gzip" =>
  //                    Symfony\Component\HttpFoundation\AcceptHeaderItem {#2437 ▼
  //                        -value: "gzip"
  //                        -quality: 1.0
  //                        -index: 0
  //                        -attributes: []
  //                }
  //                "deflate" =>
  //                    Symfony\Component\HttpFoundation\AcceptHeaderItem {#4882 ▼
  //                        -value: "deflate"
  //                        -quality: 1.0
  //                        -index: 1
  //                        -attributes: []
  //                }
  //                "br" =>
  //                    Symfony\Component\HttpFoundation\AcceptHeaderItem {#4549 ▼
  //                        -value: "br"
  //                        -quality: 1.0
  //                        -index: 2
  //                        -attributes: []
  //                }
  //                "zstd" =>
  //                    Symfony\Component\HttpFoundation\AcceptHeaderItem {#4773 ▼
  //                        -value: "zstd"
  //                        -quality: 1.0
  //                        -index: 3
  //                        -attributes: []
  //                }
  //            ]
  //              -sorted: false
  //        }
}

@StevenRenaux StevenRenaux force-pushed the map-request-header-attribute branch 2 times, most recently from fbde828 to 89f420f Compare January 10, 2025 08:26
@MatTheCat
Copy link
Contributor

It bugs me the array type has two semantics whether it applies to an Accept-* header or not. Plus

  • in the first case there already is the AcceptHeader type.
  • in the second case only the first value is returned in an array, but
    • there can be many
    • the first value can actually be many values combined by the SAPI

@ruudk
Copy link
Contributor

ruudk commented May 26, 2025

@StevenRenaux What's needed to get this to a mergeable state? I would love this to be included in the new Symfony version.

@StevenRenaux
Copy link
Contributor Author

@StevenRenaux What's needed to get this to a mergeable state? I would love this to be included in the new Symfony version.

@ruudk Waiting reviews from core-contributors, but it's seems to late for merging into the next 7.3 version.
Feel free also to review, approve...
I think at the moment it's fine to get a simple implementation as it is because it's the kind of stuff you can't statifies every problematics.

@ruudk
Copy link
Contributor

ruudk commented May 26, 2025

Could you rebase it, so that the tests go green?

@StevenRenaux StevenRenaux force-pushed the map-request-header-attribute branch from 89f420f to 2142045 Compare May 26, 2025 11:44
@ruudk
Copy link
Contributor

ruudk commented May 26, 2025

@StevenRenaux StevenRenaux force-pushed the map-request-header-attribute branch from 2142045 to 7a123ea Compare May 26, 2025 15:12
@fabpot fabpot modified the milestones: 7.3, 7.4 May 26, 2025
'accept-charset' => $request->getCharsets(),
'accept-language' => $request->getLanguages(),
'accept-encoding' => $request->getEncodings(),
default => [$request->headers->get($name)],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
default => [$request->headers->get($name)],
default => $request->headers->all($name),

}

if (null === $value && !$argument->isNullable()) {
throw new HttpException($attribute->validationFailedStatusCode, \sprintf('Missing header "%s".', $name));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if type is array we could return an empty array

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

Successfully merging this pull request may close these issues.