Skip to content

Commit c72c323

Browse files
committed
Integrated into the FrameworkBundle
1 parent 43881e7 commit c72c323

File tree

4 files changed

+261
-1
lines changed

4 files changed

+261
-1
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use Symfony\Component\Messenger\MessageBusInterface;
3131
use Symfony\Component\Notifier\Notifier;
3232
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
33+
use Symfony\Component\RateLimiter\TokenBucketLimiter;
3334
use Symfony\Component\Serializer\Serializer;
3435
use Symfony\Component\Translation\Translator;
3536
use Symfony\Component\Validator\Validation;
@@ -133,6 +134,7 @@ public function getConfigTreeBuilder()
133134
$this->addMailerSection($rootNode);
134135
$this->addSecretsSection($rootNode);
135136
$this->addNotifierSection($rootNode);
137+
$this->addRateLimiterSection($rootNode);
136138

137139
return $treeBuilder;
138140
}
@@ -1629,4 +1631,67 @@ private function addNotifierSection(ArrayNodeDefinition $rootNode)
16291631
->end()
16301632
;
16311633
}
1634+
1635+
private function addRateLimiterSection(ArrayNodeDefinition $rootNode)
1636+
{
1637+
$rootNode
1638+
->children()
1639+
->arrayNode('rate_limiter')
1640+
->info('Rate limiter configuration')
1641+
->{!class_exists(FullStack::class) && class_exists(TokenBucketLimiter::class) ? 'canBeDisabled' : 'canBeEnabled'}()
1642+
->fixXmlConfig('limiter')
1643+
->beforeNormalization()
1644+
->ifTrue(function ($v) { return \is_array($v) && !isset($v['limiters']) && !isset($v['limiter']); })
1645+
->then(function (array $v) { return ['limiters' => $v]; })
1646+
->end()
1647+
->children()
1648+
->arrayNode('limiters')
1649+
->useAttributeAsKey('name')
1650+
->arrayPrototype()
1651+
->children()
1652+
->scalarNode('lock')->defaultNull()->end()
1653+
->scalarNode('storage')->isRequired()->end()
1654+
->arrayNode('bucket')
1655+
->children()
1656+
->integerNode('max_burst')->isRequired()->end()
1657+
->arrayNode('rate')
1658+
->children()
1659+
->scalarNode('interval')
1660+
->isRequired()
1661+
->cannotBeEmpty()
1662+
->validate()
1663+
->ifTrue(function ($v) {
1664+
return !preg_match('/P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?/', $v);
1665+
})
1666+
->thenInvalid('"interval" must be a valid DateInterval spec: P1DT2H3M4S')
1667+
->end()
1668+
->end()
1669+
->integerNode('amount')->defaultValue(1)->end()
1670+
->end()
1671+
->end()
1672+
->end()
1673+
->end()
1674+
->arrayNode('window')
1675+
->children()
1676+
->integerNode('limit')->isRequired()->end()
1677+
->scalarNode('interval')
1678+
->isRequired()
1679+
->cannotBeEmpty()
1680+
->validate()
1681+
->ifTrue(function ($v) {
1682+
return !preg_match('/P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?/', $v);
1683+
})
1684+
->thenInvalid('"interval" must be a valid DateInterval spec: P1DT2H3M4S')
1685+
->end()
1686+
->end()
1687+
->end()
1688+
->end()
1689+
->end()
1690+
->end()
1691+
->end()
1692+
->end()
1693+
->end()
1694+
->end()
1695+
;
1696+
}
16321697
}

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@
115115
use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface;
116116
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
117117
use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface;
118+
use Symfony\Component\RateLimiter\LimiterFactory;
119+
use Symfony\Component\RateLimiter\Rate;
120+
use Symfony\Component\RateLimiter\TokenBucketLimiter;
121+
use Symfony\Component\RateLimiter\Storage\CacheStorage;
122+
use Symfony\Component\RateLimiter\TokenBucket;
118123
use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader;
119124
use Symfony\Component\Routing\Loader\AnnotationFileLoader;
120125
use Symfony\Component\Security\Core\Security;
@@ -162,6 +167,7 @@ class FrameworkExtension extends Extension
162167
private $messengerConfigEnabled = false;
163168
private $mailerConfigEnabled = false;
164169
private $httpClientConfigEnabled = false;
170+
private $lockConfigEnabled = false;
165171

166172
/**
167173
* Responds to the app.config configuration parameter.
@@ -394,10 +400,18 @@ public function load(array $configs, ContainerBuilder $container)
394400
$this->registerPropertyInfoConfiguration($container, $loader);
395401
}
396402

397-
if ($this->isConfigEnabled($container, $config['lock'])) {
403+
if ($this->lockConfigEnabled = $this->isConfigEnabled($container, $config['lock'])) {
398404
$this->registerLockConfiguration($config['lock'], $container, $loader);
399405
}
400406

407+
if ($this->isConfigEnabled($container, $config['rate_limiter'])) {
408+
if (!class_exists(TokenBucketLimiter::class)) {
409+
throw new LogicException('Rate limiter support cannot be enabled as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".');
410+
}
411+
412+
$this->registerRateLimiterConfiguration($config['rate_limiter'], $container, $loader);
413+
}
414+
401415
if ($this->isConfigEnabled($container, $config['web_link'])) {
402416
if (!class_exists(HttpHeaderSerializer::class)) {
403417
throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed. Try running "composer require symfony/weblink".');
@@ -2097,6 +2111,47 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $
20972111
}
20982112
}
20992113

2114+
private function registerRateLimiterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader)
2115+
{
2116+
if (!$this->lockConfigEnabled) {
2117+
throw new LogicException('Rate limiter support cannot be enabled without enabling the Lock component.');
2118+
}
2119+
2120+
$loader->load('rate_limiter.php');
2121+
2122+
$locks = [];
2123+
$storages = [];
2124+
foreach ($config['limiters'] as $name => $limiterConfig) {
2125+
$limiterFactory = $container->setDefinition($id = 'limiter.'.$name.'_factory', new ChildDefinition('limiter.factory'));
2126+
2127+
if (!isset($locks[$limiterConfig['lock']])) {
2128+
$locks[$limiterConfig['lock']] = new Reference($limiterConfig['lock']);
2129+
}
2130+
$limiterFactory->addArgument($locks[$limiterConfig['lock']]);
2131+
unset($limiterConfig['lock']);
2132+
2133+
if (!isset($storages[$limiterConfig['storage']])) {
2134+
$storageId = $limiterConfig['storage'];
2135+
if ($container->has($storageId)) {
2136+
// cache pools are configured by the FrameworkBundle, so they
2137+
// exists in the scoped ContainerBuilder provided to this method
2138+
if ($container->findDefinition($storageId)->hasTag('cache.pool')) {
2139+
$container->register('limiter.storage.'.$storageId, CacheStorage::class)->addArgument(new Reference($storageId));
2140+
$storageId = 'limiter.storage.'.$storageId;
2141+
}
2142+
}
2143+
2144+
$storages[$limiterConfig['storage']] = new Reference($storageId);
2145+
}
2146+
$limiterFactory->replaceArgument(1, $storages[$limiterConfig['storage']]);
2147+
unset($limiterConfig['storage']);
2148+
2149+
$limiterConfig['default_id'] = $name;
2150+
$limiterFactory->replaceArgument(0, $limiterConfig);
2151+
$container->registerAliasForArgument($id, LimiterFactory::class, $name.'_limiter_factory');
2152+
}
2153+
}
2154+
21002155
private function resolveTrustedHeaders(array $headers): int
21012156
{
21022157
$trustedHeaders = 0;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
13+
14+
use Symfony\Component\RateLimiter\LimiterFactory;
15+
use Symfony\Component\DependencyInjection\ServiceLocator;
16+
17+
return static function (ContainerConfigurator $container) {
18+
$container->services()
19+
->set('limiter.factory', LimiterFactory::class)
20+
->abstract()
21+
->args([
22+
abstract_arg('config'),
23+
abstract_arg('storage'),
24+
])
25+
;
26+
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\RateLimiter;
13+
14+
use Symfony\Component\Lock\LockFactory;
15+
use Symfony\Component\Lock\NoLock;
16+
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
17+
use Symfony\Component\OptionsResolver\Options;
18+
use Symfony\Component\OptionsResolver\OptionsResolver;
19+
use Symfony\Component\RateLimiter\Storage\StorageInterface;
20+
use function foo\func;
21+
22+
/**
23+
* @author Wouter de Jong <wouter@wouterj.nl>
24+
*
25+
* @final
26+
*/
27+
class LimiterFactory
28+
{
29+
private $config;
30+
private $storage;
31+
private $lockFactory;
32+
33+
public function __construct(array $config, StorageInterface $storage, ?LockFactory $lockFactory)
34+
{
35+
$this->storage = $storage;
36+
$this->lockFactory = $lockFactory;
37+
38+
$options = new OptionsResolver();
39+
self::configureOptions($options);
40+
41+
$this->config = array_filter($options->resolve($config), function ($v) { return $v !== []; });
42+
43+
if (count($this->config) < 2) {
44+
throw new InvalidOptionsException('No limiter algorithm configured, it is required to set either "window" or "bucket" config options.');
45+
} elseif (count($this->config) > 2) {
46+
// indicates that more than one algorithm is configured
47+
unset($this->config['default_id']);
48+
49+
throw new InvalidOptionsException(sprintf('Cannot configure more than one limiter algorithm, but configuration is passed for: "%s".', implode('", "', array_keys($this->config))));
50+
}
51+
}
52+
53+
public function createLimiter(?string $id = null): LimiterInterface
54+
{
55+
$id = $id ?? $this->config['default_id'];
56+
$lock = $this->lockFactory ? $this->lockFactory->createLock($id) : new NoLock();
57+
58+
if (isset($this->config['bucket'])) {
59+
return new TokenBucketLimiter(new TokenBucket($id, $this->config['bucket']['max_burst'], $this->config['bucket']['rate']), $this->storage, $lock);
60+
}
61+
62+
// @todo
63+
//return new SlidingWindowLimiter(new Window($id, $this->config['window']));
64+
}
65+
66+
protected static function configureOptions(OptionsResolver $options): void
67+
{
68+
$options
69+
->define('default_id')->required()
70+
->define('bucket')
71+
->default(function (OptionsResolver $bucket) {
72+
$bucket
73+
->define('max_burst')->allowedTypes('int')
74+
->define('rate')
75+
->default(function (OptionsResolver $rate) {
76+
$rate
77+
->define('amount')->allowedTypes('int')
78+
->define('interval')->allowedValues(function ($interval) {
79+
return preg_match('/P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?/', $interval);
80+
})
81+
;
82+
})
83+
->normalize(function (Options $options, $value) {
84+
return new Rate(new \DateInterval($value['interval']), $value['amount']);
85+
})
86+
;
87+
})
88+
->allowedValues(function ($bucketValue) {
89+
if ([] === $bucketValue || (isset($bucketValue['max_burst']) && isset($bucketValue['rate']))) {
90+
return true;
91+
}
92+
93+
throw new InvalidOptionsException('The bucket[max_burst] and bucket[rate] options must be configured.');
94+
})
95+
96+
->define('window')
97+
->default(function (OptionsResolver $window) {
98+
$window
99+
->define('limit')->allowedTypes('int')
100+
->define('interval')->allowedValues(function ($interval) {
101+
return preg_match('/P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?/', $interval);
102+
})
103+
;
104+
})
105+
->allowedValues(function ($windowValue) {
106+
if ([] === $windowValue || (isset($windowValue['limit']) && isset($windowValue['sampling_period']))) {
107+
return true;
108+
}
109+
110+
throw new InvalidOptionsException('The window[limit] and window[interval] options must be configured.');
111+
})
112+
;
113+
}
114+
}

0 commit comments

Comments
 (0)