From b791d9210ee96d9627d47b2130a5bd17302dd489 Mon Sep 17 00:00:00 2001 From: Kevin Bond <kevinbond@gmail.com> Date: Sat, 29 Mar 2025 14:10:51 -0400 Subject: [PATCH 1/6] [RateLimiter] Add `RateLimiterBuilder` --- .../RateLimiter/RateLimiterBuilder.php | 102 ++++++++++++++++++ .../RateLimiter/RateLimiterFactory.php | 16 +-- 2 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 src/Symfony/Component/RateLimiter/RateLimiterBuilder.php diff --git a/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php new file mode 100644 index 0000000000000..fef16d0ce00e9 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php @@ -0,0 +1,102 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\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; + +/** + * @author Kevin Bond <kevinbond@gmail.com> + */ +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, self::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, self::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(); + } + + /** + * @internal + */ + 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); + } +} diff --git a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php index f9d0ca9a7386e..89026f01cca06 100644 --- a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php +++ b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php @@ -56,21 +56,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 RateLimiterBuilder::intervalNormalizer($interval); }; $options From 9fa23d52f7e088edb97f7de429fc5185d1d00fd6 Mon Sep 17 00:00:00 2001 From: Kevin Bond <kevinbond@gmail.com> Date: Wed, 2 Apr 2025 17:31:26 -0400 Subject: [PATCH 2/6] frameworkbundle configuration/tests --- .../DependencyInjection/Configuration.php | 18 +++++++++ .../FrameworkExtension.php | 33 ++++++++++++++++ .../Resources/config/rate_limiter.php | 8 ++++ .../PhpFrameworkExtensionTest.php | 39 +++++++++++++++++++ 4 files changed, 98 insertions(+) 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/PhpFrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php index ea8d481e0f0f0..269dcfeba0adc 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() + { + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + '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() + { + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'lock' => true, + 'rate_limiter' => true, + ]); + }); + + $builder = $container->getDefinition('limiter_builder'); + $this->assertSame('lock.factory', (string) $builder->getArgument(1)); + } } From 5f3bce58cb77f7f6016e792ae60651a70e9d9dda Mon Sep 17 00:00:00 2001 From: Kevin Bond <kevinbond@gmail.com> Date: Wed, 2 Apr 2025 17:34:02 -0400 Subject: [PATCH 3/6] changelog --- src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md | 1 + src/Symfony/Component/RateLimiter/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) 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/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 --- From 224dbab91463208faab4e64d787c0f361b91113a Mon Sep 17 00:00:00 2001 From: Kevin Bond <kevinbond@gmail.com> Date: Wed, 2 Apr 2025 19:42:32 -0400 Subject: [PATCH 4/6] test --- .../Tests/RateLimiterBuilderTest.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/Symfony/Component/RateLimiter/Tests/RateLimiterBuilderTest.php 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 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\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()); + } +} From 84d055ec9f684caf140a30636de01983db02290f Mon Sep 17 00:00:00 2001 From: Kevin Bond <kevinbond@gmail.com> Date: Fri, 4 Apr 2025 12:12:58 -0400 Subject: [PATCH 5/6] fix tests --- .../Tests/DependencyInjection/ConfigurationTest.php | 5 +++++ .../DependencyInjection/PhpFrameworkExtensionTest.php | 8 ++++++++ 2 files changed, 13 insertions(+) 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 269dcfeba0adc..b2ab5ae6caf53 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php @@ -294,6 +294,10 @@ public function testRateLimiterIsTagged() public function testRateLimiterBuilderDefault() { + if (!class_exists(RateLimiterBuilder::class)) { + $this->markTestSkipped('RateLimiterBuilder is not available.'); + } + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { $container->loadFromExtension('framework', [ 'annotations' => false, @@ -315,6 +319,10 @@ public function testRateLimiterBuilderDefault() public function testRateLimiterBuilderDefaultWithLock() { + if (!class_exists(RateLimiterBuilder::class)) { + $this->markTestSkipped('RateLimiterBuilder is not available.'); + } + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { $container->loadFromExtension('framework', [ 'annotations' => false, From eae6a0d205b5a43294e5aac94dd7d7bbdbb21d13 Mon Sep 17 00:00:00 2001 From: Kevin Bond <kevinbond@gmail.com> Date: Mon, 7 Apr 2025 10:05:37 -0400 Subject: [PATCH 6/6] review --- .../PhpFrameworkExtensionTest.php | 8 ----- .../RateLimiter/RateLimiterBuilder.php | 31 ++----------------- .../RateLimiter/RateLimiterFactory.php | 3 +- .../Component/RateLimiter/Util/TimeUtil.php | 23 ++++++++++++++ 4 files changed, 28 insertions(+), 37 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php index b2ab5ae6caf53..5b5c63ca6c003 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php @@ -300,10 +300,6 @@ public function testRateLimiterBuilderDefault() $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { $container->loadFromExtension('framework', [ - 'annotations' => false, - 'http_method_override' => false, - 'handle_all_throwables' => true, - 'php_errors' => ['log' => true], 'rate_limiter' => true, ]); }); @@ -325,10 +321,6 @@ public function testRateLimiterBuilderDefaultWithLock() $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { $container->loadFromExtension('framework', [ - 'annotations' => false, - 'http_method_override' => false, - 'handle_all_throwables' => true, - 'php_errors' => ['log' => true], 'lock' => true, 'rate_limiter' => true, ]); diff --git a/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php index fef16d0ce00e9..6cbaf48883553 100644 --- a/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php +++ b/src/Symfony/Component/RateLimiter/RateLimiterBuilder.php @@ -18,6 +18,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 Kevin Bond <kevinbond@gmail.com> @@ -39,7 +40,7 @@ public function __construct( */ public function slidingWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface { - return new SlidingWindowLimiter($id, $limit, self::intervalNormalizer($interval), $this->storage, $this->lock); + return new SlidingWindowLimiter($id, $limit, TimeUtil::intervalNormalizer($interval), $this->storage, $this->lock); } /** @@ -51,7 +52,7 @@ public function slidingWindow(string $id, int $limit, \DateInterval|string $inte */ public function fixedWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface { - return new FixedWindowLimiter($id, $limit, self::intervalNormalizer($interval), $this->storage, $this->lock); + return new FixedWindowLimiter($id, $limit, TimeUtil::intervalNormalizer($interval), $this->storage, $this->lock); } /** @@ -73,30 +74,4 @@ public function noop(): LimiterInterface { return new NoLimiter(); } - - /** - * @internal - */ - 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); - } } diff --git a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php index 89026f01cca06..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 <wouter@wouterj.nl> @@ -56,7 +57,7 @@ public function create(?string $key = null): LimiterInterface private static function configureOptions(OptionsResolver $options): void { $intervalNormalizer = static function (Options $options, string $interval): \DateInterval { - return RateLimiterBuilder::intervalNormalizer($interval); + return TimeUtil::intervalNormalizer($interval); }; $options 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); + } }