From 7a123eab112b21f2d6c0fe4bc973d393b8eac668 Mon Sep 17 00:00:00 2001 From: Steven Renaux Date: Mon, 14 Aug 2023 11:46:33 +0200 Subject: [PATCH] Adding a new Attribute MapRequestHeader class and resolver --- .../FrameworkBundle/Resources/config/web.php | 4 + .../HttpKernel/Attribute/MapRequestHeader.php | 32 +++ src/Symfony/Component/HttpKernel/CHANGELOG.md | 1 + .../RequestHeaderValueResolver.php | 62 ++++++ .../RequestHeaderValueResolverTest.php | 202 ++++++++++++++++++ 5 files changed, 301 insertions(+) create mode 100644 src/Symfony/Component/HttpKernel/Attribute/MapRequestHeader.php create mode 100644 src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestHeaderValueResolver.php create mode 100644 src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestHeaderValueResolverTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index a4e975dac8749..0e39565fa918d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestHeaderValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\ServiceValueResolver; @@ -101,6 +102,9 @@ ->set('argument_resolver.query_parameter_value_resolver', QueryParameterValueResolver::class) ->tag('controller.targeted_value_resolver', ['name' => QueryParameterValueResolver::class]) + ->set('argument_resolver.header_value_resolver', RequestHeaderValueResolver::class) + ->tag('controller.targeted_value_resolver', ['name' => RequestHeaderValueResolver::class]) + ->set('response_listener', ResponseListener::class) ->args([ param('kernel.charset'), diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapRequestHeader.php b/src/Symfony/Component/HttpKernel/Attribute/MapRequestHeader.php new file mode 100644 index 0000000000000..d7b2cd8555548 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/MapRequestHeader.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestHeaderValueResolver; + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class MapRequestHeader extends ValueResolver +{ + /** + * @param string|null $name The name of the header parameter; if null, the name of the argument in the controller will be used + * @param string $resolver The class name of the resolver to use + * @param int $validationFailedStatusCode The HTTP code to return if the validation fails + */ + public function __construct( + public readonly ?string $name = null, + string $resolver = RequestHeaderValueResolver::class, + public readonly int $validationFailedStatusCode = Response::HTTP_BAD_REQUEST, + ) { + parent::__construct($resolver); + } +} diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 6bf1a60ebc6e2..350dc65cdd95a 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -64,6 +64,7 @@ CHANGELOG * Add argument `$buildDir` to `WarmableInterface` * Add argument `$filter` to `Profiler::find()` and `FileProfilerStorage::find()` * Add `ControllerResolver::allowControllers()` to define which callables are legit controllers when the `_check_controller_is_allowed` request attribute is set + * Add `#[MapRequestHeader]` to map header from `Request::$headers` 6.3 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestHeaderValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestHeaderValueResolver.php new file mode 100644 index 0000000000000..cf0f6cc8bef53 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestHeaderValueResolver.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver; + +use Symfony\Component\HttpFoundation\AcceptHeader; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\MapRequestHeader; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\HttpException; + +class RequestHeaderValueResolver implements ValueResolverInterface +{ + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + if (!$attribute = $argument->getAttributesOfType(MapRequestHeader::class)[0] ?? null) { + return []; + } + + $type = $argument->getType(); + + if (!\in_array($type, ['string', 'array', AcceptHeader::class])) { + throw new \LogicException(\sprintf('Could not resolve the argument typed "%s". Valid values types are "array", "string" or "%s".', $type, AcceptHeader::class)); + } + + $name = $attribute->name ?? $argument->getName(); + $value = null; + + if ($request->headers->has($name)) { + $value = match ($type) { + 'string' => $request->headers->get($name), + 'array' => match (strtolower($name)) { + 'accept' => $request->getAcceptableContentTypes(), + 'accept-charset' => $request->getCharsets(), + 'accept-language' => $request->getLanguages(), + 'accept-encoding' => $request->getEncodings(), + default => [$request->headers->get($name)], + }, + default => AcceptHeader::fromString($request->headers->get($name)), + }; + } + + if (null === $value && $argument->hasDefaultValue()) { + $value = $argument->getDefaultValue(); + } + + if (null === $value && !$argument->isNullable()) { + throw new HttpException($attribute->validationFailedStatusCode, \sprintf('Missing header "%s".', $name)); + } + + return [$value]; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestHeaderValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestHeaderValueResolverTest.php new file mode 100644 index 0000000000000..2793b188d96f5 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestHeaderValueResolverTest.php @@ -0,0 +1,202 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\AcceptHeader; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\MapRequestHeader; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestHeaderValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\HttpException; + +class RequestHeaderValueResolverTest extends TestCase +{ + public static function provideHeaderValueWithStringType(): iterable + { + yield 'with accept' => ['accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8']; + yield 'with accept-language' => ['accept-language', 'en-us,en;q=0.5']; + yield 'with host' => ['host', 'localhost']; + yield 'with user-agent' => ['user-agent', 'Symfony']; + } + + public static function provideHeaderValueWithArrayType(): iterable + { + yield 'with accept' => [ + 'accept', + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + [ + [ + 'text/html', + 'application/xhtml+xml', + 'application/xml', + '*/*', + ], + ], + ]; + yield 'with accept-language' => [ + 'accept-language', + 'en-us,en;q=0.5', + [ + [ + 'en_US', + 'en', + ], + ], + ]; + yield 'with host' => [ + 'host', + 'localhost', + [ + ['localhost'], + ], + ]; + yield 'with user-agent' => [ + 'user-agent', + 'Symfony', + [ + ['Symfony'], + ], + ]; + } + + public static function provideHeaderValueWithAcceptHeaderType(): iterable + { + yield 'with accept' => [ + 'accept', + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + [AcceptHeader::fromString('text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')], + ]; + yield 'with accept-language' => [ + 'accept-language', + 'en-us,en;q=0.5', + [AcceptHeader::fromString('en-us,en;q=0.5')], + ]; + yield 'with host' => [ + 'host', + 'localhost', + [AcceptHeader::fromString('localhost')], + ]; + yield 'with user-agent' => [ + 'user-agent', + 'Symfony', + [AcceptHeader::fromString('Symfony')], + ]; + } + + public static function provideHeaderValueWithDefaultAndNull(): iterable + { + yield 'with hasDefaultValue' => [true, 'foo', false, 'foo']; + yield 'with no isNullable' => [false, null, true, null]; + } + + public function testWrongType() + { + $this->expectException(\LogicException::class); + + $metadata = new ArgumentMetadata('accept', 'int', false, false, null, false, [ + MapRequestHeader::class => new MapRequestHeader(), + ]); + + $request = Request::create('/'); + + $resolver = new RequestHeaderValueResolver(); + $resolver->resolve($request, $metadata); + } + + /** + * @dataProvider provideHeaderValueWithStringType + */ + public function testWithStringType(string $parameter, string $value) + { + $resolver = new RequestHeaderValueResolver(); + + $metadata = new ArgumentMetadata('variableName', 'string', false, false, null, false, [ + MapRequestHeader::class => new MapRequestHeader($parameter), + ]); + + $request = Request::create('/'); + $request->headers->set($parameter, $value); + + $arguments = $resolver->resolve($request, $metadata); + + self::assertEquals([$value], $arguments); + } + + /** + * @dataProvider provideHeaderValueWithArrayType + */ + public function testWithArrayType(string $parameter, string $value, array $expected) + { + $resolver = new RequestHeaderValueResolver(); + + $metadata = new ArgumentMetadata('variableName', 'array', false, false, null, false, [ + MapRequestHeader::class => new MapRequestHeader($parameter), + ]); + + $request = Request::create('/'); + $request->headers->set($parameter, $value); + + $arguments = $resolver->resolve($request, $metadata); + + self::assertEquals($expected, $arguments); + } + + /** + * @dataProvider provideHeaderValueWithAcceptHeaderType + */ + public function testWithAcceptHeaderType(string $parameter, string $value, array $expected) + { + $resolver = new RequestHeaderValueResolver(); + + $metadata = new ArgumentMetadata('variableName', AcceptHeader::class, false, false, null, false, [ + MapRequestHeader::class => new MapRequestHeader($parameter), + ]); + + $request = Request::create('/'); + $request->headers->set($parameter, $value); + + $arguments = $resolver->resolve($request, $metadata); + + self::assertEquals($expected, $arguments); + } + + /** + * @dataProvider provideHeaderValueWithDefaultAndNull + */ + public function testWithDefaultValueAndNull(bool $hasDefaultValue, ?string $defaultValue, bool $isNullable, ?string $expected) + { + $metadata = new ArgumentMetadata('wrong-header', 'string', false, $hasDefaultValue, $defaultValue, $isNullable, [ + MapRequestHeader::class => new MapRequestHeader(), + ]); + + $request = Request::create('/'); + + $resolver = new RequestHeaderValueResolver(); + $arguments = $resolver->resolve($request, $metadata); + + self::assertEquals([$expected], $arguments); + } + + public function testWithNoDefaultAndNotNullable() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Missing header "variableName".'); + + $metadata = new ArgumentMetadata('variableName', 'string', false, false, null, false, [ + MapRequestHeader::class => new MapRequestHeader(), + ]); + + $resolver = new RequestHeaderValueResolver(); + $resolver->resolve(Request::create('/'), $metadata); + } +}