diff --git a/src/Symfony/Bridge/Doctrine/Attribute/Entity.php b/src/Symfony/Bridge/Doctrine/Attribute/Entity.php new file mode 100644 index 0000000000000..4c6b78abb6587 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Attribute/Entity.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Attribute; + +use Symfony\Component\HttpKernel\Attribute\ParamConverter; + +/** + * Doctrine-specific ParamConverter with an easier syntax. + * + * @author Ryan Weaver + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +class Entity extends ParamConverter +{ + public function setExpr($expr) + { + $options = $this->getOptions(); + $options['expr'] = $expr; + + $this->setOptions($options); + } + + public function __construct( + string $name, + string $expr = null, + string $class = null, + array $options = [], + bool $isOptional = false, + string $converter = null + ) { + parent::__construct($name, $class, $options, $isOptional, $converter); + + $this->setExpr($expr); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Controller/EntityParamConverter.php b/src/Symfony/Bridge/Doctrine/Controller/EntityParamConverter.php new file mode 100644 index 0000000000000..949dba12ff8bc --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Controller/EntityParamConverter.php @@ -0,0 +1,345 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Controller; + +use Doctrine\DBAL\Types\ConversionException; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\NoResultException; +use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\SyntaxError; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\ParamConverter; +use Symfony\Component\HttpKernel\Controller\ParamConverter\ParamConverterInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * EntityParamConverter + * + * @author Fabien Potencier + */ +class EntityParamConverter implements ParamConverterInterface +{ + /** + * @var ManagerRegistry + */ + private $registry; + + /** + * @var ExpressionLanguage + */ + private $language; + + /** + * @var array + */ + private $defaultOptions; + + public function __construct(ManagerRegistry $registry = null, ExpressionLanguage $expressionLanguage = null, array $options = []) + { + $this->registry = $registry; + $this->language = $expressionLanguage; + + $defaultValues = [ + 'entity_manager' => null, + 'exclude' => [], + 'mapping' => [], + 'strip_null' => false, + 'expr' => null, + 'id' => null, + 'repository_method' => null, + 'map_method_signature' => false, + 'evict_cache' => false, + ]; + + $this->defaultOptions = array_merge($defaultValues, $options); + } + + /** + * {@inheritdoc} + * + * @throws \LogicException When unable to guess how to get a Doctrine instance from the request information + * @throws NotFoundHttpException When object not found + */ + public function apply(Request $request, ParamConverter $configuration) + { + $name = $configuration->getName(); + $class = $configuration->getClass(); + $options = $this->getOptions($configuration); + + if (null === $request->attributes->get($name, false)) { + $configuration->setIsOptional(true); + } + + $errorMessage = null; + if ($expr = $options['expr']) { + $object = $this->findViaExpression($class, $request, $expr, $options, $configuration); + + if (null === $object) { + $errorMessage = sprintf('The expression "%s" returned null', $expr); + } + + // find by identifier? + } elseif (false === $object = $this->find($class, $request, $options, $name)) { + // find by criteria + if (false === $object = $this->findOneBy($class, $request, $options)) { + if ($configuration->isOptional()) { + $object = null; + } else { + throw new \LogicException(sprintf('Unable to guess how to get a Doctrine instance from the request information for parameter "%s".', $name)); + } + } + } + + if (null === $object && false === $configuration->isOptional()) { + $message = sprintf('%s object not found by the @%s annotation.', $class, $this->getAttributeName($configuration)); + if ($errorMessage) { + $message .= ' '.$errorMessage; + } + throw new NotFoundHttpException($message); + } + + $request->attributes->set($name, $object); + + return true; + } + + private function find($class, Request $request, $options, $name) + { + if ($options['mapping'] || $options['exclude']) { + return false; + } + + $id = $this->getIdentifier($request, $options, $name); + + if (false === $id || null === $id) { + return false; + } + + if ($options['repository_method']) { + $method = $options['repository_method']; + } else { + $method = 'find'; + } + + $om = $this->getManager($options['entity_manager'], $class); + if ($options['evict_cache'] && $om instanceof EntityManagerInterface) { + $cacheProvider = $om->getCache(); + if ($cacheProvider && $cacheProvider->containsEntity($class, $id)) { + $cacheProvider->evictEntity($class, $id); + } + } + + try { + return $om->getRepository($class)->$method($id); + } catch (NoResultException $e) { + return; + } catch (ConversionException $e) { + return; + } + } + + private function getIdentifier(Request $request, $options, $name) + { + if (null !== $options['id']) { + if (!\is_array($options['id'])) { + $name = $options['id']; + } elseif (\is_array($options['id'])) { + $id = []; + foreach ($options['id'] as $field) { + if (false !== strstr($field, '%s')) { + // Convert "%s_uuid" to "foobar_uuid" + $field = sprintf($field, $name); + } + $id[$field] = $request->attributes->get($field); + } + + return $id; + } + } + + if ($request->attributes->has($name)) { + return $request->attributes->get($name); + } + + if ($request->attributes->has('id') && !$options['id']) { + return $request->attributes->get('id'); + } + + return false; + } + + private function findOneBy($class, Request $request, $options) + { + if (!$options['mapping']) { + $keys = $request->attributes->keys(); + $options['mapping'] = $keys ? array_combine($keys, $keys) : []; + } + + foreach ($options['exclude'] as $exclude) { + unset($options['mapping'][$exclude]); + } + + if (!$options['mapping']) { + return false; + } + + // if a specific id has been defined in the options and there is no corresponding attribute + // return false in order to avoid a fallback to the id which might be of another object + if ($options['id'] && null === $request->attributes->get($options['id'])) { + return false; + } + + $criteria = []; + $em = $this->getManager($options['entity_manager'], $class); + $metadata = $em->getClassMetadata($class); + + $mapMethodSignature = $options['repository_method'] + && $options['map_method_signature'] + && true === $options['map_method_signature']; + + foreach ($options['mapping'] as $attribute => $field) { + if ($metadata->hasField($field) + || ($metadata->hasAssociation($field) && $metadata->isSingleValuedAssociation($field)) + || $mapMethodSignature) { + $criteria[$field] = $request->attributes->get($attribute); + } + } + + if ($options['strip_null']) { + $criteria = array_filter($criteria, function ($value) { + return null !== $value; + }); + } + + if (!$criteria) { + return false; + } + + if ($options['repository_method']) { + $repositoryMethod = $options['repository_method']; + } else { + $repositoryMethod = 'findOneBy'; + } + + try { + if ($mapMethodSignature) { + return $this->findDataByMapMethodSignature($em, $class, $repositoryMethod, $criteria); + } + + return $em->getRepository($class)->$repositoryMethod($criteria); + } catch (NoResultException $e) { + return; + } catch (ConversionException $e) { + return; + } + } + + private function findDataByMapMethodSignature($em, $class, $repositoryMethod, $criteria) + { + $arguments = []; + $repository = $em->getRepository($class); + $ref = new \ReflectionMethod($repository, $repositoryMethod); + foreach ($ref->getParameters() as $parameter) { + if (\array_key_exists($parameter->name, $criteria)) { + $arguments[] = $criteria[$parameter->name]; + } elseif ($parameter->isDefaultValueAvailable()) { + $arguments[] = $parameter->getDefaultValue(); + } else { + throw new \InvalidArgumentException(sprintf('Repository method "%s::%s" requires that you provide a value for the "$%s" argument.', \get_class($repository), $repositoryMethod, $parameter->name)); + } + } + + return $ref->invokeArgs($repository, $arguments); + } + + private function findViaExpression($class, Request $request, $expression, $options, ParamConverter $configuration) + { + if (null === $this->language) { + throw new \LogicException(sprintf('To use the @%s tag with the "expr" option, you need to install the ExpressionLanguage component.', $this->getAttributeName($configuration))); + } + + $repository = $this->getManager($options['entity_manager'], $class)->getRepository($class); + $variables = array_merge($request->attributes->all(), ['repository' => $repository]); + + try { + return $this->language->evaluate($expression, $variables); + } catch (NoResultException $e) { + return; + } catch (ConversionException $e) { + return; + } catch (SyntaxError $e) { + throw new \LogicException(sprintf('Error parsing expression -- "%s" -- (%s).', $expression, $e->getMessage()), 0, $e); + } + } + + /** + * {@inheritdoc} + */ + public function supports(ParamConverter $configuration) + { + // if there is no manager, this means that only Doctrine DBAL is configured + if (null === $this->registry || !\count($this->registry->getManagerNames())) { + return false; + } + + if (null === $configuration->getClass()) { + return false; + } + + $options = $this->getOptions($configuration, false); + + // Doctrine Entity? + $em = $this->getManager($options['entity_manager'], $configuration->getClass()); + if (null === $em) { + return false; + } + + return !$em->getMetadataFactory()->isTransient($configuration->getClass()); + } + + private function getOptions(ParamConverter $configuration, $strict = true) + { + $passedOptions = $configuration->getOptions(); + + if (isset($passedOptions['repository_method'])) { + @trigger_error('The repository_method option of @ParamConverter is deprecated and will be removed in 6.0. Use the expr option or @Entity.', \E_USER_DEPRECATED); + } + + if (isset($passedOptions['map_method_signature'])) { + @trigger_error('The map_method_signature option of @ParamConverter is deprecated and will be removed in 6.0. Use the expr option or @Entity.', \E_USER_DEPRECATED); + } + + $extraKeys = array_diff(array_keys($passedOptions), array_keys($this->defaultOptions)); + if ($extraKeys && $strict) { + throw new \InvalidArgumentException(sprintf('Invalid option(s) passed to @%s: "%s".', $this->getAttributeName($configuration), implode(', ', $extraKeys))); + } + + return array_replace($this->defaultOptions, $passedOptions); + } + + private function getManager($name, $class) + { + if (null === $name) { + return $this->registry->getManagerForClass($class); + } + + return $this->registry->getManager($name); + } + + private function getAttributeName(ParamConverter $configuration) + { + $r = new \ReflectionClass($configuration); + + return $r->getShortName(); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Controller/EntityParamConverterTest.php b/src/Symfony/Bridge/Doctrine/Tests/Controller/EntityParamConverterTest.php new file mode 100644 index 0000000000000..ac8266d4a44c3 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Controller/EntityParamConverterTest.php @@ -0,0 +1,692 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Controller; + +use Doctrine\DBAL\Types\ConversionException; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Doctrine\Persistence\ObjectManager; +use Doctrine\Persistence\ObjectRepository; +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\Controller\EntityParamConverter; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\SyntaxError; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\ParamConverter; + +class EntityParamConverterTest extends TestCase +{ + /** + * @var ManagerRegistry + */ + private $registry; + + /** + * @var ExpressionLanguage + */ + private $language; + + /** + * @var EntityParamConverter + */ + private $converter; + + protected function setUp(): void + { + $this->registry = $this->getMockBuilder(ManagerRegistry::class)->getMock(); + $this->language = $this->getMockBuilder(ExpressionLanguage::class)->getMock(); + $this->converter = new EntityParamConverter($this->registry, $this->language); + } + + public function createConfiguration($class = null, array $options = null, $name = 'arg', $isOptional = false) + { + $methods = ['getClass', 'getAliasName', 'getOptions', 'getName', 'allowArray']; + if (null !== $isOptional) { + $methods[] = 'isOptional'; + } + $config = $this + ->getMockBuilder(ParamConverter::class) + ->disableOriginalConstructor() + ->setMethods($methods) + ->getMock(); + if (null !== $options) { + $config->expects($this->once()) + ->method('getOptions') + ->willReturn($options); + } + if (null !== $class) { + $config->expects($this->any()) + ->method('getClass') + ->willReturn($class); + } + $config->expects($this->any()) + ->method('getName') + ->willReturn($name); + if (null !== $isOptional) { + $config->expects($this->any()) + ->method('isOptional') + ->willReturn($isOptional); + } + + return $config; + } + + public function testApplyWithNoIdAndData() + { + $this->expectException(\LogicException::class); + + $request = new Request(); + $config = $this->createConfiguration(null, []); + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + + $this->converter->apply($request, $config); + } + + public function testApplyWithNoIdAndDataOptional() + { + $request = new Request(); + $config = $this->createConfiguration(null, [], 'arg', true); + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + + $ret = $this->converter->apply($request, $config); + + $this->assertTrue($ret); + $this->assertNull($request->attributes->get('arg')); + } + + public function testApplyWithStripNulls() + { + $request = new Request(); + $request->attributes->set('arg', null); + $config = $this->createConfiguration('stdClass', ['mapping' => ['arg' => 'arg'], 'strip_null' => true], 'arg', true); + + $classMetadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $manager->expects($this->once()) + ->method('getClassMetadata') + ->with('stdClass') + ->willReturn($classMetadata); + + $manager->expects($this->never()) + ->method('getRepository'); + + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $classMetadata->expects($this->once()) + ->method('hasField') + ->with($this->equalTo('arg')) + ->willReturn(true); + + $this->converter->apply($request, $config); + + $this->assertNull($request->attributes->get('arg')); + } + + /** + * @dataProvider idsProvider + */ + public function testApplyWithId($id) + { + $request = new Request(); + $request->attributes->set('id', $id); + + $config = $this->createConfiguration('stdClass', ['id' => 'id'], 'arg'); + + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $manager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($objectRepository); + + $objectRepository->expects($this->once()) + ->method('find') + ->with($this->equalTo($id)) + ->willReturn($object = new \stdClass()); + + $ret = $this->converter->apply($request, $config); + + $this->assertTrue($ret); + $this->assertSame($object, $request->attributes->get('arg')); + } + + public function testApplyWithConversionFailedException() + { + $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); + + $request = new Request(); + $request->attributes->set('id', 'test'); + + $config = $this->createConfiguration('stdClass', ['id' => 'id'], 'arg'); + + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $manager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($objectRepository); + + $objectRepository->expects($this->once()) + ->method('find') + ->with($this->equalTo('test')) + ->will($this->throwException(new ConversionException())); + + $this->converter->apply($request, $config); + } + + public function testUsedProperIdentifier() + { + $request = new Request(); + $request->attributes->set('id', 1); + $request->attributes->set('entity_id', null); + $request->attributes->set('arg', null); + + $config = $this->createConfiguration('stdClass', ['id' => 'entity_id'], 'arg', null); + + $ret = $this->converter->apply($request, $config); + + $this->assertTrue($ret); + $this->assertNull($request->attributes->get('arg')); + } + + public function idsProvider() + { + return [ + [1], + [0], + ['foo'], + ]; + } + + public function testApplyGuessOptional() + { + $request = new Request(); + $request->attributes->set('arg', null); + + $config = $this->createConfiguration('stdClass', [], 'arg', null); + + $classMetadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $manager->expects($this->once()) + ->method('getClassMetadata') + ->with('stdClass') + ->willReturn($classMetadata); + + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $manager->expects($this->never())->method('getRepository'); + + $objectRepository->expects($this->never())->method('find'); + $objectRepository->expects($this->never())->method('findOneBy'); + + $ret = $this->converter->apply($request, $config); + + $this->assertTrue($ret); + $this->assertNull($request->attributes->get('arg')); + } + + public function testApplyWithMappingAndExclude() + { + $request = new Request(); + $request->attributes->set('foo', 1); + $request->attributes->set('bar', 2); + + $config = $this->createConfiguration( + 'stdClass', + ['mapping' => ['foo' => 'Foo'], 'exclude' => ['bar']], + 'arg' + ); + + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $metadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + $repository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($manager); + + $manager->expects($this->once()) + ->method('getClassMetadata') + ->with('stdClass') + ->willReturn($metadata); + $manager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($repository); + + $metadata->expects($this->once()) + ->method('hasField') + ->with($this->equalTo('Foo')) + ->willReturn(true); + + $repository->expects($this->once()) + ->method('findOneBy') + ->with($this->equalTo(['Foo' => 1])) + ->willReturn($object = new \stdClass()); + + $ret = $this->converter->apply($request, $config); + + $this->assertTrue($ret); + $this->assertSame($object, $request->attributes->get('arg')); + } + + /** + * @group legacy + */ + public function testApplyWithRepositoryMethod() + { + $request = new Request(); + $request->attributes->set('id', 1); + + $config = $this->createConfiguration( + 'stdClass', + ['repository_method' => 'getClassName'], + 'arg' + ); + + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $manager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($objectRepository); + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($manager); + + $objectRepository->expects($this->once()) + ->method('getClassName') + ->willReturn($className = 'ObjectRepository'); + + $ret = $this->converter->apply($request, $config); + + $this->assertTrue($ret); + $this->assertSame($className, $request->attributes->get('arg')); + } + + /** + * @group legacy + */ + public function testApplyWithRepositoryMethodAndMapping() + { + $request = new Request(); + $request->attributes->set('id', 1); + + $config = $this->createConfiguration( + 'stdClass', + ['repository_method' => 'getClassName', 'mapping' => ['foo' => 'Foo']], + 'arg' + ); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + $metadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + + $objectManager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($objectRepository); + + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($objectManager); + + $metadata->expects($this->once()) + ->method('hasField') + ->with($this->equalTo('Foo')) + ->willReturn(true); + + $objectManager->expects($this->once()) + ->method('getClassMetadata') + ->willReturn($metadata); + $objectManager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($objectRepository); + + $objectRepository->expects($this->once()) + ->method('getClassName') + ->willReturn($className = 'ObjectRepository'); + + $ret = $this->converter->apply($request, $config); + + $this->assertTrue($ret); + $this->assertSame($className, $request->attributes->get('arg')); + } + + /** + * @group legacy + */ + public function testApplyWithRepositoryMethodAndMapMethodSignature() + { + $request = new Request(); + $request->attributes->set('first_name', 'Fabien'); + $request->attributes->set('last_name', 'Potencier'); + + $config = $this->createConfiguration( + 'stdClass', + [ + 'repository_method' => 'findByFullName', + 'mapping' => ['first_name' => 'firstName', 'last_name' => 'lastName'], + 'map_method_signature' => true, + ], + 'arg' + ); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = new TestUserRepository(); + $metadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + + $objectManager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($objectRepository); + + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($objectManager); + + $objectManager->expects($this->once()) + ->method('getClassMetadata') + ->willReturn($metadata); + + $ret = $this->converter->apply($request, $config); + + $this->assertTrue($ret); + $this->assertSame('Fabien Potencier', $request->attributes->get('arg')); + } + + /** + * @group legacy + */ + public function testApplyWithRepositoryMethodAndMapMethodSignatureException() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Repository method "Symfony\\Bridge\\Doctrine\\Tests\\Controller\\TestUserRepository::findByFullName" requires that you provide a value for the "$lastName" argument.'); + + $request = new Request(); + $request->attributes->set('first_name', 'Fabien'); + $request->attributes->set('last_name', 'Potencier'); + + $config = $this->createConfiguration( + 'stdClass', + [ + 'repository_method' => 'findByFullName', + 'mapping' => ['first_name' => 'firstName', 'last_name' => 'lastNameXxx'], + 'map_method_signature' => true, + ], + 'arg' + ); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = new TestUserRepository(); + $metadata = $this->getMockBuilder(ClassMetadata::class)->getMock(); + + $objectManager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($objectRepository); + + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($objectManager); + + $objectManager->expects($this->once()) + ->method('getClassMetadata') + ->willReturn($metadata); + + $this->converter->apply($request, $config); + } + + public function testSupports() + { + $config = $this->createConfiguration('stdClass', []); + $metadataFactory = $this->getMockBuilder('Doctrine\Persistence\Mapping\ClassMetadataFactory')->getMock(); + $metadataFactory->expects($this->once()) + ->method('isTransient') + ->with($this->equalTo('stdClass')) + ->willReturn(false); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectManager->expects($this->once()) + ->method('getMetadataFactory') + ->willReturn($metadataFactory); + + $this->registry->expects($this->any()) + ->method('getManagerNames') + ->willReturn(['default']); + + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->with('stdClass') + ->willReturn($objectManager); + + $ret = $this->converter->supports($config); + + $this->assertTrue($ret, 'Should be supported'); + } + + public function testSupportsWithConfiguredEntityManager() + { + $config = $this->createConfiguration('stdClass', ['entity_manager' => 'foo']); + $metadataFactory = $this->getMockBuilder('Doctrine\Persistence\Mapping\ClassMetadataFactory')->getMock(); + $metadataFactory->expects($this->once()) + ->method('isTransient') + ->with($this->equalTo('stdClass')) + ->willReturn(false); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectManager->expects($this->once()) + ->method('getMetadataFactory') + ->willReturn($metadataFactory); + + $this->registry->expects($this->once()) + ->method('getManagerNames') + ->willReturn(['default']); + + $this->registry->expects($this->once()) + ->method('getManager') + ->with('foo') + ->willReturn($objectManager); + + $ret = $this->converter->supports($config); + + $this->assertTrue($ret, 'Should be supported'); + } + + public function testSupportsWithDifferentConfiguration() + { + $config = $this->createConfiguration('DateTime', ['format' => \DateTime::ISO8601]); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectManager->expects($this->never()) + ->method('getMetadataFactory'); + + $this->registry->expects($this->any()) + ->method('getManagerNames') + ->willReturn(['default']); + + $this->registry->expects($this->never()) + ->method('getManager'); + + $ret = $this->converter->supports($config); + + $this->assertFalse($ret, 'Should not be supported'); + } + + public function testExceptionWithExpressionIfNoLanguageAvailable() + { + $this->expectException(\LogicException::class); + + $request = new Request(); + $config = $this->createConfiguration( + 'stdClass', + [ + 'expr' => 'repository.find(id)', + ], + 'arg1' + ); + + $converter = new EntityParamConverter($this->registry); + $converter->apply($request, $config); + } + + public function testExpressionFailureReturns404() + { + $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); + + $request = new Request(); + $config = $this->createConfiguration( + 'stdClass', + [ + 'expr' => 'repository.someMethod()', + ], + 'arg1' + ); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + + $objectManager->expects($this->once()) + ->method('getRepository') + ->willReturn($objectRepository); + + // find should not be attempted on this repository as a fallback + $objectRepository->expects($this->never()) + ->method('find'); + + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($objectManager); + + $this->language->expects($this->once()) + ->method('evaluate') + ->willReturn(null); + + $this->converter->apply($request, $config); + } + + public function testExpressionMapsToArgument() + { + $request = new Request(); + $request->attributes->set('id', 5); + $config = $this->createConfiguration( + 'stdClass', + [ + 'expr' => 'repository.findOneByCustomMethod(id)', + ], + 'arg1' + ); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + + $objectManager->expects($this->once()) + ->method('getRepository') + ->willReturn($objectRepository); + + // find should not be attempted on this repository as a fallback + $objectRepository->expects($this->never()) + ->method('find'); + + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($objectManager); + + $this->language->expects($this->once()) + ->method('evaluate') + ->with('repository.findOneByCustomMethod(id)', [ + 'repository' => $objectRepository, + 'id' => 5, + ]) + ->willReturn('new_mapped_value'); + + $this->converter->apply($request, $config); + $this->assertEquals('new_mapped_value', $request->attributes->get('arg1')); + } + + public function testExpressionSyntaxErrorThrowsException() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('syntax error message around position 10'); + + $request = new Request(); + $config = $this->createConfiguration( + 'stdClass', + [ + 'expr' => 'repository.findOneByCustomMethod(id)', + ], + 'arg1' + ); + + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectRepository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + + $objectManager->expects($this->once()) + ->method('getRepository') + ->willReturn($objectRepository); + + // find should not be attempted on this repository as a fallback + $objectRepository->expects($this->never()) + ->method('find'); + + $this->registry->expects($this->once()) + ->method('getManagerForClass') + ->willReturn($objectManager); + + $this->language->expects($this->once()) + ->method('evaluate') + ->will($this->throwException(new SyntaxError('syntax error message', 10))); + + $this->converter->apply($request, $config); + } + + public function testInvalidOptionThrowsException() + { + $this->expectException(\InvalidArgumentException::class); + + $configuration = new ParamConverter( + name: 'foo', + options: [ + 'fake_option' => [], + ], + ); + + $this->converter->apply(new Request(), $configuration); + } +} + +class TestUserRepository +{ + public function findByFullName($firstName, $lastName) + { + return $firstName.' '.$lastName; + } +} diff --git a/src/Symfony/Bridge/Twig/Attribute/Template.php b/src/Symfony/Bridge/Twig/Attribute/Template.php new file mode 100644 index 0000000000000..5e8d38b36f334 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Attribute/Template.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Attribute; + +/** + * The Template class handles the Template attribute parts. + * + * @author Fabien Potencier + */ +#[\Attribute(\Attribute::TARGET_METHOD)] +class Template +{ + protected ?string $template; + private array $vars = []; + private bool $streamable = false; + private array $owner = []; + + public function __construct( + string $template = null, + array $vars = [], + bool $isStreamable = false, + array $owner = [] + ) { + if (null !== $template) { + $this->template = $template; + } + + $this->setVars($vars); + $this->setIsStreamable($isStreamable); + $this->setOwner($owner); + } + + public function getVars(): array + { + return $this->vars; + } + + public function setIsStreamable(bool $streamable): void + { + $this->streamable = $streamable; + } + + public function isStreamable(): bool + { + return $this->streamable; + } + + public function setVars(array $vars): void + { + $this->vars = $vars; + } + + public function getTemplate(): ?string + { + return $this->template; + } + + public function setTemplate(?string $template): void + { + $this->template = $template; + } + + public function setOwner(array $owner): void + { + $this->owner = $owner; + } + + /** + * The controller (+action) this annotation is attached to. + */ + public function getOwner(): array + { + return $this->owner; + } +} diff --git a/src/Symfony/Bridge/Twig/EventListener/TemplateAttributeListener.php b/src/Symfony/Bridge/Twig/EventListener/TemplateAttributeListener.php new file mode 100644 index 0000000000000..8a45ba1e3d3e8 --- /dev/null +++ b/src/Symfony/Bridge/Twig/EventListener/TemplateAttributeListener.php @@ -0,0 +1,190 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\EventListener; + +use Symfony\Bridge\Twig\Attribute\Template; +use Symfony\Bridge\Twig\TemplateGuesser; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\Event\KernelEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Twig\Environment; + +/** + * Handles the Template annotation for actions. + * + * Depends on pre-processing of the ControllerListener. + * + * @author Fabien Potencier + */ +class TemplateAttributeListener implements EventSubscriberInterface +{ + private $templateGuesser; + private $twig; + + public function __construct(TemplateGuesser $templateGuesser, Environment $twig) + { + $this->templateGuesser = $templateGuesser; + $this->twig = $twig; + } + + /** + * Guesses the template name to render and its variables and adds them to + * the request object. + */ + public function onKernelController(KernelEvent $event) + { + if (!$configuration = $this->getConfiguration($event)) { + return; + } + + if (!$configuration instanceof Template) { + return; + } + + $request = $event->getRequest(); + $controller = $event->getController(); + if (!\is_array($controller) && method_exists($controller, '__invoke')) { + $controller = [$controller, '__invoke']; + } + $configuration->setOwner($controller); + + // when no template has been given, try to resolve it based on the controller + if (null === $configuration->getTemplate()) { + $configuration->setTemplate($this->templateGuesser->guessTemplateName($controller, $request)); + } + } + + /** + * Renders the template and initializes a new response object with the + * rendered template content. + */ + public function onKernelView(KernelEvent $event) + { + if (!$configuration = $this->getConfiguration($event)) { + return; + } + + if (!$configuration instanceof Template) { + return; + } + + $request = $event->getRequest(); + $parameters = $event->getControllerResult(); + $owner = $configuration->getOwner(); + list($controller, $action) = $owner; + + // when the annotation declares no default vars and the action returns + // null, all action method arguments are used as default vars + if (null === $parameters) { + $parameters = $this->resolveDefaultParameters($request, $configuration, $controller, $action); + } + + // attempt to render the actual response + if ($configuration->isStreamable()) { + $callback = function () use ($configuration, $parameters) { + $this->twig->display($configuration->getTemplate(), $parameters); + }; + + $event->setResponse(new StreamedResponse($callback)); + } else { + $event->setResponse(new Response($this->twig->render($configuration->getTemplate(), $parameters))); + } + + // make sure the owner (controller+dependencies) is not cached or stored elsewhere + $configuration->setOwner([]); + } + + /** + * @return array + */ + public static function getSubscribedEvents() + { + return [ + KernelEvents::CONTROLLER => ['onKernelController', -128], + KernelEvents::VIEW => 'onKernelView', + ]; + } + + private function getConfiguration(KernelEvent $event): ?Template + { + $request = $event->getRequest(); + + if ($configuration = $request->attributes->get('_template')) { + return $configuration; + } + + if (!$event instanceof ControllerEvent) { + return null; + } + + $controller = $event->getController(); + + if (!\is_array($controller) && method_exists($controller, '__invoke')) { + $controller = [$controller, '__invoke']; + } + + if (!\is_array($controller)) { + return null; + } + + $className = \get_class($controller[0]); + $object = new \ReflectionClass($className); + $method = $object->getMethod($controller[1]); + + $configurations = array_map( + function (\ReflectionAttribute $attribute) { + return $attribute->newInstance(); + }, + $method->getAttributes(Template::class) + ); + + if (0 === count($configurations)) { + return null; + } + + $configuration = $configurations[0]; + $request->attributes->set('_template', $configuration); + + return $configuration; + } + + private function resolveDefaultParameters(Request $request, Template $template, $controller, $action) + { + $parameters = []; + $arguments = $template->getVars(); + + if (0 === \count($arguments)) { + $r = new \ReflectionObject($controller); + + $arguments = []; + foreach ($r->getMethod($action)->getParameters() as $param) { + $arguments[] = $param; + } + } + + // fetch the arguments of @Template.vars or everything if desired + // and assign them to the designated template + foreach ($arguments as $argument) { + if ($argument instanceof \ReflectionParameter) { + $parameters[$name = $argument->getName()] = !$request->attributes->has($name) && $argument->isDefaultValueAvailable() ? $argument->getDefaultValue() : $request->attributes->get($name); + } else { + $parameters[$argument] = $request->attributes->get($argument); + } + } + + return $parameters; + } +} diff --git a/src/Symfony/Bridge/Twig/TemplateGuesser.php b/src/Symfony/Bridge/Twig/TemplateGuesser.php new file mode 100644 index 0000000000000..f776ad19ee924 --- /dev/null +++ b/src/Symfony/Bridge/Twig/TemplateGuesser.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig; + +use Doctrine\Persistence\Proxy; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\KernelInterface; + +/** + * The TemplateGuesser class handles the guessing of template name based on controller. + * + * @author Fabien Potencier + */ +class TemplateGuesser +{ + /** + * @var KernelInterface + */ + private $kernel; + + /** + * @var string[] + */ + private $controllerPatterns; + + /** + * @param string[] $controllerPatterns Regexps extracting the controller name from its FQN + */ + public function __construct(KernelInterface $kernel, array $controllerPatterns = []) + { + $controllerPatterns[] = '/Controller\\\(.+)Controller$/'; + + $this->kernel = $kernel; + $this->controllerPatterns = $controllerPatterns; + } + + /** + * Guesses and returns the template name to render based on the controller + * and action names. + * + * @param callable $controller An array storing the controller object and action method + * + * @return string The template name + * + * @throws \InvalidArgumentException + */ + public function guessTemplateName($controller, Request $request) + { + if (\is_object($controller) && method_exists($controller, '__invoke')) { + $controller = [$controller, '__invoke']; + } elseif (!\is_array($controller)) { + throw new \InvalidArgumentException(sprintf('First argument of "%s" must be an array callable or an object defining the magic method __invoke. "%s" given.', __METHOD__, \gettype($controller))); + } + + $className = $this->getRealClass(\get_class($controller[0])); + + $matchController = null; + foreach ($this->controllerPatterns as $pattern) { + if (preg_match($pattern, $className, $tempMatch)) { + $matchController = str_replace('\\', '/', strtolower(preg_replace('/([a-z\d])([A-Z])/', '\\1_\\2', $tempMatch[1]))); + break; + } + } + if (null === $matchController) { + throw new \InvalidArgumentException(sprintf('The "%s" class does not look like a controller class (its FQN must match one of the following regexps: "%s").', \get_class($controller[0]), implode('", "', $this->controllerPatterns))); + } + + if ('__invoke' === $controller[1]) { + $matchAction = $matchController; + $matchController = null; + } else { + $matchAction = preg_replace('/Action$/', '', $controller[1]); + } + + $matchAction = strtolower(preg_replace('/([a-z\d])([A-Z])/', '\\1_\\2', $matchAction)); + $bundleName = $this->getBundleForClass($className); + + return ($bundleName ? '@'.$bundleName.'/' : '').$matchController.($matchController ? '/' : '').$matchAction.'.'.$request->getRequestFormat().'.twig'; + } + + /** + * Returns the bundle name in which the given class name is located. + * + * @param string $class A fully qualified controller class name + * + * @return string|null $bundle A bundle name + */ + private function getBundleForClass($class) + { + $reflectionClass = new \ReflectionClass($class); + $bundles = $this->kernel->getBundles(); + + do { + $namespace = $reflectionClass->getNamespaceName(); + foreach ($bundles as $bundle) { + if ('Symfony\Bundle\FrameworkBundle' === $bundle->getNamespace()) { + continue; + } + if (0 === strpos($namespace, $bundle->getNamespace())) { + return preg_replace('/Bundle$/', '', $bundle->getName()); + } + } + $reflectionClass = $reflectionClass->getParentClass(); + } while ($reflectionClass); + } + + private static function getRealClass(string $class): string + { + if (!class_exists(Proxy::class)) { + return $class; + } + if (false === $pos = strrpos($class, '\\'.Proxy::MARKER.'\\')) { + return $class; + } + + return substr($class, $pos + Proxy::MARKER_LENGTH + 2); + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/EventListener/TemplateAttributeListenerTest.php b/src/Symfony/Bridge/Twig/Tests/EventListener/TemplateAttributeListenerTest.php new file mode 100644 index 0000000000000..a037b7ca6b404 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/EventListener/TemplateAttributeListenerTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\Attribute\Template; +use Symfony\Bridge\Twig\EventListener\TemplateAttributeListener; +use Symfony\Bridge\Twig\TemplateGuesser; +use Symfony\Bridge\Twig\Tests\Fixtures\Controller\TemplateAttributeController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Twig\Environment; + +class TemplateAttributeListenerTest extends TestCase +{ + public function testAttribute() + { + $templateGuesser = $this->getMockBuilder(TemplateGuesser::class) + ->disableOriginalConstructor() + ->getMock(); + $twig = $this->getMockBuilder(Environment::class) + ->disableOriginalConstructor() + ->getMock(); + + $request = new Request(); + $event = new ControllerEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new TemplateAttributeController(), 'foo'], + $request, + null + ); + + $listener = new TemplateAttributeListener($templateGuesser, $twig); + $listener->onKernelController($event); + + $configuration = $request->attributes->get('_template'); + + $this->assertNotNull($configuration); + $this->assertEquals('templates/foo.html.twig', $configuration->getTemplate()); + $this->assertEquals(['bar'], $configuration->getVars()); + } + + public function testParameters() + { + $templateGuesser = $this->getMockBuilder(TemplateGuesser::class) + ->disableOriginalConstructor() + ->getMock(); + $twig = $this->getMockBuilder(Environment::class) + ->disableOriginalConstructor() + ->getMock(); + $twig->expects($this->once()) + ->method('render') + ->with('template.html.twig', ['foo' => 'bar']); + + $request = new Request([], [], [ + '_template' => new Template(template: 'template.html.twig', owner: ['FooController', 'barAction']), + ]); + $event = new ViewEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + $request, + HttpKernelInterface::MAIN_REQUEST, + ['foo' => 'bar'] + ); + + $listener = new TemplateAttributeListener($templateGuesser, $twig); + $listener->onKernelView($event); + + $this->assertEquals([], $request->attributes->get('_template')->getOwner()); + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/BarBundle/Controller/BarController.php b/src/Symfony/Bridge/Twig/Tests/Fixtures/BarBundle/Controller/BarController.php new file mode 100644 index 0000000000000..82064be873df8 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Fixtures/BarBundle/Controller/BarController.php @@ -0,0 +1,7 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Fixtures\Controller; + +use Symfony\Bridge\Twig\Attribute\Template; + +class TemplateAttributeController +{ + #[Template('templates/foo.html.twig', vars: ['bar'])] + public function foo($bar) + { + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/FooBarBundle/Controller/FooBarController.php b/src/Symfony/Bridge/Twig/Tests/Fixtures/FooBarBundle/Controller/FooBarController.php new file mode 100644 index 0000000000000..8bf976552c753 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Fixtures/FooBarBundle/Controller/FooBarController.php @@ -0,0 +1,7 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\TemplateGuesser; +use Symfony\Bridge\Twig\Tests\Fixtures\FooBundle\Controller\FooController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\KernelInterface; + +class TemplateGuesserTest extends TestCase +{ + /** + * @var KernelInterface + */ + private $kernel; + + private $bundles = []; + + protected function setUp(): void + { + $this->bundles['FooBundle'] = $this->getBundle('FooBundle', 'Symfony\Bridge\Twig\Tests\Fixtures\FooBundle'); + + $this->kernel = $this->getMockBuilder('Symfony\Component\HttpKernel\KernelInterface')->getMock(); + $this->kernel + ->expects($this->once()) + ->method('getBundles') + ->willReturn(array_values($this->bundles)); + } + + public function testGuessTemplateName() + { + $this->kernel + ->expects($this->never()) + ->method('getBundle'); + + $templateGuesser = new TemplateGuesser($this->kernel); + $templateReference = $templateGuesser->guessTemplateName([ + new FooController(), + 'indexAction', + ], new Request()); + + $this->assertEquals('@Foo/foo/index.html.twig', (string) $templateReference); + } + + public function testGuessTemplateWithoutBundle() + { + $templateGuesser = new TemplateGuesser($this->kernel); + $templateReference = $templateGuesser->guessTemplateName([ + new Fixtures\Controller\MyAdmin\OutOfBundleController(), + 'indexAction', + ], new Request()); + + $this->assertEquals('my_admin/out_of_bundle/index.html.twig', (string) $templateReference); + } + + public function testGuessTemplateWithSubNamespace() + { + $templateGuesser = new TemplateGuesser($this->kernel); + $templateReference = $templateGuesser->guessTemplateName([ + new Fixtures\FooBundle\Controller\SubController\FooBarController(), + 'fooBaz', + ], new Request()); + + $this->assertEquals('@Foo/sub_controller/foo_bar/foo_baz.html.twig', (string) $templateReference); + } + + /** + * @dataProvider controllerProvider + */ + public function testGuessTemplateWithInvokeMagicMethod($controller, $patterns) + { + $templateGuesser = new TemplateGuesser($this->kernel, $patterns); + + $templateReference = $templateGuesser->guessTemplateName([ + $controller, + '__invoke', + ], new Request()); + + $this->assertEquals('@Foo/foo.html.twig', (string) $templateReference); + } + + /** + * @dataProvider controllerProvider + */ + public function testGuessTemplateWithACustomPattern($controller, $patterns) + { + $templateGuesser = new TemplateGuesser($this->kernel, $patterns); + + $templateReference = $templateGuesser->guessTemplateName([ + $controller, + 'indexAction', + ], new Request()); + + $this->assertEquals('@Foo/foo/index.html.twig', (string) $templateReference); + } + + /** + * @dataProvider controllerProvider + */ + public function testGuessTemplateWithNotStandardMethodName($controller, $patterns) + { + $templateGuesser = new TemplateGuesser($this->kernel, $patterns); + + $templateReference = $templateGuesser->guessTemplateName([ + $controller, + 'fooBar', + ], new Request()); + + $this->assertEquals('@Foo/foo/foo_bar.html.twig', (string) $templateReference); + } + + public function controllerProvider() + { + return [ + [new FooController(), []], + [new Fixtures\FooBundle\Action\FooAction(), ['/foobar/', '/FooBundle\\\Action\\\(.+)Action/']], + ]; + } + + public function testGuessTemplateWhenControllerFQNDoesNotMatchAPattern() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "stdClass" class does not look like a controller class (its FQN must match one of the following regexps: "/foo/", "/bar/"'); + + $this->kernel->getBundles(); + $templateGuesser = new TemplateGuesser($this->kernel, ['/foo/', '/bar/']); + $templateReference = $templateGuesser->guessTemplateName([ + new \stdClass(), + 'indexAction', + ], new Request()); + } + + public function testInvalidController() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be an array callable or an object defining the magic method __invoke. "object" given.'); + + $this->kernel->getBundles(); + $templateGuesser = new TemplateGuesser($this->kernel); + $templateReference = $templateGuesser->guessTemplateName( + new FooController(), + new Request() + ); + } + + private function getBundle($name, $namespace) + { + $bundle = $this->getMockBuilder('Symfony\Component\HttpKernel\Bundle\BundleInterface')->getMock(); + $bundle + ->expects($this->any()) + ->method('getName') + ->willReturn($name); + + $bundle + ->expects($this->any()) + ->method('getNamespace') + ->willReturn($namespace); + + return $bundle; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 67bbc740f816b..790e6b79daa0c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -71,6 +71,7 @@ class UnusedTagsPass implements CompilerPassInterface 'property_info.list_extractor', 'property_info.type_extractor', 'proxy', + 'request.param_converter', 'routing.expression_language_function', 'routing.expression_language_provider', 'routing.loader', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 5f2eb5daa173e..d466195db31ae 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -140,6 +140,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addRouterSection($rootNode); $this->addSessionSection($rootNode); $this->addRequestSection($rootNode); + $this->addParamConverterSection($rootNode); $this->addAssetsSection($rootNode, $enableIfStandalone); $this->addTranslatorSection($rootNode, $enableIfStandalone); $this->addValidationSection($rootNode, $enableIfStandalone, $willBeAvailable); @@ -681,6 +682,22 @@ private function addRequestSection(ArrayNodeDefinition $rootNode) ; } + private function addParamConverterSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('param_converter') + ->info('param converter configuration') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('auto_convert')->defaultTrue()->end() + ->arrayNode('disable')->prototype('scalar')->end()->end() + ->end() + ->end() + ->end() + ; + } + private function addAssetsSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index b81e8d147f921..4be003a19e4ae 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -81,6 +81,7 @@ use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\Controller\ParamConverter\ParamConverterInterface; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\Lock\LockFactory; @@ -371,6 +372,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerAnnotationsConfiguration($config['annotations'], $container, $loader); $this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader); $this->registerSecretsConfiguration($config['secrets'], $container, $loader); + $this->registerParamConverterConfiguration($config['param_converter'], $container, $loader); $container->getDefinition('exception_listener')->replaceArgument(3, $config['exceptions']); @@ -573,6 +575,8 @@ public function load(array $configs, ContainerBuilder $container) ->addTag('property_info.access_extractor'); $container->registerForAutoconfiguration(PropertyInitializableExtractorInterface::class) ->addTag('property_info.initializable_extractor'); + $container->registerForAutoconfiguration(ParamConverterInterface::class) + ->addTag('request.param_converter'); $container->registerForAutoconfiguration(EncoderInterface::class) ->addTag('serializer.encoder'); $container->registerForAutoconfiguration(DecoderInterface::class) @@ -1166,6 +1170,15 @@ private function registerRequestConfiguration(array $config, ContainerBuilder $c } } + private function registerParamConverterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) + { + $loader->load('param_converter.php'); + + $container->setParameter('param_converter.disabled_converters', \is_string($config['disable']) ? implode(',', $config['disable']) : $config['disable']); + + $container->getDefinition('param_converter.listener')->replaceArgument(1, $config['auto_convert']); + } + private function registerAssetsConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) { $loader->load('assets.php'); diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index 794686e3cdf5f..aa56735bba2a4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -46,6 +46,7 @@ use Symfony\Component\HttpClient\DependencyInjection\HttpClientPass; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\Component\HttpKernel\DependencyInjection\AddParamConverterPass; use Symfony\Component\HttpKernel\DependencyInjection\ControllerArgumentValueResolverPass; use Symfony\Component\HttpKernel\DependencyInjection\FragmentRendererPass; use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass; @@ -158,6 +159,7 @@ public function build(ContainerBuilder $container) $container->addCompilerPass(new RegisterReverseContainerPass(true)); $container->addCompilerPass(new RegisterReverseContainerPass(false), PassConfig::TYPE_AFTER_REMOVING); $container->addCompilerPass(new RemoveUnusedSessionMarshallingHandlerPass()); + $container->addCompilerPass(new AddParamConverterPass()); if ($container->getParameter('kernel.debug')) { $container->addCompilerPass(new AddDebugLogProcessorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 2); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/param_converter.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/param_converter.php new file mode 100644 index 0000000000000..54cda47ec0665 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/param_converter.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\HttpKernel\Controller\ParamConverter\DateTimeParamConverter; +use Symfony\Component\HttpKernel\Controller\ParamConverter\ParamConverterManager; +use Symfony\Component\HttpKernel\EventListener\ParamConverterListener; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('param_converter.listener', ParamConverterListener::class) + ->args([ + service('param_converter.manager'), + true, + ]) + ->tag('kernel.event_subscriber') + + ->set('param_converter.manager', ParamConverterManager::class) + + ->set('date_time_param_converter', DateTimeParamConverter::class) + ->tag('request.param_converter', [ + 'converter' => 'datetime', + ]) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index a7d91bfd4a69d..0a85ab702d92f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -22,9 +22,12 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver\VariadicValueResolver; use Symfony\Component\HttpKernel\Controller\ErrorController; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentNameConverter; +use Symfony\Component\HttpKernel\EventListener\CacheAttributeListener; use Symfony\Component\HttpKernel\EventListener\DisallowRobotsIndexingListener; use Symfony\Component\HttpKernel\EventListener\ErrorListener; use Symfony\Component\HttpKernel\EventListener\LocaleListener; +use Symfony\Component\HttpKernel\EventListener\ParamConverterListener; use Symfony\Component\HttpKernel\EventListener\ResponseListener; use Symfony\Component\HttpKernel\EventListener\StreamedResponseListener; use Symfony\Component\HttpKernel\EventListener\ValidateRequestListener; @@ -115,5 +118,13 @@ ]) ->tag('kernel.event_subscriber') ->tag('monolog.logger', ['channel' => 'request']) + + ->set('argument_name_convertor', ArgumentNameConverter::class) + ->args([ + service('argument_metadata_factory'), + ]) + + ->set('cache_attribute_listener', CacheAttributeListener::class) + ->tag('kernel.event_subscriber') ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 5a769c85f5e5f..b70a5446e8bd1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -582,6 +582,10 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'time_based_uuid_version' => 6, ], 'exceptions' => [], + 'param_converter' => [ + 'auto_convert' => true, + 'disable' => [], + ], ]; } } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/controller.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/controller.php new file mode 100644 index 0000000000000..55af55ed1510b --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/controller.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener; +use Symfony\Component\Security\Http\EventListener\SecurityAttributeListener; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('security.is_granted_attribute_listener', IsGrantedAttributeListener::class) + ->args([ + service('argument_name_convertor'), + service('security.authorization_checker')->ignoreOnInvalid(), + ]) + ->tag('kernel.event_subscriber') + + ->set('security.security_attribute_listener', SecurityAttributeListener::class) + ->args([ + service('argument_name_convertor'), + service('security.expression_language')->ignoreOnInvalid(), + service('security.authentication.trust_resolver')->ignoreOnInvalid(), + service('security.role_hierarchy')->ignoreOnInvalid(), + service('security.token_storage')->ignoreOnInvalid(), + service('security.authorization_checker')->ignoreOnInvalid(), + service('logger')->ignoreOnInvalid(), + ]) + ->tag('kernel.event_subscriber') + ; +}; diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Configuration.php index df688c5dd43ee..6fd18cde54265 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Configuration.php @@ -48,6 +48,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addGlobalsSection($rootNode); $this->addTwigOptions($rootNode); $this->addTwigFormatOptions($rootNode); + $this->addControllerPatternsSection($rootNode); return $treeBuilder; } @@ -201,4 +202,16 @@ private function addTwigFormatOptions(ArrayNodeDefinition $rootNode) ->end() ; } + + private function addControllerPatternsSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->fixXmlConfig('controller_pattern') + ->children() + ->arrayNode('controller_patterns') + ->prototype('scalar') + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index 2fb47d3746aef..fe2ab8d15f967 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -37,6 +37,7 @@ public function load(array $configs, ContainerBuilder $container) { $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('twig.php'); + $loader->load('web.php'); if ($container::willBeAvailable('symfony/form', Form::class, ['symfony/twig-bundle'])) { $loader->load('form.php'); @@ -140,6 +141,10 @@ public function load(array $configs, ContainerBuilder $container) 'optimizations' => true, ])); + if ($container->hasDefinition('twig.template_guesser')) { + $container->getDefinition('twig.template_guesser')->addArgument($config['controller_patterns']); + } + $container->registerForAutoconfiguration(\Twig_ExtensionInterface::class)->addTag('twig.extension'); $container->registerForAutoconfiguration(\Twig_LoaderInterface::class)->addTag('twig.loader'); $container->registerForAutoconfiguration(ExtensionInterface::class)->addTag('twig.extension'); diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/web.php b/src/Symfony/Bundle/TwigBundle/Resources/config/web.php new file mode 100644 index 0000000000000..9e02a31571943 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/web.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Bridge\Twig\EventListener\TemplateAttributeListener; +use Symfony\Bridge\Twig\TemplateGuesser; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('twig.template_guesser', TemplateGuesser::class) + ->args([ + service('kernel'), + ]) + + ->set('twig.template_attribute_listener', TemplateAttributeListener::class) + ->args([ + service('twig.template_guesser'), + service('twig'), + ]) + ; +}; diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/TwigExtensionTest.php b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/TwigExtensionTest.php index 1cc9c0ebf3e5c..7190c3ceff461 100644 --- a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/TwigExtensionTest.php +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/TwigExtensionTest.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\TwigBundle\Tests\DependencyInjection; +use Sensio\Bundle\FrameworkExtraBundle\DependencyInjection\SensioFrameworkExtraExtension; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\RuntimeLoaderPass; use Symfony\Bundle\TwigBundle\DependencyInjection\TwigExtension; use Symfony\Bundle\TwigBundle\Tests\DependencyInjection\AcmeBundle\AcmeBundle; @@ -18,12 +19,14 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; +use Symfony\Component\HttpKernel\Attribute\ParamConverter; class TwigExtensionTest extends TestCase { @@ -264,6 +267,20 @@ public function testRuntimeLoader() $this->assertEquals('foo', $args['FooClass']->getValues()[0]); } + public function testControllerPatterns() + { + $patterns = ['/foo/', '/bar/', '/foobar/']; + + $container = $this->createContainer(); + $container->registerExtension(new TwigExtension()); + $container->loadFromExtension('twig', [ + 'controller_patterns' => $patterns, + ]); + $this->compileContainer($container); + + $this->assertEquals($patterns, $container->getDefinition('twig.template_guesser')->getArgument(1)); + } + private function createContainer() { $container = new ContainerBuilder(new ParameterBag([ @@ -282,6 +299,8 @@ private function createContainer() ], ])); + $container->setDefinition('param_converter.manager', new Definition(ParamConverter::class)); + return $container; } diff --git a/src/Symfony/Component/HttpKernel/Attribute/Cache.php b/src/Symfony/Component/HttpKernel/Attribute/Cache.php new file mode 100644 index 0000000000000..6fb64d58731de --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/Cache.php @@ -0,0 +1,364 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * The Cache class handles the Cache attribute parts. + * + * @author Fabien Potencier + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +class Cache +{ + /** + * The expiration date as a valid date for the strtotime() function. + * + * @var string + */ + private $expires; + + /** + * The number of seconds that the response is considered fresh by a private + * cache like a web browser. + * + * @var int|string|null + */ + private $maxage; + + /** + * The number of seconds that the response is considered fresh by a public + * cache like a reverse proxy cache. + * + * @var int|string|null + */ + private $smaxage; + + /** + * Whether the response is public or not. + * + * @var bool + */ + private $public; + + /** + * Whether or not the response must be revalidated. + * + * @var bool + */ + private $mustRevalidate; + + /** + * Additional "Vary:"-headers. + * + * @var array + */ + private $vary; + + /** + * An expression to compute the Last-Modified HTTP header. + * + * @var string + */ + private $lastModified; + + /** + * An expression to compute the ETag HTTP header. + * + * @var string + */ + private $etag; + + /** + * max-stale Cache-Control header + * It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...). + * + * @var int|string + */ + private $maxStale; + + /** + * stale-while-revalidate Cache-Control header + * It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...). + * + * @var int|string + */ + private $staleWhileRevalidate; + + /** + * stale-if-error Cache-Control header + * It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...). + * + * @var int|string + */ + private $staleIfError; + + /** + * @param int|string|null $maxage + * @param int|string|null $smaxage + * @param int|string|null $maxstale + * @param int|string|null $staleWhileRevalidate + * @param int|string|null $staleIfError + */ + public function __construct( + string $expires = null, + $maxage = null, + $smaxage = null, + bool $public = null, + bool $mustRevalidate = null, + array $vary = null, + string $lastModified = null, + string $Etag = null, + $maxstale = null, + $staleWhileRevalidate = null, + $staleIfError = null + ) { + $this->expires = $expires; + $this->maxage = $maxage; + $this->smaxage = $smaxage; + $this->public = $public; + $this->mustRevalidate = $mustRevalidate; + $this->vary = $vary; + $this->lastModified = $lastModified; + $this->etag = $Etag; + $this->maxStale = $maxstale; + $this->staleWhileRevalidate = $staleWhileRevalidate; + $this->staleIfError = $staleIfError; + } + + /** + * Returns the expiration date for the Expires header field. + * + * @return string + */ + public function getExpires() + { + return $this->expires; + } + + /** + * Sets the expiration date for the Expires header field. + * + * @param string $expires A valid php date + */ + public function setExpires($expires) + { + $this->expires = $expires; + } + + /** + * Sets the number of seconds for the max-age cache-control header field. + * + * @param int $maxage A number of seconds + */ + public function setMaxAge($maxage) + { + $this->maxage = $maxage; + } + + /** + * Returns the number of seconds the response is considered fresh by a + * private cache. + * + * @return int + */ + public function getMaxAge() + { + return $this->maxage; + } + + /** + * Sets the number of seconds for the s-maxage cache-control header field. + * + * @param int $smaxage A number of seconds + */ + public function setSMaxAge($smaxage) + { + $this->smaxage = $smaxage; + } + + /** + * Returns the number of seconds the response is considered fresh by a + * public cache. + * + * @return int + */ + public function getSMaxAge() + { + return $this->smaxage; + } + + /** + * Returns whether or not a response is public. + * + * @return bool + */ + public function isPublic() + { + return true === $this->public; + } + + /** + * @return bool + */ + public function mustRevalidate() + { + return true === $this->mustRevalidate; + } + + /** + * Forces a response to be revalidated. + * + * @param bool $mustRevalidate + */ + public function setMustRevalidate($mustRevalidate) + { + $this->mustRevalidate = (bool) $mustRevalidate; + } + + /** + * Returns whether or not a response is private. + * + * @return bool + */ + public function isPrivate() + { + return false === $this->public; + } + + /** + * Sets a response public. + * + * @param bool $public A boolean value + */ + public function setPublic($public) + { + $this->public = (bool) $public; + } + + /** + * Returns the custom "Vary"-headers. + * + * @return array + */ + public function getVary() + { + return $this->vary; + } + + /** + * Add additional "Vary:"-headers. + * + * @param array $vary + */ + public function setVary($vary) + { + $this->vary = $vary; + } + + /** + * Sets the "Last-Modified"-header expression. + * + * @param string $expression + */ + public function setLastModified($expression) + { + $this->lastModified = $expression; + } + + /** + * Returns the "Last-Modified"-header expression. + * + * @return string + */ + public function getLastModified() + { + return $this->lastModified; + } + + /** + * Sets the "ETag"-header expression. + * + * @param string $expression + */ + public function setEtag($expression) + { + $this->etag = $expression; + } + + /** + * Returns the "ETag"-header expression. + * + * @return string + */ + public function getEtag() + { + return $this->etag; + } + + /** + * @return int|string + */ + public function getMaxStale() + { + return $this->maxStale; + } + + /** + * Sets the number of seconds for the max-stale cache-control header field. + * + * @param int|string $maxStale A number of seconds + */ + public function setMaxStale($maxStale) + { + $this->maxStale = $maxStale; + } + + /** + * @return int|string + */ + public function getStaleWhileRevalidate() + { + return $this->staleWhileRevalidate; + } + + /** + * @param int|string $staleWhileRevalidate + * + * @return self + */ + public function setStaleWhileRevalidate($staleWhileRevalidate) + { + $this->staleWhileRevalidate = $staleWhileRevalidate; + + return $this; + } + + /** + * @return int|string + */ + public function getStaleIfError() + { + return $this->staleIfError; + } + + /** + * @param int|string $staleIfError + * + * @return self + */ + public function setStaleIfError($staleIfError) + { + $this->staleIfError = $staleIfError; + + return $this; + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/ParamConverter.php b/src/Symfony/Component/HttpKernel/Attribute/ParamConverter.php new file mode 100644 index 0000000000000..baa019b30290a --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/ParamConverter.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * The ParamConverter class handles the ParamConverter attribute parts. + * + * @author Fabien Potencier + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +class ParamConverter +{ + /** + * The parameter name. + * + * @var string + */ + private $name; + + /** + * The parameter class. + * + * @var string + */ + private $class; + + /** + * An array of options. + * + * @var array + */ + private $options = []; + + /** + * Whether or not the parameter is optional. + * + * @var bool + */ + private $isOptional = false; + + /** + * Use explicitly named converter instead of iterating by priorities. + * + * @var string + */ + private $converter; + + public function __construct( + string $name, + string $class = null, + array $options = [], + bool $isOptional = false, + string $converter = null + ) { + $this->name = $name; + $this->class = $class; + $this->options = $options; + $this->isOptional = $isOptional; + $this->converter = $converter; + } + + /** + * Returns the parameter name. + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Sets the parameter name. + * + * @param string $name The parameter name + */ + public function setValue($name) + { + $this->setName($name); + } + + /** + * Sets the parameter name. + * + * @param string $name The parameter name + */ + public function setName($name) + { + $this->name = $name; + } + + /** + * Returns the parameter class name. + * + * @return string $name + */ + public function getClass() + { + return $this->class; + } + + /** + * Sets the parameter class name. + * + * @param string $class The parameter class name + */ + public function setClass($class) + { + $this->class = $class; + } + + /** + * Returns an array of options. + * + * @return array + */ + public function getOptions() + { + return $this->options; + } + + /** + * Sets an array of options. + * + * @param array $options An array of options + */ + public function setOptions($options) + { + $this->options = $options; + } + + /** + * Sets whether or not the parameter is optional. + * + * @param bool $optional Whether the parameter is optional + */ + public function setIsOptional($optional) + { + $this->isOptional = (bool) $optional; + } + + /** + * Returns whether or not the parameter is optional. + * + * @return bool + */ + public function isOptional() + { + return $this->isOptional; + } + + /** + * Get explicit converter name. + * + * @return string + */ + public function getConverter() + { + return $this->converter; + } + + /** + * Set explicit converter name. + * + * @param string $converter + */ + public function setConverter($converter) + { + $this->converter = $converter; + } +} diff --git a/src/Symfony/Component/HttpKernel/Controller/ParamConverter/DateTimeParamConverter.php b/src/Symfony/Component/HttpKernel/Controller/ParamConverter/DateTimeParamConverter.php new file mode 100644 index 0000000000000..c12e6cc3a2a11 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ParamConverter/DateTimeParamConverter.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Controller\ParamConverter; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\ParamConverter; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Convert DateTime instances from request attribute variable. + * + * @author Benjamin Eberlei + */ +class DateTimeParamConverter implements ParamConverterInterface +{ + /** + * {@inheritdoc} + * + * @throws NotFoundHttpException When invalid date given + */ + public function apply(Request $request, ParamConverter $configuration) + { + $param = $configuration->getName(); + + if (!$request->attributes->has($param)) { + return false; + } + + $options = $configuration->getOptions(); + $value = $request->attributes->get($param); + + if (!$value && $configuration->isOptional()) { + $request->attributes->set($param, null); + + return true; + } + + $class = $configuration->getClass(); + + if (isset($options['format'])) { + $date = $class::createFromFormat($options['format'], $value); + + if (0 < \DateTime::getLastErrors()['warning_count']) { + $date = false; + } + + if (!$date) { + throw new NotFoundHttpException(sprintf('Invalid date given for parameter "%s".', $param)); + } + } else { + $valueIsInt = filter_var($value, \FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + if (false !== $valueIsInt) { + $date = (new $class())->setTimestamp($value); + } else { + if (false === strtotime($value)) { + throw new NotFoundHttpException(sprintf('Invalid date given for parameter "%s".', $param)); + } + + $date = new $class($value); + } + } + + $request->attributes->set($param, $date); + + return true; + } + + /** + * {@inheritdoc} + */ + public function supports(ParamConverter $configuration) + { + if (null === $configuration->getClass()) { + return false; + } + + return is_subclass_of($configuration->getClass(), \DateTimeInterface::class); + } +} diff --git a/src/Symfony/Component/HttpKernel/Controller/ParamConverter/ParamConverterInterface.php b/src/Symfony/Component/HttpKernel/Controller/ParamConverter/ParamConverterInterface.php new file mode 100644 index 0000000000000..dc5c50b107fbb --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ParamConverter/ParamConverterInterface.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Controller\ParamConverter; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\ParamConverter; + +/** + * Converts request parameters to objects and stores them as request + * attributes, so they can be injected as controller method arguments. + * + * @author Fabien Potencier + */ +interface ParamConverterInterface +{ + /** + * Stores the object in the request. + * + * @param ParamConverter $configuration Contains the name, class and options of the object + * + * @return bool True if the object has been successfully set, else false + */ + public function apply(Request $request, ParamConverter $configuration); + + /** + * Checks if the object is supported. + * + * @return bool True if the object is supported, else false + */ + public function supports(ParamConverter $configuration); +} diff --git a/src/Symfony/Component/HttpKernel/Controller/ParamConverter/ParamConverterManager.php b/src/Symfony/Component/HttpKernel/Controller/ParamConverter/ParamConverterManager.php new file mode 100644 index 0000000000000..3a114637df7e0 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ParamConverter/ParamConverterManager.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Controller\ParamConverter; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\ParamConverter; + +/** + * Managers converters. + * + * @author Fabien Potencier + * @author Henrik Bjornskov + */ +class ParamConverterManager +{ + /** + * @var array + */ + private $converters = []; + + /** + * @var array + */ + private $namedConverters = []; + + /** + * Applies all converters to the passed configurations and stops when a + * converter is applied it will move on to the next configuration and so on. + * + * @param array|object $configurations + */ + public function apply(Request $request, $configurations) + { + if (\is_object($configurations)) { + $configurations = [$configurations]; + } + + foreach ($configurations as $configuration) { + $this->applyConverter($request, $configuration); + } + } + + /** + * Applies converter on request based on the given configuration. + */ + private function applyConverter(Request $request, ParamConverter $configuration) + { + $value = $request->attributes->get($configuration->getName()); + $className = $configuration->getClass(); + + // If the value is already an instance of the class we are trying to convert it into + // we should continue as no conversion is required + if (\is_object($value) && $value instanceof $className) { + return; + } + + if ($converterName = $configuration->getConverter()) { + if (!isset($this->namedConverters[$converterName])) { + throw new \RuntimeException(sprintf("No converter named '%s' found for conversion of parameter '%s'.", $converterName, $configuration->getName())); + } + + $converter = $this->namedConverters[$converterName]; + + if (!$converter->supports($configuration)) { + throw new \RuntimeException(sprintf("Converter '%s' does not support conversion of parameter '%s'.", $converterName, $configuration->getName())); + } + + $converter->apply($request, $configuration); + + return; + } + + foreach ($this->all() as $converter) { + if ($converter->supports($configuration)) { + if ($converter->apply($request, $configuration)) { + return; + } + } + } + } + + /** + * Adds a parameter converter. + * + * Converters match either explicitly via $name or by iteration over all + * converters with a $priority. If you pass a $priority = null then the + * added converter will not be part of the iteration chain and can only + * be invoked explicitly. + * + * @param int $priority the priority (between -10 and 10) + * @param string $name name of the converter + */ + public function add(ParamConverterInterface $converter, $priority = 0, $name = null) + { + if (null !== $priority) { + if (!isset($this->converters[$priority])) { + $this->converters[$priority] = []; + } + + $this->converters[$priority][] = $converter; + } + + if (null !== $name) { + $this->namedConverters[$name] = $converter; + } + } + + /** + * Returns all registered param converters. + * + * @return array An array of param converters + */ + public function all() + { + krsort($this->converters); + + $converters = []; + foreach ($this->converters as $all) { + $converters = array_merge($converters, $all); + } + + return $converters; + } +} diff --git a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentNameConverter.php b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentNameConverter.php new file mode 100644 index 0000000000000..76108f42e83c4 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentNameConverter.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\ControllerMetadata; + +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; + +/** + * @author Ryan Weaver + */ +class ArgumentNameConverter +{ + private $argumentMetadataFactory; + + public function __construct(ArgumentMetadataFactoryInterface $argumentMetadataFactory) + { + $this->argumentMetadataFactory = $argumentMetadataFactory; + } + + /** + * Returns an associative array of the controller arguments for the event. + * + * @return array + */ + public function getControllerArguments(ControllerArgumentsEvent $event) + { + $namedArguments = $event->getRequest()->attributes->all(); + $argumentMetadatas = $this->argumentMetadataFactory->createArgumentMetadata($event->getController()); + $controllerArguments = $event->getArguments(); + + foreach ($argumentMetadatas as $index => $argumentMetadata) { + if ($argumentMetadata->isVariadic()) { + // set the rest of the arguments as this arg's value + $namedArguments[$argumentMetadata->getName()] = \array_slice($controllerArguments, $index); + + break; + } + + if (!\array_key_exists($index, $controllerArguments)) { + throw new \LogicException(sprintf('Could not find an argument value for argument %d of the controller.', $index)); + } + + $namedArguments[$argumentMetadata->getName()] = $controllerArguments[$index]; + } + + return $namedArguments; + } +} diff --git a/src/Symfony/Component/HttpKernel/DependencyInjection/AddParamConverterPass.php b/src/Symfony/Component/HttpKernel/DependencyInjection/AddParamConverterPass.php new file mode 100644 index 0000000000000..b038cf8b11d38 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/DependencyInjection/AddParamConverterPass.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Adds tagged request.param_converter services to converter.manager service. + * + * @author Fabien Potencier + */ +class AddParamConverterPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + if (false === $container->hasDefinition('param_converter.manager')) { + return; + } + + $definition = $container->getDefinition('param_converter.manager'); + $disabled = $container->getParameter('param_converter.disabled_converters'); + $container->getParameterBag()->remove('param_converter.disabled_converters'); + + foreach ($container->findTaggedServiceIds('request.param_converter') as $id => $converters) { + foreach ($converters as $converter) { + $name = isset($converter['converter']) ? $converter['converter'] : null; + + if (null !== $name && \in_array($name, $disabled)) { + continue; + } + + $priority = isset($converter['priority']) ? $converter['priority'] : 0; + + if ('false' === $priority || false === $priority) { + $priority = null; + } + + $definition->addMethodCall('add', [new Reference($id), $priority, $name]); + } + } + } +} diff --git a/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php b/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php new file mode 100644 index 0000000000000..95c63b61ab7d4 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php @@ -0,0 +1,259 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\EventListener; + +use Sensio\Bundle\FrameworkExtraBundle\Configuration\ConfigurationAnnotation; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\Cache; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\Event\KernelEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * HttpCacheListener handles HTTP cache headers. + * + * It can be configured via the Cache annotation. + * + * @author Fabien Potencier + */ +class CacheAttributeListener implements EventSubscriberInterface +{ + private $lastModifiedDates; + private $etags; + private $expressionLanguage; + + public function __construct() + { + $this->lastModifiedDates = new \SplObjectStorage(); + $this->etags = new \SplObjectStorage(); + } + + /** + * Handles HTTP validation headers. + */ + public function onKernelController(KernelEvent $event) + { + if (!$configuration = $this->getConfiguration($event)) { + return; + } + + if (!$configuration instanceof Cache) { + return; + } + + $request = $event->getRequest(); + $response = new Response(); + + $lastModifiedDate = ''; + if ($configuration->getLastModified()) { + $lastModifiedDate = $this->getExpressionLanguage()->evaluate($configuration->getLastModified(), $request->attributes->all()); + $response->setLastModified($lastModifiedDate); + } + + $etag = ''; + if ($configuration->getEtag()) { + $etag = hash('sha256', $this->getExpressionLanguage()->evaluate($configuration->getEtag(), $request->attributes->all())); + $response->setEtag($etag); + } + + if ($response->isNotModified($request)) { + $event->setController(function () use ($response) { + return $response; + }); + $event->stopPropagation(); + } else { + if ($etag) { + $this->etags[$request] = $etag; + } + if ($lastModifiedDate) { + $this->lastModifiedDates[$request] = $lastModifiedDate; + } + } + } + + /** + * Modifies the response to apply HTTP cache headers when needed. + */ + public function onKernelResponse(KernelEvent $event) + { + if (!$configuration = $this->getConfiguration($event)) { + return; + } + + if (!$configuration instanceof Cache) { + return; + } + + $request = $event->getRequest(); + $response = $event->getResponse(); + + // http://tools.ietf.org/html/draft-ietf-httpbis-p4-conditional-12#section-3.1 + if (!\in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 304, 404, 410])) { + return; + } + + if (!$response->headers->hasCacheControlDirective('s-maxage') && null !== $age = $configuration->getSMaxAge()) { + $age = $this->convertToSecondsIfNeeded($age); + + $response->setSharedMaxAge($age); + } + + if ($configuration->mustRevalidate()) { + $response->headers->addCacheControlDirective('must-revalidate'); + } + + if (!$response->headers->hasCacheControlDirective('max-age') && null !== $age = $configuration->getMaxAge()) { + $age = $this->convertToSecondsIfNeeded($age); + + $response->setMaxAge($age); + } + + if (!$response->headers->hasCacheControlDirective('max-stale') && null !== $stale = $configuration->getMaxStale()) { + $stale = $this->convertToSecondsIfNeeded($stale); + + $response->headers->addCacheControlDirective('max-stale', $stale); + } + + if (!$response->headers->hasCacheControlDirective('stale-while-revalidate') && null !== $staleWhileRevalidate = $configuration->getStaleWhileRevalidate()) { + $staleWhileRevalidate = $this->convertToSecondsIfNeeded($staleWhileRevalidate); + + $response->headers->addCacheControlDirective('stale-while-revalidate', $staleWhileRevalidate); + } + + if (!$response->headers->hasCacheControlDirective('stale-if-error') && null !== $staleIfError = $configuration->getStaleIfError()) { + $staleIfError = $this->convertToSecondsIfNeeded($staleIfError); + + $response->headers->addCacheControlDirective('stale-if-error', $staleIfError); + } + + if (!$response->headers->has('Expires') && null !== $configuration->getExpires()) { + $date = \DateTime::createFromFormat('U', strtotime($configuration->getExpires()), new \DateTimeZone('UTC')); + $response->setExpires($date); + } + + if (!$response->headers->has('Vary') && null !== $configuration->getVary()) { + $response->setVary($configuration->getVary()); + } + + if ($configuration->isPublic()) { + $response->setPublic(); + } + + if ($configuration->isPrivate()) { + $response->setPrivate(); + } + + if (!$response->headers->has('Last-Modified') && isset($this->lastModifiedDates[$request])) { + $response->setLastModified($this->lastModifiedDates[$request]); + + unset($this->lastModifiedDates[$request]); + } + + if (!$response->headers->has('Etag') && isset($this->etags[$request])) { + $response->setEtag($this->etags[$request]); + + unset($this->etags[$request]); + } + } + + /** + * @return array + */ + public static function getSubscribedEvents() + { + return [ + KernelEvents::CONTROLLER => 'onKernelController', + KernelEvents::RESPONSE => 'onKernelResponse', + ]; + } + + private function getConfiguration(KernelEvent $event): ?Cache + { + $request = $event->getRequest(); + + if ($configuration = $request->attributes->get('_cache')) { + return $configuration; + } + + if (!$event instanceof ControllerEvent) { + return null; + } + + $controller = $event->getController(); + + if (!\is_array($controller) && method_exists($controller, '__invoke')) { + $controller = [$controller, '__invoke']; + } + + if (!\is_array($controller)) { + return null; + } + + $className = \get_class($controller[0]); + $object = new \ReflectionClass($className); + $method = $object->getMethod($controller[1]); + + $classConfigurations = array_map( + function (\ReflectionAttribute $attribute) { + return $attribute->newInstance(); + }, + $object->getAttributes(Cache::class) + ); + $methodConfigurations = array_map( + function (\ReflectionAttribute $attribute) { + return $attribute->newInstance(); + }, + $method->getAttributes(Cache::class) + ); + $configurations = array_merge($methodConfigurations, $classConfigurations); + + if (0 === count($configurations)) { + return null; + } + + // Use the first encountered configuration, method attributes take precedence over class attributes + $configuration = $configurations[0]; + $request->attributes->set('_cache', $configuration); + + return $configuration; + } + + private function getExpressionLanguage() + { + if (null === $this->expressionLanguage) { + if (!class_exists(ExpressionLanguage::class)) { + throw new \RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); + } + $this->expressionLanguage = new ExpressionLanguage(); + } + + return $this->expressionLanguage; + } + + /** + * @param int|string $time Time that can be either expressed in seconds or with relative time format (1 day, 2 weeks, ...) + * + * @return int + */ + private function convertToSecondsIfNeeded($time) + { + if (!is_numeric($time)) { + $now = microtime(true); + + $time = ceil(strtotime($time, $now) - $now); + } + + return $time; + } +} diff --git a/src/Symfony/Component/HttpKernel/EventListener/ParamConverterListener.php b/src/Symfony/Component/HttpKernel/EventListener/ParamConverterListener.php new file mode 100644 index 0000000000000..b9b21fa047d30 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/EventListener/ParamConverterListener.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\ParamConverter; +use Symfony\Component\HttpKernel\Controller\ParamConverter\ParamConverterManager; +use Symfony\Component\HttpKernel\Event\KernelEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * The ParamConverterListener handles the ParamConverter annotation. + * + * @author Fabien Potencier + */ +class ParamConverterListener implements EventSubscriberInterface +{ + /** + * @var ParamConverterManager + */ + private $manager; + + private $autoConvert; + + /** + * @param bool $autoConvert Auto convert non-configured objects + */ + public function __construct(ParamConverterManager $manager, $autoConvert = true) + { + $this->manager = $manager; + $this->autoConvert = $autoConvert; + } + + /** + * Modifies the ParamConverterManager instance. + */ + public function onKernelController(KernelEvent $event) + { + $request = $event->getRequest(); + $controller = $event->getController(); + + if (!\is_array($controller) && method_exists($controller, '__invoke')) { + $controller = [$controller, '__invoke']; + } + + if (!\is_array($controller)) { + return; + } + + $className = \get_class($controller[0]); + $object = new \ReflectionClass($className); + $method = $object->getMethod($controller[1]); + + $classConfigurations = array_map( + function (\ReflectionAttribute $attribute) { + return $attribute->newInstance(); + }, + $object->getAttributes(ParamConverter::class) + ); + $methodConfigurations = array_map( + function (\ReflectionAttribute $attribute) { + return $attribute->newInstance(); + }, + $method->getAttributes(ParamConverter::class) + ); + $rawConfigurations = array_merge($classConfigurations, $methodConfigurations); + + $configurations = []; + + foreach ($rawConfigurations as $configuration) { + $configurations[$configuration->getName()] = $configuration; + } + + // automatically apply conversion for non-configured objects + if ($this->autoConvert) { + if (\is_array($controller)) { + $r = new \ReflectionMethod($controller[0], $controller[1]); + } elseif (\is_object($controller) && \is_callable([$controller, '__invoke'])) { + $r = new \ReflectionMethod($controller, '__invoke'); + } else { + $r = new \ReflectionFunction($controller); + } + + $configurations = $this->autoConfigure($r, $request, $configurations); + } + + $this->manager->apply($request, $configurations); + } + + private function autoConfigure(\ReflectionFunctionAbstract $r, Request $request, $configurations) + { + foreach ($r->getParameters() as $param) { + $type = $param->getType(); + $class = $this->getParamClassByType($type); + if (null !== $class && $request instanceof $class) { + continue; + } + + $name = $param->getName(); + + if ($type) { + if (!isset($configurations[$name])) { + $configuration = new ParamConverter($name); + + $configurations[$name] = $configuration; + } + + if (null !== $class && null === $configurations[$name]->getClass()) { + $configurations[$name]->setClass($class); + } + } + + if (isset($configurations[$name])) { + $configurations[$name]->setIsOptional($param->isOptional() || $param->isDefaultValueAvailable() || ($type && $type->allowsNull())); + } + } + + return $configurations; + } + + private function getParamClassByType(?\ReflectionType $type): ?string + { + if (null === $type) { + return null; + } + + foreach ($type instanceof \ReflectionUnionType ? $type->getTypes() : [$type] as $type) { + if (!$type->isBuiltin()) { + return $type->getName(); + } + } + + return null; + } + + /** + * @return array + */ + public static function getSubscribedEvents() + { + return [ + KernelEvents::CONTROLLER => 'onKernelController', + ]; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ParamConverter/DateTimeParamConverterTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ParamConverter/DateTimeParamConverterTest.php new file mode 100644 index 0000000000000..85c0e8c9cf563 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ParamConverter/DateTimeParamConverterTest.php @@ -0,0 +1,160 @@ + + * + * 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\ParamConverter; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\ParamConverter; +use Symfony\Component\HttpKernel\Controller\ParamConverter\DateTimeParamConverter; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Tests\Fixtures\FooDateTime; + +class DateTimeParamConverterTest extends TestCase +{ + private $converter; + + protected function setUp(): void + { + $this->converter = new DateTimeParamConverter(); + } + + public function testSupports() + { + $config = $this->createConfiguration(\DateTime::class); + $this->assertTrue($this->converter->supports($config)); + + $config = $this->createConfiguration(FooDateTime::class); + $this->assertTrue($this->converter->supports($config)); + + $config = $this->createConfiguration(__CLASS__); + $this->assertFalse($this->converter->supports($config)); + + $config = $this->createConfiguration(); + $this->assertFalse($this->converter->supports($config)); + } + + public function testApply() + { + $request = new Request([], [], ['start' => '2012-07-21 00:00:00']); + $config = $this->createConfiguration('DateTime', 'start'); + + $this->converter->apply($request, $config); + + $this->assertInstanceOf('DateTime', $request->attributes->get('start')); + $this->assertEquals('2012-07-21', $request->attributes->get('start')->format('Y-m-d')); + } + + public function testApplyUnixTimestamp() + { + $request = new Request([], [], ['start' => '989541720']); + $config = $this->createConfiguration('DateTime', 'start'); + + $this->converter->apply($request, $config); + + $this->assertInstanceOf('DateTime', $request->attributes->get('start')); + $this->assertEquals('2001-05-11', $request->attributes->get('start')->format('Y-m-d')); + } + + public function testApplyInvalidDate404Exception() + { + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Invalid date given for parameter "start".'); + + $request = new Request([], [], ['start' => 'Invalid DateTime Format']); + $config = $this->createConfiguration('DateTime', 'start'); + + $this->converter->apply($request, $config); + } + + public function testApplyWithFormatInvalidDate404Exception() + { + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Invalid date given for parameter "start".'); + + $request = new Request([], [], ['start' => '2012-07-21']); + $config = $this->createConfiguration('DateTime', 'start'); + $config->expects($this->any())->method('getOptions')->willReturn(['format' => 'd.m.Y']); + + $this->converter->apply($request, $config); + } + + public function testApplyWithYmdFormatInvalidDate404Exception() + { + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Invalid date given for parameter "start".'); + + $request = new Request([], [], ['start' => '2012-21-07']); + $config = $this->createConfiguration('DateTime', 'start'); + $config->expects($this->any())->method('getOptions')->willReturn(['format' => 'Y-m-d']); + + $this->converter->apply($request, $config); + } + + public function testApplyOptionalWithEmptyAttribute() + { + $request = new Request([], [], ['start' => '']); + $config = $this->createConfiguration('DateTime', 'start'); + $config->expects($this->once()) + ->method('isOptional') + ->willReturn(true); + + $this->assertTrue($this->converter->apply($request, $config)); + $this->assertNull($request->attributes->get('start')); + } + + public function testApplyCustomClass() + { + $request = new Request([], [], ['start' => '2016-09-08 00:00:00']); + $config = $this->createConfiguration(FooDateTime::class, 'start'); + + $this->converter->apply($request, $config); + + $this->assertInstanceOf(FooDateTime::class, $request->attributes->get('start')); + $this->assertEquals('2016-09-08', $request->attributes->get('start')->format('Y-m-d')); + } + + /** + * @requires PHP 5.5 + */ + public function testApplyDateTimeImmutable() + { + $request = new Request([], [], ['start' => '2016-09-08 00:00:00']); + $config = $this->createConfiguration(\DateTimeImmutable::class, 'start'); + + $this->converter->apply($request, $config); + + $this->assertInstanceOf(\DateTimeImmutable::class, $request->attributes->get('start')); + $this->assertEquals('2016-09-08', $request->attributes->get('start')->format('Y-m-d')); + } + + public function createConfiguration($class = null, $name = null) + { + $config = $this + ->getMockBuilder(ParamConverter::class) + ->setMethods(['getClass', 'getAliasName', 'getOptions', 'getName', 'allowArray', 'isOptional']) + ->disableOriginalConstructor() + ->getMock(); + + if (null !== $name) { + $config->expects($this->any()) + ->method('getName') + ->willReturn($name); + } + if (null !== $class) { + $config->expects($this->any()) + ->method('getClass') + ->willReturn($class); + } + + return $config; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ParamConverter/ParamConverterManagerTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ParamConverter/ParamConverterManagerTest.php new file mode 100644 index 0000000000000..36a43ff6474a9 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ParamConverter/ParamConverterManagerTest.php @@ -0,0 +1,159 @@ + + * + * 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\ParamConverter; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute; +use Symfony\Component\HttpKernel\Controller\ParamConverter\ParamConverterManager; + +class ParamConverterManagerTest extends \PHPUnit\Framework\TestCase +{ + public function testPriorities() + { + $manager = new ParamConverterManager(); + $this->assertEquals([], $manager->all()); + + $high = $this->createParamConverterMock(); + $low = $this->createParamConverterMock(); + + $manager->add($low); + $manager->add($high, 10); + + $this->assertEquals([$high, $low], $manager->all()); + } + + public function testApply() + { + $supported = $this->createParamConverterMock(); + $supported + ->expects($this->once()) + ->method('supports') + ->willReturn(true) + ; + $supported + ->expects($this->once()) + ->method('apply') + ->willReturn(false) + ; + + $invalid = $this->createParamConverterMock(); + $invalid + ->expects($this->once()) + ->method('supports') + ->willReturn(false) + ; + $invalid + ->expects($this->never()) + ->method('apply') + ; + + $configurations = [ + new Attribute\ParamConverter('var'), + ]; + + $manager = new ParamConverterManager(); + $manager->add($supported); + $manager->add($invalid); + $manager->apply(new Request(), $configurations); + } + + /** + * @doesNotPerformAssertions + */ + public function testApplyNamedConverter() + { + $converter = $this->createParamConverterMock(); + $converter + ->expects($this->any()) + ->method('supports') + ->willReturn(true) + ; + + $converter + ->expects($this->any()) + ->method('apply') + ; + + $request = new Request(); + $request->attributes->set('param', '1234'); + + $configuration = new Attribute\ParamConverter('param', 'stdClass', [], false, 'test'); + + $manager = new ParamConverterManager(); + $manager->add($converter, 0, 'test'); + $manager->apply($request, [$configuration]); + } + + public function testApplyNamedConverterNotSupportsParameter() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Converter \'test\' does not support conversion of parameter \'param\'.'); + + $converter = $this->createParamConverterMock(); + $converter + ->expects($this->any()) + ->method('supports') + ->willReturn(false) + ; + + $request = new Request(); + $request->attributes->set('param', '1234'); + + $configuration = new Attribute\ParamConverter(name: 'param', class: 'stdClass', converter: 'test'); + + $manager = new ParamConverterManager(); + $manager->add($converter, 0, 'test'); + $manager->apply($request, [$configuration]); + } + + public function testApplyNamedConverterNoConverter() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No converter named \'test\' found for conversion of parameter \'param\'.'); + + $request = new Request(); + $request->attributes->set('param', '1234'); + + $configuration = new Attribute\ParamConverter(name: 'param', class: 'stdClass', converter: 'test'); + + $manager = new ParamConverterManager(); + $manager->apply($request, [$configuration]); + } + + public function testApplyNotCalledOnAlreadyConvertedObjects() + { + $converter = $this->createParamConverterMock(); + $converter + ->expects($this->never()) + ->method('supports') + ; + + $converter + ->expects($this->never()) + ->method('apply') + ; + + $request = new Request(); + $request->attributes->set('converted', new \stdClass()); + + $configuration = new Attribute\ParamConverter(name: 'converted', class: 'stdClass'); + + $manager = new ParamConverterManager(); + $manager->add($converter); + $manager->apply($request, [$configuration]); + } + + private function createParamConverterMock() + { + return $this->getMockBuilder('Symfony\Component\HttpKernel\Controller\ParamConverter\ParamConverterInterface')->getMock(); + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentNameConverterTest.php b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentNameConverterTest.php new file mode 100644 index 0000000000000..acc4683b99537 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/ControllerMetadata/ArgumentNameConverterTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\ControllerMetadata; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactoryInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentNameConverter; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class ArgumentNameConverterTest extends \PHPUnit\Framework\TestCase +{ + /** + * @dataProvider getArgumentTests + */ + public function testGetControllerArguments(array $resolvedArguments, array $argumentMetadatas, array $requestAttributes, array $expectedArguments) + { + $metadataFactory = $this->getMockBuilder(ArgumentMetadataFactoryInterface::class)->getMock(); + $metadataFactory->expects($this->any()) + ->method('createArgumentMetadata') + ->willReturn($argumentMetadatas); + + $request = new Request(); + $request->attributes->add($requestAttributes); + + $converter = new ArgumentNameConverter($metadataFactory); + $event = new ControllerArgumentsEvent($this->getMockBuilder(HttpKernelInterface::class)->getMock(), function () { + return new Response(); + }, $resolvedArguments, $request, null); + $actualArguments = $converter->getControllerArguments($event); + $this->assertSame($expectedArguments, $actualArguments); + } + + public function getArgumentTests() + { + // everything empty + yield [[], [], [], []]; + + // uses request attributes + yield [[], [], ['post' => 5], ['post' => 5]]; + + // resolves argument names correctly + $arg1Metadata = new ArgumentMetadata('arg1Name', 'string', false, false, null); + $arg2Metadata = new ArgumentMetadata('arg2Name', 'string', false, false, null); + yield [['arg1Value', 'arg2Value'], [$arg1Metadata, $arg2Metadata], ['post' => 5], ['post' => 5, 'arg1Name' => 'arg1Value', 'arg2Name' => 'arg2Value']]; + + // argument names have priority over request attributes + yield [['arg1Value', 'arg2Value'], [$arg1Metadata, $arg2Metadata], ['arg1Name' => 'differentValue'], ['arg1Name' => 'arg1Value', 'arg2Name' => 'arg2Value']]; + + // variadic arguments are resolved correctly + $arg1Metadata = new ArgumentMetadata('arg1Name', 'string', false, false, null); + $arg2VariadicMetadata = new ArgumentMetadata('arg2Name', 'string', true, false, null); + yield [['arg1Value', 'arg2Value', 'arg3Value'], [$arg1Metadata, $arg2VariadicMetadata], [], ['arg1Name' => 'arg1Value', 'arg2Name' => ['arg2Value', 'arg3Value']]]; + + // variadic argument receives no arguments, so becomes an empty array + yield [['arg1Value'], [$arg1Metadata, $arg2VariadicMetadata], [], ['arg1Name' => 'arg1Value', 'arg2Name' => []]]; + + // resolves nullable argument correctly + $arg1Metadata = new ArgumentMetadata('arg1Name', 'string', false, false, null); + $arg2NullableMetadata = new ArgumentMetadata('arg2Name', 'string', false, false, true); + yield [['arg1Value', null], [$arg1Metadata, $arg2Metadata], ['post' => 5], ['post' => 5, 'arg1Name' => 'arg1Value', 'arg2Name' => null]]; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/AddParamConverterPassTest.php b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/AddParamConverterPassTest.php new file mode 100644 index 0000000000000..b8cf9df34555a --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/AddParamConverterPassTest.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\DependencyInjection; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\DependencyInjection\AddParamConverterPass; + +class AddParamConverterPassTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var AddParamConverterPass + */ + private $pass; + + /** + * @var ContainerBuilder + */ + private $container; + + /** + * @var Definition + */ + private $managerDefinition; + + protected function setUp(): void + { + $this->pass = new AddParamConverterPass(); + $this->container = new ContainerBuilder(); + $this->managerDefinition = new Definition(); + $this->container->setDefinition('param_converter.manager', $this->managerDefinition); + $this->container->setParameter('param_converter.disabled_converters', []); + } + + /** + * @doesNotPerformAssertions + */ + public function testProcessNoOpNoManager() + { + $this->container->removeDefinition('param_converter.manager'); + $this->pass->process($this->container); + } + + public function testProcessNoOpNoTaggedServices() + { + $this->pass->process($this->container); + $this->assertCount(0, $this->managerDefinition->getMethodCalls()); + } + + public function testProcessAddsTaggedServices() + { + $paramConverter1 = new Definition(); + $paramConverter1->setTags([ + 'request.param_converter' => [ + [ + 'priority' => 'false', + ], + ], + ]); + + $paramConverter2 = new Definition(); + $paramConverter2->setTags([ + 'request.param_converter' => [ + [ + 'converter' => 'foo', + ], + ], + ]); + + $paramConverter3 = new Definition(); + $paramConverter3->setTags([ + 'request.param_converter' => [ + [ + 'priority' => 5, + ], + ], + ]); + + $this->container->setDefinition('param_converter_one', $paramConverter1); + $this->container->setDefinition('param_converter_two', $paramConverter2); + $this->container->setDefinition('param_converter_three', $paramConverter3); + + $this->pass->process($this->container); + + $methodCalls = $this->managerDefinition->getMethodCalls(); + $this->assertCount(3, $methodCalls); + $this->assertEquals(['add', [new Reference('param_converter_one'), 0, null]], $methodCalls[0]); + $this->assertEquals(['add', [new Reference('param_converter_two'), 0, 'foo']], $methodCalls[1]); + $this->assertEquals(['add', [new Reference('param_converter_three'), 5, null]], $methodCalls[2]); + } + + public function testProcessExplicitAddsTaggedServices() + { + $paramConverter1 = new Definition(); + $paramConverter1->setTags([ + 'request.param_converter' => [ + [ + 'priority' => 'false', + 'converter' => 'bar', + ], + ], + ]); + + $paramConverter2 = new Definition(); + $paramConverter2->setTags([ + 'request.param_converter' => [ + [ + 'converter' => 'foo', + ], + ], + ]); + + $paramConverter3 = new Definition(); + $paramConverter3->setTags([ + 'request.param_converter' => [ + [ + 'priority' => 5, + 'converter' => 'baz', + ], + ], + ]); + + $this->container->setDefinition('param_converter_one', $paramConverter1); + $this->container->setDefinition('param_converter_two', $paramConverter2); + $this->container->setDefinition('param_converter_three', $paramConverter3); + + $this->container->setParameter('param_converter.disabled_converters', ['bar', 'baz']); + + $this->pass->process($this->container); + + $methodCalls = $this->managerDefinition->getMethodCalls(); + $this->assertCount(1, $methodCalls); + $this->assertEquals(['add', [new Reference('param_converter_two'), 0, 'foo']], $methodCalls[0]); + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php b/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php new file mode 100644 index 0000000000000..161586c836568 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php @@ -0,0 +1,335 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\Cache; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\EventListener\CacheAttributeListener; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\CacheAttributeController; + +class CacheAttributeListenerTest extends TestCase +{ + protected function setUp(): void + { + $this->listener = new CacheAttributeListener(); + $this->response = new Response(); + $this->cache = new Cache(); + $this->request = $this->createRequest($this->cache); + $this->event = $this->createEventMock($this->request, $this->response); + } + + public function testWontReassignResponseWhenResponseIsUnsuccessful() + { + $response = $this->event->getResponse(); + + $this->response->setStatusCode(500); + + $this->listener->onKernelResponse($this->event); + + $this->assertSame($response, $this->event->getResponse()); + } + + public function testWontReassignResponseWhenNoConfigurationIsPresent() + { + $response = $this->event->getResponse(); + + $this->request->attributes->remove('_cache'); + + $this->listener->onKernelResponse($this->event); + + $this->assertSame($response, $this->event->getResponse()); + } + + public function testResponseIsPublicIfSharedMaxAgeSetAndPublicNotOverridden() + { + $request = $this->createRequest(new Cache(smaxage: 1)); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + + $this->assertTrue($this->response->headers->hasCacheControlDirective('public')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('private')); + } + + public function testResponseIsPublicIfConfigurationIsPublicTrue() + { + $request = $this->createRequest(new Cache(public: true)); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + + $this->assertTrue($this->response->headers->hasCacheControlDirective('public')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('private')); + } + + public function testResponseIsPrivateIfConfigurationIsPublicFalse() + { + $request = $this->createRequest(new Cache(public: false)); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + + $this->assertFalse($this->response->headers->hasCacheControlDirective('public')); + $this->assertTrue($this->response->headers->hasCacheControlDirective('private')); + } + + public function testResponseVary() + { + $vary = ['foobar']; + $request = $this->createRequest(new Cache(vary: $vary)); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + $this->assertTrue($this->response->hasVary()); + $result = $this->response->getVary(); + $this->assertEquals($vary, $result); + } + + public function testResponseVaryWhenVaryNotSet() + { + $request = $this->createRequest(new Cache()); + $vary = ['foobar']; + $this->response->setVary($vary); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + $this->assertTrue($this->response->hasVary()); + $result = $this->response->getVary(); + $this->assertNotEmpty($result, 'Existing vary headers should not be removed'); + $this->assertEquals($vary, $result, 'Vary header should not be changed'); + } + + public function testResponseIsPrivateIfConfigurationIsPublicNotSet() + { + $request = $this->createRequest(new Cache()); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + + $this->assertFalse($this->response->headers->hasCacheControlDirective('public')); + } + + public function testAttributeConfigurationsAreSetOnResponse() + { + $this->assertNull($this->response->getMaxAge()); + $this->assertNull($this->response->getExpires()); + $this->assertFalse($this->response->headers->hasCacheControlDirective('s-maxage')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('max-stale')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('stale-while-revalidate')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('stale-if-error')); + + $this->request->attributes->set('_cache', new Cache( + expires: 'tomorrow', + maxage: '15', + smaxage: '15', + maxstale: '5', + staleWhileRevalidate: '6', + staleIfError: '7', + )); + + $this->listener->onKernelResponse($this->event); + + $this->assertEquals('15', $this->response->getMaxAge()); + $this->assertEquals('15', $this->response->headers->getCacheControlDirective('s-maxage')); + $this->assertEquals('5', $this->response->headers->getCacheControlDirective('max-stale')); + $this->assertEquals('6', $this->response->headers->getCacheControlDirective('stale-while-revalidate')); + $this->assertEquals('7', $this->response->headers->getCacheControlDirective('stale-if-error')); + $this->assertInstanceOf(\DateTime::class, $this->response->getExpires()); + } + + public function testCacheMaxAgeSupportsStrtotimeFormat() + { + $this->request->attributes->set('_cache', new Cache( + maxage: '1 day', + smaxage: '1 day', + maxstale: '1 day', + staleWhileRevalidate: '1 day', + staleIfError: '1 day', + )); + + $this->listener->onKernelResponse($this->event); + + $this->assertEquals(60 * 60 * 24, $this->response->headers->getCacheControlDirective('s-maxage')); + $this->assertEquals(60 * 60 * 24, $this->response->getMaxAge()); + $this->assertEquals(60 * 60 * 24, $this->response->headers->getCacheControlDirective('max-stale')); + $this->assertEquals(60 * 60 * 24, $this->response->headers->getCacheControlDirective('stale-if-error')); + } + + public function testLastModifiedNotModifiedResponse() + { + $request = $this->createRequest(new Cache(lastModified: 'test.getDate()')); + $request->attributes->set('test', new TestEntity()); + $request->headers->add(['If-Modified-Since' => 'Fri, 23 Aug 2013 00:00:00 GMT']); + + $listener = new CacheAttributeListener(); + $controllerEvent = new ControllerEvent($this->getKernel(), function () { + return new Response(); + }, $request, null); + + $listener->onKernelController($controllerEvent); + $response = \call_user_func($controllerEvent->getController()); + + $this->assertEquals(304, $response->getStatusCode()); + } + + public function testLastModifiedHeader() + { + $request = $this->createRequest(new Cache(lastModified: 'test.getDate()')); + $request->attributes->set('test', new TestEntity()); + + $listener = new CacheAttributeListener(); + $controllerEvent = new ControllerEvent($this->getKernel(), function () { + return new Response(); + }, $request, null); + $listener->onKernelController($controllerEvent); + + $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MASTER_REQUEST, \call_user_func($controllerEvent->getController())); + $listener->onKernelResponse($responseEvent); + + $response = $responseEvent->getResponse(); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertTrue($response->headers->has('Last-Modified')); + $this->assertEquals('Fri, 23 Aug 2013 00:00:00 GMT', $response->headers->get('Last-Modified')); + } + + public function testEtagNotModifiedResponse() + { + $request = $this->createRequest(new Cache(Etag: 'test.getId()')); + $request->attributes->set('test', $entity = new TestEntity()); + $request->headers->add(['If-None-Match' => sprintf('"%s"', hash('sha256', $entity->getId()))]); + + $listener = new CacheAttributeListener(); + $controllerEvent = new ControllerEvent($this->getKernel(), function () { + return new Response(); + }, $request, null); + + $listener->onKernelController($controllerEvent); + $response = \call_user_func($controllerEvent->getController()); + + $this->assertEquals(304, $response->getStatusCode()); + } + + public function testEtagHeader() + { + $request = $this->createRequest(new Cache(Etag: 'test.getId()')); + $request->attributes->set('test', $entity = new TestEntity()); + + $listener = new CacheAttributeListener(); + $controllerEvent = new ControllerEvent($this->getKernel(), function () { + return new Response(); + }, $request, null); + $listener->onKernelController($controllerEvent); + + $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MASTER_REQUEST, \call_user_func($controllerEvent->getController())); + $listener->onKernelResponse($responseEvent); + + $response = $responseEvent->getResponse(); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertTrue($response->headers->has('Etag')); + $this->assertStringContainsString(hash('sha256', $entity->getId()), $response->headers->get('Etag')); + } + + public function testConfigurationDoesNotOverrideAlreadySetResponseHeaders() + { + $request = $this->createRequest(new Cache( + expires: 'Fri, 24 Aug 2013 00:00:00 GMT', + maxage: '15', + smaxage: '15', + vary: ['foobar'], + lastModified: 'Fri, 24 Aug 2013 00:00:00 GMT', + Etag: '"12345"', + )); + + $response = new Response(); + $response->setEtag('"54321"'); + $response->setLastModified(new \DateTime('Fri, 23 Aug 2014 00:00:00 GMT')); + $response->setExpires(new \DateTime('Fri, 24 Aug 2014 00:00:00 GMT')); + $response->setSharedMaxAge(30); + $response->setMaxAge(30); + $response->setVary(['foobaz']); + + $listener = new CacheAttributeListener(); + $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MASTER_REQUEST, $response); + $listener->onKernelResponse($responseEvent); + + $this->assertEquals('"54321"', $response->getEtag()); + $this->assertEquals(new \DateTime('Fri, 23 Aug 2014 00:00:00 GMT'), $response->getLastModified()); + $this->assertEquals(new \DateTime('Fri, 24 Aug 2014 00:00:00 GMT'), $response->getExpires()); + $this->assertEquals(30, $response->headers->getCacheControlDirective('s-maxage')); + $this->assertEquals(30, $response->getMaxAge()); + $this->assertEquals(['foobaz'], $response->getVary()); + } + + public function testAttribute() + { + $request = new Request(); + $event = new ControllerEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new CacheAttributeController(), 'foo'], + $request, + null + ); + + $this->listener->onKernelController($event); + + $configuration = $request->attributes->get('_cache'); + + $this->assertNotNull($configuration); + $this->assertEquals(CacheAttributeController::METHOD_SMAXAGE, $configuration->getSMaxAge()); + + $request = new Request(); + $event = new ControllerEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new CacheAttributeController(), 'bar'], + $request, + null + ); + + $this->listener->onKernelController($event); + + $configuration = $request->attributes->get('_cache'); + + $this->assertNotNull($configuration); + $this->assertEquals(CacheAttributeController::CLASS_SMAXAGE, $configuration->getSMaxAge()); + } + + private function createRequest(Cache $cache = null) + { + return new Request([], [], [ + '_cache' => $cache, + ]); + } + + private function createEventMock(Request $request, Response $response) + { + return new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MASTER_REQUEST, $response); + } + + private function getKernel() + { + return $this->getMockBuilder(HttpKernelInterface::class)->getMock(); + } +} + +class TestEntity +{ + public function getDate() + { + return new \DateTime('Fri, 23 Aug 2013 00:00:00 GMT'); + } + + public function getId() + { + return '12345'; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/EventListener/ParamConverterListenerTest.php b/src/Symfony/Component/HttpKernel/Tests/EventListener/ParamConverterListenerTest.php new file mode 100644 index 0000000000000..889f9a4df25ee --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/EventListener/ParamConverterListenerTest.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\ParamConverter; +use Symfony\Component\HttpKernel\Controller\ParamConverter\DateTimeParamConverter; +use Symfony\Component\HttpKernel\Controller\ParamConverter\ParamConverterManager; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\EventListener\ParamConverterListener; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\ParamConverterAttributeController; +use Symfony\Component\HttpKernel\Tests\Fixtures\FooControllerNullableParameter; +use Symfony\Component\HttpKernel\Tests\Fixtures\InvokableControllerWithUnion; + +class ParamConverterListenerTest extends TestCase +{ + /** + * @dataProvider getControllerWithNoArgsFixtures + */ + public function testRequestIsSkipped($controllerCallable) + { + $kernel = $this->getMockBuilder(HttpKernelInterface::class)->getMock(); + $request = new Request(); + + $listener = new ParamConverterListener($this->getParamConverterManager($request, [])); + $event = new ControllerEvent($kernel, $controllerCallable, $request, null); + + $listener->onKernelController($event); + } + + public function getControllerWithNoArgsFixtures() + { + return [ + [[new TestController(), 'noArgAction']], + [new InvokableNoArgController()], + ]; + } + + /** + * @dataProvider getControllerWithArgsFixtures + */ + public function testAutoConvert($controllerCallable) + { + $kernel = $this->getMockBuilder(HttpKernelInterface::class)->getMock(); + $request = new Request([], [], ['date' => '2014-03-14 09:00:00']); + + $converter = new ParamConverter(name: 'date', class: 'DateTime'); + + $listener = new ParamConverterListener($this->getParamConverterManager($request, ['date' => $converter])); + $event = new ControllerEvent($kernel, $controllerCallable, $request, null); + + $listener->onKernelController($event); + } + + public function testAutoConvertInterface() + { + $kernel = $this->getMockBuilder(HttpKernelInterface::class)->getMock(); + $request = new Request([], [], ['date' => '2014-03-14 09:00:00']); + + $converter = new ParamConverter(name: 'date', class: 'DateTimeInterface'); + + $listener = new ParamConverterListener($this->getParamConverterManager($request, ['date' => $converter])); + $event = new ControllerEvent($kernel, new InvokableControllerWithInterface(), $request, null); + + $listener->onKernelController($event); + } + + /** + * @dataProvider settingOptionalParamProvider + * @requires PHP 7.1 + */ + public function testSettingOptionalParam($function, $isOptional) + { + $kernel = $this->getMockBuilder(HttpKernelInterface::class)->getMock(); + $request = new Request(); + + $converter = new ParamConverter(name: 'param', class: 'DateTime'); + $converter->setIsOptional($isOptional); + + $listener = new ParamConverterListener($this->getParamConverterManager($request, ['param' => $converter]), true); + $event = new ControllerEvent( + $kernel, + [ + new FooControllerNullableParameter(), + $function, + ], + $request, + null + ); + + $listener->onKernelController($event); + } + + public function settingOptionalParamProvider() + { + return [ + ['requiredParamAction', false], + ['defaultParamAction', true], + ['nullableParamAction', true], + ]; + } + + /** + * @dataProvider getControllerWithArgsFixtures + */ + public function testNoAutoConvert($controllerCallable) + { + $kernel = $this->getMockBuilder(HttpKernelInterface::class)->getMock(); + $request = new Request([], [], ['date' => '2014-03-14 09:00:00']); + + $listener = new ParamConverterListener($this->getParamConverterManager($request, []), false); + $event = new ControllerEvent($kernel, $controllerCallable, $request, null); + + $listener->onKernelController($event); + } + + public function testAttribute() + { + $request = new Request([], [], ['foo' => '2014-03-14 09:00:00', 'bar' => '2014-03-14 09:00:00']); + $converters = [ + 'bar' => new ParamConverter(name: 'bar', class: 'DateTime'), + 'foo' => new ParamConverter(name: 'foo', class: 'DateTime'), + ]; + + $event = new ControllerEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new ParamConverterAttributeController(), 'foo'], + $request, + null + ); + + $listener = new ParamConverterListener($this->getParamConverterManager($request, $converters), false); + $listener->onKernelController($event); + } + + public function getControllerWithArgsFixtures(): iterable + { + yield [[new TestController(), 'dateAction']]; + yield [new InvokableController()]; + + if (80000 <= \PHP_VERSION_ID) { + yield [new InvokableControllerWithUnion()]; + } + } + + private function getParamConverterManager(Request $request, $configurations) + { + $manager = $this->getMockBuilder(ParamConverterManager::class)->getMock(); + $manager + ->expects($this->once()) + ->method('apply') + ->with($this->equalTo($request), $this->equalTo($configurations)) + ; + + return $manager; + } +} + +class TestController +{ + public function noArgAction(Request $request) + { + } + + public function dateAction(\DateTime $date) + { + } +} + +class InvokableNoArgController +{ + public function __invoke(Request $request) + { + } +} + +class InvokableController +{ + public function __invoke(\DateTime $date) + { + } +} + +class InvokableControllerWithInterface +{ + public function __invoke(\DateTimeInterface $date) + { + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/CacheAttributeController.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/CacheAttributeController.php new file mode 100644 index 0000000000000..cbf29e86d50b7 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/CacheAttributeController.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Fixtures\Controller; + +use Symfony\Component\HttpKernel\Attribute\Cache; + +#[Cache(smaxage: 20)] +class CacheAttributeController +{ + const CLASS_SMAXAGE = 20; + const METHOD_SMAXAGE = 25; + + #[Cache(smaxage: 25)] + public function foo() + { + } + + public function bar() + { + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ParamConverterAttributeController.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ParamConverterAttributeController.php new file mode 100644 index 0000000000000..8ccdc4f6d24cc --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/ParamConverterAttributeController.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Fixtures\Controller; + +use Symfony\Component\HttpKernel\Attribute\ParamConverter; + +#[ParamConverter(name: 'bar', class: 'DateTime')] +class ParamConverterAttributeController +{ + #[ParamConverter(name: 'foo', class: 'DateTime')] + public function foo(\DateTime $foo, \DateTime $bar) + { + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/FooControllerNullableParameter.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/FooControllerNullableParameter.php new file mode 100644 index 0000000000000..d3df786d36250 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/FooControllerNullableParameter.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Fixtures; + +class FooDateTime extends \DateTime +{ +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/InvokableControllerWithUnion.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/InvokableControllerWithUnion.php new file mode 100644 index 0000000000000..0838aa36e1d09 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/InvokableControllerWithUnion.php @@ -0,0 +1,10 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Attribute; + +/** + * The Security class handles the Security attribute. + * + * @author Ryan Weaver + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +class IsGranted +{ + /** + * Sets the first argument that will be passed to isGranted(). + * + * @var mixed + */ + private $attributes; + + /** + * Sets the second argument passed to isGranted(). + * + * @var mixed + */ + private $subject; + + /** + * The message of the exception - has a nice default if not set. + * + * @var string + */ + private $message; + + /** + * If set, will throw Symfony\Component\HttpKernel\Exception\HttpException + * with the given $statusCode. + * If null, Symfony\Component\Security\Core\Exception\AccessDeniedException. + * will be used. + * + * @var int|null + */ + private $statusCode; + + /** + * @param mixed $subject + */ + public function __construct( + array|string $attributes = null, + $subject = null, + string $message = null, + ?int $statusCode = null + ) { + $this->attributes = $attributes; + $this->subject = $subject; + $this->message = $message; + $this->statusCode = $statusCode; + } + + public function setAttributes($attributes) + { + $this->attributes = $attributes; + } + + public function getAttributes() + { + return $this->attributes; + } + + public function setSubject($subject) + { + $this->subject = $subject; + } + + public function getSubject() + { + return $this->subject; + } + + public function getMessage() + { + return $this->message; + } + + public function setMessage($message) + { + $this->message = $message; + } + + public function getStatusCode() + { + return $this->statusCode; + } + + public function setStatusCode($statusCode) + { + $this->statusCode = $statusCode; + } +} diff --git a/src/Symfony/Component/Security/Http/Attribute/Security.php b/src/Symfony/Component/Security/Http/Attribute/Security.php new file mode 100644 index 0000000000000..0e88a6916333d --- /dev/null +++ b/src/Symfony/Component/Security/Http/Attribute/Security.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\Component\Security\Http\Attribute; + +/** + * The Security class handles the Security attribute. + * + * @author Fabien Potencier + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +class Security +{ + /** + * The expression evaluated to allow or deny access. + * + * @var string + */ + private $expression; + + /** + * If set, will throw Symfony\Component\HttpKernel\Exception\HttpException + * with the given $statusCode. + * If null, Symfony\Component\Security\Core\Exception\AccessDeniedException. + * will be used. + * + * @var int|null + */ + protected $statusCode; + + /** + * The message of the exception. + * + * @var string + */ + protected $message = 'Access denied.'; + + /** + * @param array|string $data + */ + public function __construct( + string $expression = null, + string $message = null, + ?int $statusCode = null + ) { + $this->expression = $expression; + if (null !== $message) { + $this->message = $message; + } + $this->statusCode = $statusCode; + } + + public function getExpression() + { + return $this->expression; + } + + public function setExpression($expression) + { + $this->expression = $expression; + } + + public function getStatusCode() + { + return $this->statusCode; + } + + public function setStatusCode($statusCode) + { + $this->statusCode = $statusCode; + } + + public function getMessage() + { + return $this->message; + } + + public function setMessage($message) + { + $this->message = $message; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php new file mode 100644 index 0000000000000..6d5b9a3738365 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/IsGrantedAttributeListener.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentNameConverter; +use Symfony\Component\HttpKernel\Event\KernelEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Http\Attribute\IsGranted; + +/** + * Handles the IsGranted annotation on controllers. + * + * @author Ryan Weaver + */ +class IsGrantedAttributeListener implements EventSubscriberInterface +{ + private $argumentNameConverter; + private $authChecker; + + public function __construct(ArgumentNameConverter $argumentNameConverter, AuthorizationCheckerInterface $authChecker = null) + { + $this->argumentNameConverter = $argumentNameConverter; + $this->authChecker = $authChecker; + } + + public function onKernelControllerArguments(KernelEvent $event) + { + $controller = $event->getController(); + + if (!\is_array($controller) && method_exists($controller, '__invoke')) { + $controller = [$controller, '__invoke']; + } + + if (!\is_array($controller)) { + return; + } + + $className = \get_class($controller[0]); + $object = new \ReflectionClass($className); + $method = $object->getMethod($controller[1]); + + $classConfigurations = array_map( + function (\ReflectionAttribute $attribute) { + return $attribute->newInstance(); + }, + $object->getAttributes(IsGranted::class) + ); + $methodConfigurations = array_map( + function (\ReflectionAttribute $attribute) { + return $attribute->newInstance(); + }, + $method->getAttributes(IsGranted::class) + ); + $configurations = array_merge($classConfigurations, $methodConfigurations); + + if (0 === count($configurations)) { + return; + } + + if (null === $this->authChecker) { + throw new \LogicException('To use the @IsGranted tag, you need to install symfony/security-bundle and configure your security system.'); + } + + $arguments = $this->argumentNameConverter->getControllerArguments($event); + + foreach ($configurations as $configuration) { + $subjectRef = $configuration->getSubject(); + $subject = null; + + if ($subjectRef) { + if (\is_array($subjectRef)) { + foreach ($subjectRef as $ref) { + if (!\array_key_exists($ref, $arguments)) { + throw $this->createMissingSubjectException($ref); + } + + $subject[$ref] = $arguments[$ref]; + } + } else { + if (!\array_key_exists($subjectRef, $arguments)) { + throw $this->createMissingSubjectException($subjectRef); + } + + $subject = $arguments[$subjectRef]; + } + } + + if (!$this->authChecker->isGranted($configuration->getAttributes(), $subject)) { + $argsString = $this->getIsGrantedString($configuration); + + $message = $configuration->getMessage() ?: sprintf('Access Denied by controller annotation @IsGranted(%s)', $argsString); + + if ($statusCode = $configuration->getStatusCode()) { + throw new HttpException($statusCode, $message); + } + + $accessDeniedException = new AccessDeniedException($message); + $accessDeniedException->setAttributes($configuration->getAttributes()); + $accessDeniedException->setSubject($subject); + + throw $accessDeniedException; + } + } + } + + private function createMissingSubjectException(string $subject) + { + return new \RuntimeException(sprintf('Could not find the subject "%s" for the @IsGranted annotation. Try adding a "$%s" argument to your controller method.', $subject, $subject)); + } + + private function getIsGrantedString(IsGranted $isGranted) + { + $attributes = array_map(function ($attribute) { + return sprintf('"%s"', $attribute); + }, (array) $isGranted->getAttributes()); + if (1 === \count($attributes)) { + $argsString = reset($attributes); + } else { + $argsString = sprintf('[%s]', implode(', ', $attributes)); + } + + if (null !== $isGranted->getSubject()) { + $argsString = sprintf('%s, %s', $argsString, $isGranted->getSubject()); + } + + return $argsString; + } + + /** + * @return array + */ + public static function getSubscribedEvents() + { + return [KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments']; + } +} diff --git a/src/Symfony/Component/Security/Http/EventListener/SecurityAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/SecurityAttributeListener.php new file mode 100644 index 0000000000000..b6ebfc2cf8a59 --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/SecurityAttributeListener.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentNameConverter; +use Symfony\Component\HttpKernel\Event\KernelEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; +use Symfony\Component\Security\Http\Attribute\Security; + +/** + * SecurityListener handles security restrictions on controllers. + * + * @author Fabien Potencier + */ +class SecurityAttributeListener implements EventSubscriberInterface +{ + private $argumentNameConverter; + private $tokenStorage; + private $authChecker; + private $language; + private $trustResolver; + private $roleHierarchy; + private $logger; + + public function __construct(ArgumentNameConverter $argumentNameConverter, ExpressionLanguage $language = null, AuthenticationTrustResolverInterface $trustResolver = null, RoleHierarchyInterface $roleHierarchy = null, TokenStorageInterface $tokenStorage = null, AuthorizationCheckerInterface $authChecker = null, LoggerInterface $logger = null) + { + $this->argumentNameConverter = $argumentNameConverter; + $this->tokenStorage = $tokenStorage; + $this->authChecker = $authChecker; + $this->language = $language; + $this->trustResolver = $trustResolver; + $this->roleHierarchy = $roleHierarchy; + $this->logger = $logger; + } + + public function onKernelControllerArguments(KernelEvent $event) + { + $controller = $event->getController(); + + if (!\is_array($controller) && method_exists($controller, '__invoke')) { + $controller = [$controller, '__invoke']; + } + + if (!\is_array($controller)) { + return; + } + + $className = \get_class($controller[0]); + $object = new \ReflectionClass($className); + $method = $object->getMethod($controller[1]); + + $classConfigurations = array_map( + function (\ReflectionAttribute $attribute) { + return $attribute->newInstance(); + }, + $object->getAttributes(Security::class) + ); + $methodConfigurations = array_map( + function (\ReflectionAttribute $attribute) { + return $attribute->newInstance(); + }, + $method->getAttributes(Security::class) + ); + $configurations = array_merge($methodConfigurations, $classConfigurations); + + if (0 === count($configurations)) { + return; + } + + if (null === $this->tokenStorage || null === $this->trustResolver) { + throw new \LogicException('To use the @Security tag, you need to install the Symfony Security bundle.'); + } + + if (null === $this->tokenStorage->getToken()) { + throw new AccessDeniedException('No user token or you forgot to put your controller behind a firewall while using a @Security tag.'); + } + + if (null === $this->language) { + throw new \LogicException('To use the @Security tag, you need to use the Security component 2.4 or newer and install the ExpressionLanguage component.'); + } + + foreach ($configurations as $configuration) { + if (!$this->language->evaluate($configuration->getExpression(), $this->getVariables($event))) { + if ($statusCode = $configuration->getStatusCode()) { + throw new HttpException($statusCode, $configuration->getMessage()); + } + + throw new AccessDeniedException($configuration->getMessage() ?: sprintf('Expression "%s" denied access.', $configuration->getExpression())); + } + } + } + + // code should be sync with Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter + private function getVariables(KernelEvent $event) + { + $request = $event->getRequest(); + $token = $this->tokenStorage->getToken(); + $variables = [ + 'token' => $token, + 'user' => $token->getUser(), + 'object' => $request, + 'subject' => $request, + 'request' => $request, + 'roles' => $this->getRoles($token), + 'trust_resolver' => $this->trustResolver, + // needed for the is_granted expression function + 'auth_checker' => $this->authChecker, + ]; + + $controllerArguments = $this->argumentNameConverter->getControllerArguments($event); + + if ($diff = array_intersect(array_keys($variables), array_keys($controllerArguments))) { + foreach ($diff as $key => $variableName) { + if ($variables[$variableName] === $controllerArguments[$variableName]) { + unset($diff[$key]); + } + } + + if ($diff) { + $singular = 1 === \count($diff); + if (null !== $this->logger) { + $this->logger->warning(sprintf('Controller argument%s "%s" collided with the built-in security expression variables. The built-in value%s are being used for the @Security expression.', $singular ? '' : 's', implode('", "', $diff), $singular ? 's' : '')); + } + } + } + + // controller variables should also be accessible + return array_merge($controllerArguments, $variables); + } + + private function getRoles(TokenInterface $token): array + { + if (method_exists($this->roleHierarchy, 'getReachableRoleNames')) { + if (null !== $this->roleHierarchy) { + $roles = $this->roleHierarchy->getReachableRoleNames($token->getRoleNames()); + } else { + $roles = $token->getRoleNames(); + } + } else { + if (null !== $this->roleHierarchy) { + $roles = $this->roleHierarchy->getReachableRoles($token->getRoles()); + } else { + $roles = $token->getRoles(); + } + + $roles = array_map(function ($role) { + return $role->getRole(); + }, $roles); + } + + return $roles; + } + + /** + * @return array + */ + public static function getSubscribedEvents() + { + return [KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments']; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Attribute/SecurityTest.php b/src/Symfony/Component/Security/Http/Tests/Attribute/SecurityTest.php new file mode 100644 index 0000000000000..5f97d1f66bd23 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Attribute/SecurityTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Http\Attribute\Security; + +class SecurityTest extends TestCase +{ + public function testEmptyConstruct() + { + $security = new Security(); + $this->assertEquals('Access denied.', $security->getMessage()); + $this->assertNull($security->getStatusCode()); + $this->assertNull($security->getExpression()); + } + + public function testSettersViaConstruct() + { + $security = new Security("is_granted('foo')", 'Not allowed', 403); + $this->assertEquals('Not allowed', $security->getMessage()); + $this->assertEquals(403, $security->getStatusCode()); + $this->assertEquals("is_granted('foo')", $security->getExpression()); + } + + public function testSetters() + { + $security = new Security("is_granted('foo')", 'Not allowed', 403); + $security->setExpression("is_granted('bar')"); + $security->setMessage('Disallowed'); + $security->setStatusCode(404); + $this->assertEquals('Disallowed', $security->getMessage()); + $this->assertEquals(404, $security->getStatusCode()); + $this->assertEquals("is_granted('bar')", $security->getExpression()); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php new file mode 100644 index 0000000000000..7394cbb078606 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/IsGrantedAttributeListenerTest.php @@ -0,0 +1,305 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentNameConverter; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener; +use Symfony\Component\Security\Http\Tests\Fixtures\Controller\IsGrantedAttributeController; +use Symfony\Component\Security\Http\Tests\Fixtures\Controller\IsGrantedAttributeMethodsController; + +class IsGrantedAttributeListenerTest extends TestCase +{ + public function testAttribute() + { + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->exactly(2)) + ->method('isGranted') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeController(), 'foo'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter([]), $authChecker); + $listener->onKernelControllerArguments($event); + + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->once()) + ->method('isGranted') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeController(), 'bar'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter([]), $authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testExceptionIfSecurityNotInstalled() + { + $this->expectException(\LogicException::class); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), 'emptyAttribute'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter([])); + $listener->onKernelControllerArguments($event); + } + + public function testNothingHappensWithNoConfig() + { + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->never()) + ->method('isGranted'); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), 'noAttribute'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter([]), $authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedCalledCorrectly() + { + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with('ROLE_ADMIN') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), 'admin'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter([]), $authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedSubjectFromArguments() + { + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->once()) + ->method('isGranted') + // the subject => arg2name will eventually resolve to the 2nd argument, which has this value + ->with('ROLE_ADMIN', 'arg2Value') + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), 'withSubject'], + [], + new Request(), + null + ); + + // create metadata for 2 named args for the controller + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter(['arg1Name' => 'arg1Value', 'arg2Name' => 'arg2Value']), $authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedSubjectFromArgumentsWithArray() + { + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->once()) + ->method('isGranted') + // the subject => arg2name will eventually resolve to the 2nd argument, which has this value + ->with('ROLE_ADMIN', [ + 'arg1Name' => 'arg1Value', + 'arg2Name' => 'arg2Value', + ]) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), 'withSubjectArray'], + [], + new Request(), + null + ); + + // create metadata for 2 named args for the controller + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter(['arg1Name' => 'arg1Value', 'arg2Name' => 'arg2Value']), $authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedNullSubjectFromArguments() + { + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with('ROLE_ADMIN', null) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), 'withSubject'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter(['arg1Name' => 'arg1Value', 'arg2Name' => null]), $authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testIsGrantedArrayWithNullValueSubjectFromArguments() + { + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->once()) + ->method('isGranted') + ->with('ROLE_ADMIN', [ + 'arg1Name' => 'arg1Value', + 'arg2Name' => null, + ]) + ->willReturn(true); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), 'withSubjectArray'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter(['arg1Name' => 'arg1Value', 'arg2Name' => null]), $authChecker); + $listener->onKernelControllerArguments($event); + } + + public function testExceptionWhenMissingSubjectAttribute() + { + $this->expectException(\RuntimeException::class); + + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), 'withMissingSubject'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter([]), $authChecker); + $listener->onKernelControllerArguments($event); + } + + /** + * @dataProvider getAccessDeniedMessageTests + */ + public function testAccessDeniedMessages(array $attributes, ?string $subject, string $method, string $expectedMessage) + { + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + // avoid the error of the subject not being found in the request attributes + $arguments = []; + if (null !== $subject) { + $arguments[$subject] = 'bar'; + } + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter($arguments), $authChecker); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), $method], + [], + new Request(), + null + ); + + try { + $listener->onKernelControllerArguments($event); + $this->fail(); + } catch (\Exception $e) { + $this->assertEquals(AccessDeniedException::class, \get_class($e)); + $this->assertEquals($expectedMessage, $e->getMessage()); + $this->assertEquals($attributes, $e->getAttributes()); + if (null !== $subject) { + $this->assertEquals('bar', $e->getSubject()); + } else { + $this->assertNull($e->getSubject()); + } + } + } + + public function getAccessDeniedMessageTests() + { + yield [['ROLE_ADMIN'], null, 'admin', 'Access Denied by controller annotation @IsGranted("ROLE_ADMIN")']; + yield [['ROLE_ADMIN', 'ROLE_USER'], null, 'adminOrUser', 'Access Denied by controller annotation @IsGranted(["ROLE_ADMIN", "ROLE_USER"])']; + yield [['ROLE_ADMIN', 'ROLE_USER'], 'product', 'adminOrUserWithSubject', 'Access Denied by controller annotation @IsGranted(["ROLE_ADMIN", "ROLE_USER"], product)']; + } + + public function testNotFoundHttpException() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Not found'); + + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->any()) + ->method('isGranted') + ->willReturn(false); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new IsGrantedAttributeMethodsController(), 'notFound'], + [], + new Request(), + null + ); + + $listener = new IsGrantedAttributeListener($this->createArgumentNameConverter([]), $authChecker); + $listener->onKernelControllerArguments($event); + } + + private function createArgumentNameConverter(array $arguments) + { + $nameConverter = $this->getMockBuilder(ArgumentNameConverter::class)->disableOriginalConstructor()->getMock(); + + $nameConverter->expects($this->any()) + ->method('getControllerArguments') + ->willReturn($arguments); + + return $nameConverter; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/SecurityAttributeListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/SecurityAttributeListenerTest.php new file mode 100644 index 0000000000000..68eba76f5af9b --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/SecurityAttributeListenerTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentNameConverter; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Core\Role\RoleHierarchy; +use Symfony\Component\Security\Http\Attribute\Security; +use Symfony\Component\Security\Http\EventListener\SecurityAttributeListener; +use Symfony\Component\Security\Http\Tests\Fixtures\Controller\SecurityAttributeController; + +class SecurityAttributeListenerTest extends \PHPUnit\Framework\TestCase +{ + public function testAccessDenied() + { + $this->expectException(AccessDeniedException::class); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new SecurityAttributeController(), 'accessDenied'], + [], + new Request(), + null + ); + + $this->getListener()->onKernelControllerArguments($event); + } + + public function testNotFoundHttpException() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Not found'); + + $event = new ControllerArgumentsEvent( + $this->getMockBuilder(HttpKernelInterface::class)->getMock(), + [new SecurityAttributeController(), 'notFound'], + [], + new Request(), + null + ); + + $this->getListener()->onKernelControllerArguments($event); + } + + private function getListener() + { + $roleHierarchy = $this->getMockBuilder(RoleHierarchy::class)->disableOriginalConstructor()->getMock(); + $roleHierarchy->expects($this->once())->method('getReachableRoleNames')->willReturn([]); + + $token = $this->getMockBuilder(AbstractToken::class)->getMock(); + $token->expects($this->once())->method('getRoleNames')->willReturn([]); + + $tokenStorage = $this->getMockBuilder(TokenStorageInterface::class)->getMock(); + $tokenStorage->expects($this->exactly(2))->method('getToken')->willReturn($token); + + $authChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); + $authChecker->expects($this->exactly(2))->method('isGranted')->willReturn(false); + + $trustResolver = $this->getMockBuilder(AuthenticationTrustResolverInterface::class)->getMock(); + + $argNameConverter = $this->createArgumentNameConverter([]); + + $language = new ExpressionLanguage(); + + return new SecurityAttributeListener($argNameConverter, $language, $trustResolver, $roleHierarchy, $tokenStorage, $authChecker); + } + + private function createArgumentNameConverter(array $arguments) + { + $nameConverter = $this->getMockBuilder(ArgumentNameConverter::class)->disableOriginalConstructor()->getMock(); + + $nameConverter->expects($this->any()) + ->method('getControllerArguments') + ->willReturn($arguments); + + return $nameConverter; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/Controller/IsGrantedAttributeController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/Controller/IsGrantedAttributeController.php new file mode 100644 index 0000000000000..94311867b04c5 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/Controller/IsGrantedAttributeController.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures\Controller; + +use Symfony\Component\Security\Http\Attribute\IsGranted; + +#[IsGranted(attributes: ['ROLE_ADMIN', 'ROLE_USER'])] +class IsGrantedAttributeController +{ + #[IsGranted(attributes: ['ROLE_ADMIN'])] + public function foo() + { + } + + public function bar() + { + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/Controller/IsGrantedAttributeMethodsController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/Controller/IsGrantedAttributeMethodsController.php new file mode 100644 index 0000000000000..fbe278e975794 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/Controller/IsGrantedAttributeMethodsController.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures\Controller; + +use Symfony\Component\Security\Http\Attribute\IsGranted; + +class IsGrantedAttributeMethodsController +{ + public function noAttribute() + { + } + + #[IsGranted()] + public function emptyAttribute() + { + } + + #[IsGranted(attributes: 'ROLE_ADMIN')] + public function admin() + { + } + + #[IsGranted(attributes: ['ROLE_ADMIN', 'ROLE_USER'])] + public function adminOrUser() + { + } + + #[IsGranted(attributes: ['ROLE_ADMIN', 'ROLE_USER'], subject: 'product')] + public function adminOrUserWithSubject() + { + } + + #[IsGranted(attributes: 'ROLE_ADMIN', subject: 'arg2Name')] + public function withSubject() + { + } + + #[IsGranted(attributes: 'ROLE_ADMIN', subject: ['arg1Name', 'arg2Name'])] + public function withSubjectArray() + { + } + + #[IsGranted(attributes: 'ROLE_ADMIN', subject: 'non_existent')] + public function withMissingSubject() + { + } + + #[IsGranted(attributes: 'ROLE_ADMIN', statusCode: 404, message: 'Not found')] + public function notFound() + { + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/Controller/SecurityAttributeController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/Controller/SecurityAttributeController.php new file mode 100644 index 0000000000000..ccc8775e1b23a --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/Controller/SecurityAttributeController.php @@ -0,0 +1,18 @@ +