diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 712da050db306..c329b8992daff 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add method `isKernelTerminating()` to `ExceptionEvent` that allows to check if an exception was thrown while the kernel is being terminated * Add `HttpException::fromStatusCode()` * Add `$validationFailedStatusCode` argument to `#[MapQueryParameter]` that allows setting a custom HTTP status code when validation fails + * `NearMissValueResolverException` is introduced that lets value resolvers tell when an argument could be under their watch but failed to be resolved 7.0 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php index dbd47b73db36f..53856692c5de4 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php @@ -21,6 +21,7 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver\VariadicValueResolver; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactoryInterface; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; use Symfony\Component\HttpKernel\Exception\ResolverNotFoundException; use Symfony\Contracts\Service\ServiceProviderInterface; @@ -78,15 +79,20 @@ public function getArguments(Request $request, callable $controller, ?\Reflectio } } + $valueResolverExceptions = []; foreach ($argumentValueResolvers as $name => $resolver) { if (isset($disabledResolvers[\is_int($name) ? $resolver::class : $name])) { continue; } - $count = 0; - foreach ($resolver->resolve($request, $metadata) as $argument) { - ++$count; - $arguments[] = $argument; + try { + $count = 0; + foreach ($resolver->resolve($request, $metadata) as $argument) { + ++$count; + $arguments[] = $argument; + } + } catch (NearMissValueResolverException $e) { + $valueResolverExceptions[] = $e; } if (1 < $count && !$metadata->isVariadic()) { @@ -99,7 +105,20 @@ public function getArguments(Request $request, callable $controller, ?\Reflectio } } - throw new \RuntimeException(sprintf('Controller "%s" requires that you provide a value for the "$%s" argument. Either the argument is nullable and no null value has been provided, no default value has been provided or there is a non-optional argument after this one.', $this->getPrettyName($controller), $metadata->getName())); + $reasons = array_map(static fn (NearMissValueResolverException $e) => $e->getMessage(), $valueResolverExceptions); + if (!$reasons) { + $reasons[] = 'Either the argument is nullable and no null value has been provided, no default value has been provided or there is a non-optional argument after this one.'; + } + + $reasonCounter = 1; + if (\count($reasons) > 1) { + foreach ($reasons as $i => $reason) { + $reasons[$i] = $reasonCounter.') '.$reason; + ++$reasonCounter; + } + } + + throw new \RuntimeException(sprintf('Controller "%s" requires the "$%s" argument that could not be resolved. '.($reasonCounter > 1 ? 'Possible reasons: ' : '').'%s', $this->getPrettyName($controller), $metadata->getName(), implode(' ', $reasons))); } return $arguments; diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestValueResolver.php index bf2d2a0af6bc9..bc34089f4f982 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestValueResolver.php @@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; /** * Yields the same instance as the request object passed along. @@ -24,6 +25,14 @@ final class RequestValueResolver implements ValueResolverInterface { public function resolve(Request $request, ArgumentMetadata $argument): array { - return Request::class === $argument->getType() || is_subclass_of($argument->getType(), Request::class) ? [$request] : []; + if (Request::class === $argument->getType() || is_subclass_of($argument->getType(), Request::class)) { + return [$request]; + } + + if (str_ends_with($argument->getType() ?? '', '\\Request')) { + throw new NearMissValueResolverException(sprintf('Looks like you required a Request object with the wrong class name "%s". Did you mean to use "%s" instead?', $argument->getType(), Request::class)); + } + + return []; } } diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php index cf92b81e334f1..4ac10a45b0868 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; /** * Yields a service keyed by _controller and argument name. @@ -61,10 +62,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): array $message = sprintf('Cannot resolve %s: %s', $what, $message); } - $r = new \ReflectionProperty($e, 'message'); - $r->setValue($e, $message); - - throw $e; + throw new NearMissValueResolverException($message, $e->getCode(), $e); } } } diff --git a/src/Symfony/Component/HttpKernel/Exception/NearMissValueResolverException.php b/src/Symfony/Component/HttpKernel/Exception/NearMissValueResolverException.php new file mode 100644 index 0000000000000..73ccfe916a89f --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Exception/NearMissValueResolverException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Exception; + +/** + * Lets value resolvers tell when an argument could be under their watch but failed to be resolved. + * + * Throwing this exception inside `ValueResolverInterface::resolve` does not interrupt the value resolvers chain. + */ +class NearMissValueResolverException extends \RuntimeException +{ +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestValueResolverTest.php new file mode 100644 index 0000000000000..7d7e091d40de3 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestValueResolverTest.php @@ -0,0 +1,47 @@ + + * + * 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\BrowserKit\Request as RandomRequest; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; + +class RequestValueResolverTest extends TestCase +{ + public function testSameRequestReturned() + { + $resolver = new RequestValueResolver(); + $expectedRequest = Request::create('/'); + $actualRequest = $resolver->resolve($expectedRequest, new ArgumentMetadata('request', Request::class, false, false, null)); + self::assertCount(1, $actualRequest); + self::assertSame($expectedRequest, $actualRequest[0] ?? null); + } + + public function testRequestIsNotResolvedForRandomClass() + { + $resolver = new RequestValueResolver(); + $expectedRequest = Request::create('/'); + $actualRequest = $resolver->resolve($expectedRequest, new ArgumentMetadata('request', self::class, false, false, null)); + self::assertCount(0, $actualRequest); + } + + public function testExceptionThrownForRandomRequestClass() + { + $resolver = new RequestValueResolver(); + $expectedRequest = Request::create('/'); + $this->expectException(NearMissValueResolverException::class); + $resolver->resolve($expectedRequest, new ArgumentMetadata('request', RandomRequest::class, false, false, null)); + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php index df248047d0ea1..59e81a9ae5ad0 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php @@ -13,12 +13,12 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Exception\RuntimeException; use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\ServiceValueResolver; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\DependencyInjection\RegisterControllerArgumentLocatorsPass; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; class ServiceValueResolverTest extends TestCase { @@ -88,7 +88,7 @@ public function testControllerNameIsAnArray() public function testErrorIsTruncated() { - $this->expectException(RuntimeException::class); + $this->expectException(NearMissValueResolverException::class); $this->expectExceptionMessage('Cannot autowire argument $dummy of "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyController::index()": it references class "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyService" but no such service exists.'); $container = new ContainerBuilder(); $container->addCompilerPass(new RegisterControllerArgumentLocatorsPass()); diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php index c8e97d53216f1..0a16e0e11c3fe 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolverTest.php @@ -24,6 +24,7 @@ use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; use Symfony\Component\HttpKernel\Exception\ResolverNotFoundException; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\ExtendingRequest; use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\ExtendingSession; @@ -179,10 +180,11 @@ public function testGetVariadicArgumentsWithoutArrayInRequest() public function testIfExceptionIsThrownWhenMissingAnArgument() { - $this->expectException(\RuntimeException::class); $request = Request::create('/'); $controller = (new ArgumentResolverTestController())->controllerWithFoo(...); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Controller "Closure" requires the "$foo" argument that could not be resolved. Either the argument is nullable and no null value has been provided, no default value has been provided or there is a non-optional argument after this one.'); self::getResolver()->getArguments($request, $controller); } @@ -345,6 +347,68 @@ public function testUnknownTargetedResolver() $this->expectException(ResolverNotFoundException::class); $resolver->getArguments($request, $controller); } + + public function testResolversChainCompletionWhenResolverThrowsSpecialException() + { + $failingValueResolver = new class() implements ValueResolverInterface { + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + throw new NearMissValueResolverException('This resolver throws an exception'); + } + }; + // Put failing value resolver in the beginning + $expectedToCallValueResolver = $this->createMock(ValueResolverInterface::class); + $expectedToCallValueResolver->expects($this->once())->method('resolve')->willReturn([123]); + + $resolver = self::getResolver([$failingValueResolver, ...ArgumentResolver::getDefaultArgumentValueResolvers(), $expectedToCallValueResolver]); + $request = Request::create('/'); + $controller = [new ArgumentResolverTestController(), 'controllerWithFoo']; + + $actualArguments = $resolver->getArguments($request, $controller); + self::assertEquals([123], $actualArguments); + } + + public function testExceptionListSingle() + { + $failingValueResolverOne = new class() implements ValueResolverInterface { + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + throw new NearMissValueResolverException('Some reason why value could not be resolved.'); + } + }; + + $resolver = self::getResolver([$failingValueResolverOne]); + $request = Request::create('/'); + $controller = [new ArgumentResolverTestController(), 'controllerWithFoo']; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Controller "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolverTestController::controllerWithFoo" requires the "$foo" argument that could not be resolved. Some reason why value could not be resolved.'); + $resolver->getArguments($request, $controller); + } + + public function testExceptionListMultiple() + { + $failingValueResolverOne = new class() implements ValueResolverInterface { + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + throw new NearMissValueResolverException('Some reason why value could not be resolved.'); + } + }; + $failingValueResolverTwo = new class() implements ValueResolverInterface { + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + throw new NearMissValueResolverException('Another reason why value could not be resolved.'); + } + }; + + $resolver = self::getResolver([$failingValueResolverOne, $failingValueResolverTwo]); + $request = Request::create('/'); + $controller = [new ArgumentResolverTestController(), 'controllerWithFoo']; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Controller "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolverTestController::controllerWithFoo" requires the "$foo" argument that could not be resolved. Possible reasons: 1) Some reason why value could not be resolved. 2) Another reason why value could not be resolved.'); + $resolver->getArguments($request, $controller); + } } class ArgumentResolverTestController