Skip to content

Commit cced8c1

Browse files
committed
Revert to RequestHeaderValueResolver for mapping DTO or individual Headers
1 parent 90f1015 commit cced8c1

File tree

7 files changed

+237
-93
lines changed

7 files changed

+237
-93
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ public function load(array $configs, ContainerBuilder $container)
401401
} else {
402402
$container->getDefinition('argument_resolver.request_payload')
403403
->setArguments([])
404-
->addError('You can neither use "#[MapRequestPayload]", "#[MapQueryString]" nor #[MapRequestHeader] since the Serializer component is not '
404+
->addError('You can neither use "#[MapRequestPayload]" nor "#[MapQueryString]" since the Serializer component is not '
405405
.(class_exists(Serializer::class) ? 'enabled. Try setting "framework.serializer.enabled" to true.' : 'installed. Try running "composer require symfony/serializer-pack".')
406406
)
407407
->addTag('container.error')

src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver;
1919
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver;
2020
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver;
21+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestHeaderValueResolver;
2122
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
2223
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver;
2324
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\ServiceValueResolver;
@@ -97,6 +98,13 @@
9798
->set('argument_resolver.query_parameter_value_resolver', QueryParameterValueResolver::class)
9899
->tag('controller.targeted_value_resolver', ['name' => QueryParameterValueResolver::class])
99100

101+
->set('argument_resolver.header_value_resolver', RequestHeaderValueResolver::class)
102+
->args([
103+
service('serializer'),
104+
service('validator')->nullOnInvalid(),
105+
])
106+
->tag('controller.targeted_value_resolver', ['name' => RequestHeaderValueResolver::class])
107+
100108
->set('response_listener', ResponseListener::class)
101109
->args([
102110
param('kernel.charset'),

src/Symfony/Component/HttpKernel/Attribute/MapRequestHeader.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
namespace Symfony\Component\HttpKernel\Attribute;
1313

1414
use Symfony\Component\HttpFoundation\Response;
15-
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
15+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestHeaderValueResolver;
1616
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
1717
use Symfony\Component\Validator\Constraints\GroupSequence;
1818

@@ -22,9 +22,10 @@ class MapRequestHeader extends ValueResolver
2222
public ArgumentMetadata $metadata;
2323

2424
public function __construct(
25+
public readonly string|array|null $name = null,
2526
public readonly array $serializationContext = [],
2627
public readonly string|GroupSequence|array|null $validationGroups = null,
27-
string $resolver = RequestPayloadValueResolver::class,
28+
string $resolver = RequestHeaderValueResolver::class,
2829
public readonly int $validationFailedStatusCode = Response::HTTP_UNPROCESSABLE_ENTITY,
2930
) {
3031
parent::__construct($resolver);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpKernel\Attribute\MapRequestHeader;
16+
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
17+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
18+
use Symfony\Component\HttpKernel\Exception\HttpException;
19+
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
20+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
21+
use Symfony\Component\Serializer\SerializerInterface;
22+
use Symfony\Component\Validator\ConstraintViolationList;
23+
use Symfony\Component\Validator\Exception\ValidationFailedException;
24+
use Symfony\Component\Validator\Validator\ValidatorInterface;
25+
26+
class RequestHeaderValueResolver implements ValueResolverInterface
27+
{
28+
/**
29+
* @see DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS
30+
*/
31+
private const CONTEXT_DESERIALIZE = [
32+
'collect_denormalization_errors' => true,
33+
];
34+
35+
public function __construct(
36+
private readonly SerializerInterface&DenormalizerInterface $serializer,
37+
private readonly ?ValidatorInterface $validator = null,
38+
) {
39+
}
40+
41+
public function resolve(Request $request, ArgumentMetadata $argument): iterable
42+
{
43+
if (!$attribute = $argument->getAttributesOfType(MapRequestHeader::class)[0] ?? null) {
44+
return [];
45+
}
46+
47+
$headers = [];
48+
$requestHeaders = $request->headers->all();
49+
50+
array_walk($requestHeaders, function ($value, $key) use (&$headers) {
51+
$headers[$key] = implode(',', $value);
52+
});
53+
54+
$type = $argument->getType();
55+
56+
if ('string' === $type || 'array' === $type) {
57+
$name = $attribute->name ?? $argument->getName();
58+
59+
if (!isset($headers[$name])) {
60+
return [null];
61+
}
62+
63+
if ('string' === $type) {
64+
return [$headers[$name]];
65+
}
66+
67+
return [explode(',', $headers[$name])];
68+
}
69+
70+
try {
71+
$payload = $this->serializer->denormalize($headers, $argument->getType(), null, self::CONTEXT_DESERIALIZE + $attribute->serializationContext);
72+
} catch (PartialDenormalizationException $e) {
73+
throw new HttpException($attribute->validationFailedStatusCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), $e->getErrors())), $e);
74+
}
75+
76+
if ($this->validator) {
77+
$violations = new ConstraintViolationList();
78+
$violations->addAll($this->validator->validate($payload, null, $attribute->validationGroups));
79+
80+
if (\count($violations)) {
81+
throw new HttpException($attribute->validationFailedStatusCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), iterator_to_array($violations))), new ValidationFailedException($payload, $violations));
82+
}
83+
}
84+
85+
if (null === $payload) {
86+
$payload = match (true) {
87+
$argument->hasDefaultValue() => $argument->getDefaultValue(),
88+
$argument->isNullable() => null,
89+
default => throw new HttpException($attribute->validationFailedStatusCode)
90+
};
91+
}
92+
93+
return [$payload];
94+
}
95+
}

src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
use Symfony\Component\HttpFoundation\Request;
1616
use Symfony\Component\HttpFoundation\Response;
1717
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
18-
use Symfony\Component\HttpKernel\Attribute\MapRequestHeader;
1918
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
2019
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
2120
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
@@ -67,7 +66,6 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable
6766
{
6867
$attribute = $argument->getAttributesOfType(MapQueryString::class, ArgumentMetadata::IS_INSTANCEOF)[0]
6968
?? $argument->getAttributesOfType(MapRequestPayload::class, ArgumentMetadata::IS_INSTANCEOF)[0]
70-
?? $argument->getAttributesOfType(MapRequestHeader::class, ArgumentMetadata::IS_INSTANCEOF)[0]
7169
?? null;
7270

7371
if (!$attribute) {
@@ -94,9 +92,6 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
9492
} elseif ($argument instanceof MapRequestPayload) {
9593
$payloadMapper = 'mapRequestPayload';
9694
$validationFailedCode = $argument->validationFailedStatusCode;
97-
} elseif ($argument instanceof MapRequestHeader) {
98-
$payloadMapper = 'mapRequestHeader';
99-
$validationFailedCode = $argument->validationFailedStatusCode;
10095
} else {
10196
continue;
10297
}
@@ -200,17 +195,4 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay
200195
throw new HttpException(Response::HTTP_BAD_REQUEST, sprintf('Request payload contains invalid "%s" data.', $format), $e);
201196
}
202197
}
203-
204-
private function mapRequestHeader(Request $request, string $type, MapRequestHeader $attribute): ?object
205-
{
206-
$headers = [];
207-
$requestHeaders = $request->headers->all();
208-
209-
array_walk($requestHeaders, function ($value, $key) use (&$headers) {
210-
$key = lcfirst(str_replace('-', '', ucwords($key, '-')));
211-
$headers[$key] = $value[0];
212-
});
213-
214-
return $this->serializer->denormalize($headers, $type, null, self::CONTEXT_DESERIALIZE + $attribute->serializationContext);
215-
}
216198
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpKernel\Attribute\MapRequestHeader;
17+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestHeaderValueResolver;
18+
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
19+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
20+
use Symfony\Component\HttpKernel\Exception\HttpException;
21+
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
22+
use Symfony\Component\Serializer\Serializer;
23+
use Symfony\Component\Validator\Constraints as Assert;
24+
use Symfony\Component\Validator\Exception\ValidationFailedException;
25+
use Symfony\Component\Validator\ValidatorBuilder;
26+
27+
class RequestHeaderValueResolverTest extends TestCase
28+
{
29+
private const HEADER_PARAMS = [
30+
'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
31+
'accept-language' => 'en-us,en;q=0.5',
32+
'host' => 'localhost',
33+
'user-agent' => 'Symfony',
34+
];
35+
36+
private ValueResolverInterface $resolver;
37+
38+
protected function setUp(): void
39+
{
40+
$serializer = new Serializer([new ObjectNormalizer()]);
41+
$validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator();
42+
$this->resolver = new RequestHeaderValueResolver($serializer, $validator);
43+
}
44+
45+
public function testWithStringType()
46+
{
47+
foreach (self::HEADER_PARAMS as $parameter => $value) {
48+
$metadata = new ArgumentMetadata('variableName', 'string', false, false, null, false, [
49+
MapRequestHeader::class => new MapRequestHeader($parameter),
50+
]);
51+
52+
$arguments = $this->resolver->resolve(Request::create('/'), $metadata);
53+
54+
self::assertEquals([$value], $arguments);
55+
}
56+
}
57+
58+
public function testWithArrayType()
59+
{
60+
foreach (self::HEADER_PARAMS as $parameter => $value) {
61+
$metadata = new ArgumentMetadata('variableName', 'array', false, false, null, false, [
62+
MapRequestHeader::class => new MapRequestHeader($parameter),
63+
]);
64+
65+
$arguments = $this->resolver->resolve(Request::create('/'), $metadata);
66+
67+
self::assertEquals([explode(',', $value)], $arguments);
68+
}
69+
}
70+
71+
public function testWithNoValue()
72+
{
73+
$metadata = new ArgumentMetadata('variableName', 'string', false, false, null, false, [
74+
MapRequestHeader::class => new MapRequestHeader(),
75+
]);
76+
77+
$arguments = $this->resolver->resolve(Request::create('/'), $metadata);
78+
79+
self::assertEquals([null], $arguments);
80+
}
81+
82+
public function testWithDtoAndErrorWithValidationGroups()
83+
{
84+
$request = Request::create('/');
85+
86+
$argument = new ArgumentMetadata('HeaderPayload', HeaderPayloadDto::class, false, false, null, false, [
87+
MapRequestHeader::class => new MapRequestHeader(validationGroups: ['strict']),
88+
]);
89+
90+
try {
91+
$this->resolver->resolve($request, $argument);
92+
$this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class));
93+
} catch (HttpException $e) {
94+
$validationFailedException = $e->getPrevious();
95+
96+
$this->assertSame(422, $e->getStatusCode());
97+
$this->assertInstanceOf(ValidationFailedException::class, $validationFailedException);
98+
$this->assertSame('host', $validationFailedException->getViolations()[0]->getPropertyPath());
99+
$this->assertSame('This value should be equal to "symfony.com".', $validationFailedException->getViolations()[0]->getMessage());
100+
}
101+
}
102+
103+
public function testWithDtoAndDefaultValidationPassed()
104+
{
105+
$payload = new HeaderPayloadDto(
106+
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
107+
'localhost'
108+
);
109+
110+
$request = Request::create('/');
111+
112+
$argument = new ArgumentMetadata('HeaderPayload', HeaderPayloadDto::class, false, false, null, false, [
113+
MapRequestHeader::class => new MapRequestHeader(),
114+
]);
115+
116+
$arguments = $this->resolver->resolve($request, $argument);
117+
118+
$this->assertEquals([$payload], $arguments);
119+
}
120+
}
121+
122+
class HeaderPayloadDto
123+
{
124+
public function __construct(
125+
public readonly string $accept,
126+
#[Assert\EqualTo('symfony.com', groups: ['strict'])]
127+
public readonly string $host,
128+
) {
129+
}
130+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy