diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 0a55afe0df490..b179cbbee2a1f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -80,6 +80,7 @@ use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\UidValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -416,6 +417,8 @@ public function load(array $configs, ContainerBuilder $container) } $this->registerUidConfiguration($config['uid'], $container, $loader); + } else { + $container->removeDefinition('argument_resolver.uid'); } // register cache before session so both can share the connection services @@ -2577,6 +2580,10 @@ private function registerUidConfiguration(array $config, ContainerBuilder $conta $container->getDefinition('name_based_uuid.factory') ->setArguments([$config['name_based_uuid_namespace']]); } + + if (!class_exists(UidValueResolver::class)) { + $container->removeDefinition('argument_resolver.uid'); + } } private function resolveTrustedHeaders(array $headers): int diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index 3efa0f4079d78..f2a229dab8423 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\ServiceValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\SessionValueResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\UidValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\VariadicValueResolver; use Symfony\Component\HttpKernel\Controller\ErrorController; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; @@ -50,6 +51,11 @@ 'priority' => 105, // prior to the RequestAttributeValueResolver ]) + ->set('argument_resolver.uid', UidValueResolver::class) + ->tag('controller.argument_value_resolver', [ + 'priority' => 100, // same priority than RequestAttributeValueResolver, but registered before + ]) + ->set('argument_resolver.request_attribute', RequestAttributeValueResolver::class) ->tag('controller.argument_value_resolver', ['priority' => 100]) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Controller/UidController.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Controller/UidController.php new file mode 100644 index 0000000000000..2653f9fbcf82b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Controller/UidController.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Controller; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\UuidV1; + +class UidController +{ + #[Route(path: '/1/uuid-v1/{userId}')] + public function anyFormat(UuidV1 $userId): Response + { + return new Response($userId); + } + + #[Route(path: '/2/ulid/{id}', requirements: ['id' => '[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{22}'])] + public function specificFormatInAttribute(Ulid $id): Response + { + return new Response($id); + } + + #[Route(path: '/3/uuid-v1/{id<[0123456789ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz]{26}>}')] + public function specificFormatInPath(UuidV1 $id): Response + { + return new Response($id); + } + + #[Route(path: '/4/uuid-v1/{postId}/custom-uid/{commentId}')] + public function manyUids(UuidV1 $postId, TestCommentIdentifier $commentId): Response + { + return new Response($postId."\n".$commentId); + } +} + +class TestCommentIdentifier extends Ulid +{ +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml index 8d41ce3267131..bfd37826b268b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml @@ -60,3 +60,7 @@ array_controller: send_email: path: /send_email defaults: { _controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Controller\EmailController::indexAction } + +uid: + resource: "../../Controller/UidController.php" + type: "annotation" diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/UidTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/UidTest.php new file mode 100644 index 0000000000000..c7b6d58cb1d6c --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/UidTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Controller\UidController; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\UidValueResolver; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\UuidV1; +use Symfony\Component\Uid\UuidV4; +use Symfony\Component\Uid\UuidV6; + +/** + * @see UidController + */ +class UidTest extends AbstractWebTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + self::deleteTmpDir(); + } + + public function testArgumentValueResolverDisabled() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\Controller\UidController::anyFormat(): Argument #1 ($userId) must be of type Symfony\Component\Uid\UuidV1, string given'); + + $client = $this->createClient(['test_case' => 'Uid', 'root_config' => 'config_disabled.yml']); + + $client->request('GET', '/1/uuid-v1/'.new UuidV1()); + } + + public function testArgumentValueResolverEnabled() + { + if (!class_exists(UidValueResolver::class)) { + $this->markTestSkipped('Needs symfony/http-kernel >= 6.1'); + } + + $client = $this->createClient(['test_case' => 'Uid', 'root_config' => 'config_enabled.yml']); + + // Any format + $client->request('GET', '/1/uuid-v1/'.$uuidV1 = new UuidV1()); + $this->assertSame((string) $uuidV1, $client->getResponse()->getContent()); + $client->request('GET', '/1/uuid-v1/'.$uuidV1->toBase58()); + $this->assertSame((string) $uuidV1, $client->getResponse()->getContent()); + $client->request('GET', '/1/uuid-v1/'.$uuidV1->toRfc4122()); + $this->assertSame((string) $uuidV1, $client->getResponse()->getContent()); + // Bad version + $client->request('GET', '/1/uuid-v1/'.$uuidV4 = new UuidV4()); + $this->assertSame(404, $client->getResponse()->getStatusCode()); + + // Only base58 format + $client->request('GET', '/2/ulid/'.($ulid = new Ulid())->toBase58()); + $this->assertSame((string) $ulid, $client->getResponse()->getContent()); + $client->request('GET', '/2/ulid/'.$ulid); + $this->assertSame(404, $client->getResponse()->getStatusCode()); + $client->request('GET', '/2/ulid/'.$ulid->toRfc4122()); + $this->assertSame(404, $client->getResponse()->getStatusCode()); + + // Only base32 format + $client->request('GET', '/3/uuid-v1/'.$uuidV1->toBase32()); + $this->assertSame((string) $uuidV1, $client->getResponse()->getContent()); + $client->request('GET', '/3/uuid-v1/'.$uuidV1); + $this->assertSame(404, $client->getResponse()->getStatusCode()); + $client->request('GET', '/3/uuid-v1/'.$uuidV1->toBase58()); + $this->assertSame(404, $client->getResponse()->getStatusCode()); + // Bad version + $client->request('GET', '/3/uuid-v1/'.(new UuidV6())->toBase32()); + $this->assertSame(404, $client->getResponse()->getStatusCode()); + + // Any format for both + $client->request('GET', '/4/uuid-v1/'.$uuidV1.'/custom-uid/'.$ulid->toRfc4122()); + $this->assertSame($uuidV1."\n".$ulid, $client->getResponse()->getContent()); + $client->request('GET', '/4/uuid-v1/'.$uuidV1->toBase58().'/custom-uid/'.$ulid->toBase58()); + $this->assertSame($uuidV1."\n".$ulid, $client->getResponse()->getContent()); + // Bad version + $client->request('GET', '/4/uuid-v1/'.$uuidV4.'/custom-uid/'.$ulid); + $this->assertSame(404, $client->getResponse()->getStatusCode()); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/bundles.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/bundles.php new file mode 100644 index 0000000000000..15ff182c6fed5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/bundles.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\TestBundle; + +return [ + new FrameworkBundle(), + new TestBundle(), +]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/config_disabled.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/config_disabled.yml new file mode 100644 index 0000000000000..2706e31fe9f56 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/config_disabled.yml @@ -0,0 +1,2 @@ +imports: + - { resource: "../config/default.yml" } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/config_enabled.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/config_enabled.yml new file mode 100644 index 0000000000000..f3139be1d19e5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/config_enabled.yml @@ -0,0 +1,5 @@ +imports: + - { resource: "../config/default.yml" } + +framework: + uid: ~ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/routing.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/routing.yml new file mode 100644 index 0000000000000..46a625a0183cb --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Uid/routing.yml @@ -0,0 +1,2 @@ +uid: + resource: "@TestBundle/Resources/config/routing.yml" diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index f19610887be35..f86766ad51fcb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -62,6 +62,7 @@ "symfony/workflow": "^5.4|^6.0", "symfony/yaml": "^5.4|^6.0", "symfony/property-info": "^5.4|^6.0", + "symfony/uid": "^5.4|^6.0", "symfony/web-link": "^5.4|^6.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", "paragonie/sodium_compat": "^1.8", diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 368e05d071f2a..e65ea735b1a0b 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add `BackedEnumValueResolver` to resolve backed enum cases from request attributes in controller arguments * Deprecate StreamedResponseListener, it's not needed anymore * Add `Profiler::isEnabled()` so collaborating collector services may elect to omit themselves. + * Add the `UidValueResolver` argument value resolver 6.0 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/UidValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/UidValueResolver.php new file mode 100644 index 0000000000000..feafa56f2f65a --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/UidValueResolver.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\Controller\ArgumentResolver; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Uid\AbstractUid; + +final class UidValueResolver implements ArgumentValueResolverInterface +{ + /** + * {@inheritdoc} + */ + public function supports(Request $request, ArgumentMetadata $argument): bool + { + return !$argument->isVariadic() + && \is_string($request->attributes->get($argument->getName())) + && null !== $argument->getType() + && is_subclass_of($argument->getType(), AbstractUid::class, true); + } + + /** + * {@inheritdoc} + */ + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + /** @var class-string $uidClass */ + $uidClass = $argument->getType(); + + try { + return [$uidClass::fromString($request->attributes->get($argument->getName()))]; + } catch (\InvalidArgumentException $e) { + throw new NotFoundHttpException(sprintf('The uid for the "%s" parameter is invalid.', $argument->getName()), $e); + } + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UidValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UidValueResolverTest.php new file mode 100644 index 0000000000000..64065699c17bc --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/UidValueResolverTest.php @@ -0,0 +1,120 @@ + + * + * 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\Request; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\UidValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Uid\AbstractUid; +use Symfony\Component\Uid\Factory\UlidFactory; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\UuidV1; +use Symfony\Component\Uid\UuidV4; + +class UidValueResolverTest extends TestCase +{ + /** + * @dataProvider provideSupports + */ + public function testSupports(bool $expected, Request $request, ArgumentMetadata $argument) + { + $this->assertSame($expected, (new UidValueResolver())->supports($request, $argument)); + } + + public function provideSupports() + { + return [ + 'Variadic argument' => [false, new Request([], [], ['foo' => (string) $uuidV4 = new UuidV4()]), new ArgumentMetadata('foo', UuidV4::class, true, false, null)], + 'No attribute for argument' => [false, new Request([], [], []), new ArgumentMetadata('foo', UuidV4::class, false, false, null)], + 'Attribute is not a string' => [false, new Request([], [], ['foo' => ['bar']]), new ArgumentMetadata('foo', UuidV4::class, false, false, null)], + 'Argument has no type' => [false, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', null, false, false, null)], + 'Argument type is not a class' => [false, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', 'string', false, false, null)], + 'Argument type is not a subclass of AbstractUid' => [false, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', UlidFactory::class, false, false, null)], + 'AbstractUid is not supported' => [false, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', AbstractUid::class, false, false, null)], + 'Custom abstract subclass is supported but will fail in resolve' => [true, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', TestAbstractCustomUid::class, false, false, null)], + 'Known subclass' => [true, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', UuidV4::class, false, false, null)], + 'Format does not matter' => [true, new Request([], [], ['foo' => (string) $uuidV4]), new ArgumentMetadata('foo', Ulid::class, false, false, null)], + 'Custom subclass' => [true, new Request([], [], ['foo' => '01FPND7BD15ZV07X5VGDXAJ8VD']), new ArgumentMetadata('foo', TestCustomUid::class, false, false, null)], + ]; + } + + /** + * @dataProvider provideResolveOK + */ + public function testResolveOK(AbstractUid $expected, string $requestUid) + { + $this->assertEquals([$expected], (new UidValueResolver())->resolve( + new Request([], [], ['id' => $requestUid]), + new ArgumentMetadata('id', \get_class($expected), false, false, null) + )); + } + + public function provideResolveOK() + { + return [ + [$uuidV1 = new UuidV1(), (string) $uuidV1], + [$uuidV1, $uuidV1->toBase58()], + [$uuidV1, $uuidV1->toBase32()], + [$ulid = Ulid::fromBase32('01FQC6Y03WDZ73DQY9RXQMPHB1'), (string) $ulid], + [$ulid, $ulid->toBase58()], + [$ulid, $ulid->toRfc4122()], + [$customUid = new TestCustomUid(), (string) $customUid], + [$customUid, $customUid->toBase58()], + [$customUid, $customUid->toBase32()], + ]; + } + + /** + * @dataProvider provideResolveKO + */ + public function testResolveKO(string $requestUid, string $argumentType) + { + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('The uid for the "id" parameter is invalid.'); + + (new UidValueResolver())->resolve( + new Request([], [], ['id' => $requestUid]), + new ArgumentMetadata('id', $argumentType, false, false, null) + ); + } + + public function provideResolveKO() + { + return [ + 'Bad value for UUID' => ['ccc', UuidV1::class], + 'Bad value for ULID' => ['ccc', Ulid::class], + 'Bad value for custom UID' => ['ccc', TestCustomUid::class], + 'Bad UUID version' => [(string) new UuidV4(), UuidV1::class], + ]; + } + + public function testResolveAbstractClass() + { + $this->expectException(\Error::class); + $this->expectExceptionMessage('Cannot instantiate abstract class Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\TestAbstractCustomUid'); + + (new UidValueResolver())->resolve( + new Request([], [], ['id' => (string) new UuidV1()]), + new ArgumentMetadata('id', TestAbstractCustomUid::class, false, false, null) + ); + } +} + +class TestCustomUid extends UuidV1 +{ +} + +abstract class TestAbstractCustomUid extends UuidV1 +{ +} diff --git a/src/Symfony/Component/HttpKernel/composer.json b/src/Symfony/Component/HttpKernel/composer.json index e28f8585624e7..4c7e77f39e16d 100644 --- a/src/Symfony/Component/HttpKernel/composer.json +++ b/src/Symfony/Component/HttpKernel/composer.json @@ -38,6 +38,7 @@ "symfony/stopwatch": "^5.4|^6.0", "symfony/translation": "^5.4|^6.0", "symfony/translation-contracts": "^1.1|^2|^3", + "symfony/uid": "^5.4|^6.0", "psr/cache": "^1.0|^2.0|^3.0", "twig/twig": "^2.13|^3.0.4" },