diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index b7efe5a18bbf7..38734c622ec00 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -23,6 +23,7 @@ CHANGELOG the `#[AsController]` attribute is no longer required * Deprecate setting the `framework.profiler.collect_serializer_data` config option to `false` * Set `framework.rate_limiter.limiters.*.lock_factory` to `auto` by default + * Add `framework.rate_limiter.builder` option 7.2 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 6dc1b7d6e57d8..b54e8f7122b61 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -2499,6 +2499,24 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $ }) ->end() ->children() + ->arrayNode('builder') + ->info('Configuration for the RateLimiterBuilder service.') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('lock_factory') + ->info('The service ID of the lock factory to use with the RateLimiterBuilder.') + ->defaultValue('auto') + ->end() + ->scalarNode('cache_pool') + ->info('The cache pool to use with RateLimiterBuilder.') + ->defaultValue('cache.rate_limiter') + ->end() + ->scalarNode('storage_service') + ->info('The service ID of a custom storage implementation, this precedes any configured "cache_pool".') + ->defaultNull() + ->end() + ->end() + ->end() ->arrayNode('limiters') ->useAttributeAsKey('name') ->arrayPrototype() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 98e2e8904c3f2..6c7d89502c94e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -159,6 +159,7 @@ use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimiterBuilder; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Symfony\Component\RateLimiter\Storage\CacheStorage; @@ -3272,6 +3273,38 @@ private function registerRateLimiterConfiguration(array $config, ContainerBuilde $container->registerAliasForArgument($limiterId, RateLimiterFactoryInterface::class, $name.'.limiter'); } } + + if (class_exists(RateLimiterBuilder::class)) { + $builderConfig = $config['builder']; + + if ('auto' === $builderConfig['lock_factory']) { + $builderConfig['lock_factory'] = $this->isInitializedConfigEnabled('lock') ? 'lock.factory' : null; + } + + $builder = $container->getDefinition('limiter_builder'); + + if (null === $storageId = $builderConfig['storage_service'] ?? null) { + $container->register($storageId = 'limiter_builder.storage', CacheStorage::class)->addArgument(new Reference($builderConfig['cache_pool'])); + } + + $builder->replaceArgument(0, new Reference($storageId)); + + if ($builderConfig['lock_factory']) { + if (!interface_exists(LockInterface::class)) { + throw new LogicException('Rate Limiter Builder requires the Lock component to be installed. Try running "composer require symfony/lock".'); + } + + if (!$this->isInitializedConfigEnabled('lock')) { + throw new LogicException('Rate Limiter Builder requires the Lock component to be configured.'); + } + + $builder->replaceArgument(1, new Reference($builderConfig['lock_factory'])); + } + + $container->setAlias(RateLimiterBuilder::class, 'limiter_builder'); + } else { + $container->removeDefinition('limiter_builder'); + } } private function registerUidConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php index 727a1f6364456..bc3b01ebee9c4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php @@ -11,7 +11,9 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\RateLimiter\RateLimiterBuilder; use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\Storage\CacheStorage; return static function (ContainerConfigurator $container) { $container->services() @@ -26,5 +28,11 @@ abstract_arg('storage'), null, ]) + + ->set('limiter_builder', RateLimiterBuilder::class) + ->args([ + abstract_arg('storage'), + null, + ]) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index c8142e98ab1a7..06731161aab08 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -988,6 +988,11 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'rate_limiter' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(TokenBucketLimiter::class), 'limiters' => [], + 'builder' => [ + 'lock_factory' => 'auto', + 'cache_pool' => 'cache.rate_limiter', + 'storage_service' => null, + ] ], 'uid' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(UuidFactory::class), diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php index ea8d481e0f0f0..5b5c63ca6c003 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php @@ -17,6 +17,7 @@ use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Exception\OutOfBoundsException; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\RateLimiter\RateLimiterBuilder; use Symfony\Component\Workflow\Exception\InvalidDefinitionException; class PhpFrameworkExtensionTest extends FrameworkExtensionTestCase @@ -290,4 +291,42 @@ public function testRateLimiterIsTagged() $this->assertSame('first', $container->getDefinition('limiter.first')->getTag('rate_limiter')[0]['name']); $this->assertSame('second', $container->getDefinition('limiter.second')->getTag('rate_limiter')[0]['name']); } + + public function testRateLimiterBuilderDefault() + { + if (!class_exists(RateLimiterBuilder::class)) { + $this->markTestSkipped('RateLimiterBuilder is not available.'); + } + + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'rate_limiter' => true, + ]); + }); + + $this->assertSame('cache.rate_limiter', (string) $container->getDefinition('limiter_builder.storage')->getArgument(0)); + + $builder = $container->getDefinition('limiter_builder'); + $this->assertSame('limiter_builder.storage', (string) $builder->getArgument(0)); + $this->assertNull($builder->getArgument(1)); + + $this->assertSame('limiter_builder', (string) $container->getAlias(RateLimiterBuilder::class)); + } + + public function testRateLimiterBuilderDefaultWithLock() + { + if (!class_exists(RateLimiterBuilder::class)) { + $this->markTestSkipped('RateLimiterBuilder is not available.'); + } + + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'lock' => true, + 'rate_limiter' => true, + ]); + }); + + $builder = $container->getDefinition('limiter_builder'); + $this->assertSame('lock.factory', (string) $builder->getArgument(1)); + } } diff --git a/src/Symfony/Component/RateLimiter/CHANGELOG.md b/src/Symfony/Component/RateLimiter/CHANGELOG.md index d2851b915d62e..adca15c601706 100644 --- a/src/Symfony/Component/RateLimiter/CHANGELOG.md +++ b/src/Symfony/Component/RateLimiter/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add `RateLimiterFactoryInterface` * Add `CompoundRateLimiterFactory` + * Add `RateLimiterBuilder` 6.4 --- diff --git a/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php new file mode 100644 index 0000000000000..6cbaf48883553 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\RateLimiter; + +use Symfony\Component\Lock\LockInterface; +use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter; +use Symfony\Component\RateLimiter\Policy\NoLimiter; +use Symfony\Component\RateLimiter\Policy\Rate; +use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter; +use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter; +use Symfony\Component\RateLimiter\Storage\StorageInterface; +use Symfony\Component\RateLimiter\Util\TimeUtil; + +/** + * @author Kevin Bond + */ +class RateLimiterBuilder +{ + public function __construct( + private StorageInterface $storage, + private ?LockInterface $lock = null, + ) { + } + + /** + * @param string $id Unique identifier for the rate limiter + * @param int $limit Maximum allowed hits for the passed interval + * @param \DateInterval|string $interval If string, must be a number followed by "second", + * "minute", "hour", "day", "week" or "month" (or their + * plural equivalent) + */ + public function slidingWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface + { + return new SlidingWindowLimiter($id, $limit, TimeUtil::intervalNormalizer($interval), $this->storage, $this->lock); + } + + /** + * @param string $id Unique identifier for the rate limiter + * @param int $limit Maximum allowed hits for the passed interval + * @param \DateInterval|string $interval If string, must be a number followed by "second", + * "minute", "hour", "day", "week" or "month" (or their + * plural equivalent) + */ + public function fixedWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface + { + return new FixedWindowLimiter($id, $limit, TimeUtil::intervalNormalizer($interval), $this->storage, $this->lock); + } + + /** + * @param string $id Unique identifier for the rate limiter + * @param int $maxBurst The maximum allowed hits in a burst + * @param Rate $rate The rate at which tokens are added to the bucket + */ + public function tokenBucket(string $id, int $maxBurst, Rate $rate): LimiterInterface + { + return new TokenBucketLimiter($id, $maxBurst, $rate, $this->storage, $this->lock); + } + + public function compound(LimiterInterface ...$limiters): LimiterInterface + { + return new CompoundLimiter($limiters); + } + + public function noop(): LimiterInterface + { + return new NoLimiter(); + } +} diff --git a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php index f9d0ca9a7386e..76edae2356fda 100644 --- a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php +++ b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php @@ -20,6 +20,7 @@ use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter; use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter; use Symfony\Component\RateLimiter\Storage\StorageInterface; +use Symfony\Component\RateLimiter\Util\TimeUtil; /** * @author Wouter de Jong @@ -56,21 +57,7 @@ public function create(?string $key = null): LimiterInterface private static function configureOptions(OptionsResolver $options): void { $intervalNormalizer = static function (Options $options, string $interval): \DateInterval { - // Create DateTimeImmutable from unix timesatmp, so the default timezone is ignored and we don't need to - // deal with quirks happening when modifying dates using a timezone with DST. - $now = \DateTimeImmutable::createFromFormat('U', time()); - - try { - $nowPlusInterval = @$now->modify('+'.$interval); - } catch (\DateMalformedStringException $e) { - throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval), 0, $e); - } - - if (!$nowPlusInterval) { - throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval)); - } - - return $now->diff($nowPlusInterval); + return TimeUtil::intervalNormalizer($interval); }; $options diff --git a/src/Symfony/Component/RateLimiter/Tests/RateLimiterBuilderTest.php b/src/Symfony/Component/RateLimiter/Tests/RateLimiterBuilderTest.php new file mode 100644 index 0000000000000..fe17b888939ff --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Tests/RateLimiterBuilderTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\RateLimiter\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\RateLimiter\CompoundLimiter; +use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter; +use Symfony\Component\RateLimiter\Policy\NoLimiter; +use Symfony\Component\RateLimiter\Policy\Rate; +use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter; +use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter; +use Symfony\Component\RateLimiter\RateLimiterBuilder; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; + +class RateLimiterBuilderTest extends TestCase +{ + public function testCreateMethods() + { + $builder = new RateLimiterBuilder(new InMemoryStorage()); + + $this->assertInstanceOf(SlidingWindowLimiter::class, $builder->slidingWindow('foo', 5, '1 minute')); + $this->assertInstanceOf(FixedWindowLimiter::class, $builder->fixedWindow('foo', 5, '1 minute')); + $this->assertInstanceOf(TokenBucketLimiter::class, $builder->tokenBucket('foo', 5, Rate::perHour(2))); + $this->assertInstanceOf(CompoundLimiter::class, $builder->compound($this->createMock(LimiterInterface::class))); + $this->assertInstanceOf(NoLimiter::class, $builder->noop()); + } +} diff --git a/src/Symfony/Component/RateLimiter/Util/TimeUtil.php b/src/Symfony/Component/RateLimiter/Util/TimeUtil.php index 30351d72c4c22..6099efce1e3a5 100644 --- a/src/Symfony/Component/RateLimiter/Util/TimeUtil.php +++ b/src/Symfony/Component/RateLimiter/Util/TimeUtil.php @@ -24,4 +24,27 @@ public static function dateIntervalToSeconds(\DateInterval $interval): int return $now->add($interval)->getTimestamp() - $now->getTimestamp(); } + + public static function intervalNormalizer(\DateInterval|string $interval): \DateInterval + { + if ($interval instanceof \DateInterval) { + return $interval; + } + + // Create DateTimeImmutable from unix timestamp, so the default timezone is ignored and we don't need to + // deal with quirks happening when modifying dates using a timezone with DST. + $now = \DateTimeImmutable::createFromFormat('U', time()); + + try { + $nowPlusInterval = @$now->modify('+'.$interval); + } catch (\DateMalformedStringException $e) { + throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval), 0, $e); + } + + if (!$nowPlusInterval) { + throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval)); + } + + return $now->diff($nowPlusInterval); + } }