Skip to content
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

[HttpKernel] Improve error reporting when requiring the wrong Request class #54107

Merged
merged 1 commit into from Mar 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Component/HttpKernel/CHANGELOG.md
Expand Up @@ -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
---
Expand Down
29 changes: 24 additions & 5 deletions src/Symfony/Component/HttpKernel/Controller/ArgumentResolver.php
Expand Up @@ -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;

Expand Down Expand Up @@ -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()) {
Expand All @@ -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;
}
}
ilyachase marked this conversation as resolved.
Show resolved Hide resolved

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;
Expand Down
Expand Up @@ -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.
Expand All @@ -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 [];
}
}
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
}
@@ -0,0 +1,21 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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.
*/
nicolas-grekas marked this conversation as resolved.
Show resolved Hide resolved
class NearMissValueResolverException extends \RuntimeException
{
}
@@ -0,0 +1,47 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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));
}
}
Expand Up @@ -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
{
Expand Down Expand Up @@ -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());
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down