Skip to content

Commit 04a475a

Browse files
wouterjfabpot
authored andcommitted
[RFC] Introduce a RateLimiter component
1 parent 2b6f835 commit 04a475a

File tree

5 files changed

+159
-1
lines changed

5 files changed

+159
-1
lines changed

DependencyInjection/Configuration.php

Lines changed: 48 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;
@@ -134,6 +135,7 @@ public function getConfigTreeBuilder()
134135
$this->addMailerSection($rootNode);
135136
$this->addSecretsSection($rootNode);
136137
$this->addNotifierSection($rootNode);
138+
$this->addRateLimiterSection($rootNode);
137139

138140
return $treeBuilder;
139141
}
@@ -1707,4 +1709,50 @@ private function addNotifierSection(ArrayNodeDefinition $rootNode)
17071709
->end()
17081710
;
17091711
}
1712+
1713+
private function addRateLimiterSection(ArrayNodeDefinition $rootNode)
1714+
{
1715+
$rootNode
1716+
->children()
1717+
->arrayNode('rate_limiter')
1718+
->info('Rate limiter configuration')
1719+
->{!class_exists(FullStack::class) && class_exists(TokenBucketLimiter::class) ? 'canBeDisabled' : 'canBeEnabled'}()
1720+
->fixXmlConfig('limiter')
1721+
->beforeNormalization()
1722+
->ifTrue(function ($v) { return \is_array($v) && !isset($v['limiters']) && !isset($v['limiter']); })
1723+
->then(function (array $v) {
1724+
$newV = [
1725+
'enabled' => $v['enabled'],
1726+
];
1727+
unset($v['enabled']);
1728+
1729+
$newV['limiters'] = $v;
1730+
1731+
return $newV;
1732+
})
1733+
->end()
1734+
->children()
1735+
->arrayNode('limiters')
1736+
->useAttributeAsKey('name')
1737+
->arrayPrototype()
1738+
->children()
1739+
->scalarNode('lock')->defaultValue('lock.factory')->end()
1740+
->scalarNode('storage')->defaultValue('cache.app')->end()
1741+
->scalarNode('strategy')->isRequired()->end()
1742+
->integerNode('limit')->isRequired()->end()
1743+
->scalarNode('interval')->end()
1744+
->arrayNode('rate')
1745+
->children()
1746+
->scalarNode('interval')->isRequired()->end()
1747+
->integerNode('amount')->defaultValue(1)->end()
1748+
->end()
1749+
->end()
1750+
->end()
1751+
->end()
1752+
->end()
1753+
->end()
1754+
->end()
1755+
->end()
1756+
;
1757+
}
17101758
}

DependencyInjection/FrameworkExtension.php

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@
123123
use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface;
124124
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
125125
use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface;
126+
use Symfony\Component\RateLimiter\Limiter;
127+
use Symfony\Component\RateLimiter\LimiterInterface;
128+
use Symfony\Component\RateLimiter\Storage\CacheStorage;
126129
use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader;
127130
use Symfony\Component\Routing\Loader\AnnotationFileLoader;
128131
use Symfony\Component\Security\Core\Security;
@@ -173,6 +176,7 @@ class FrameworkExtension extends Extension
173176
private $mailerConfigEnabled = false;
174177
private $httpClientConfigEnabled = false;
175178
private $notifierConfigEnabled = false;
179+
private $lockConfigEnabled = false;
176180

177181
/**
178182
* Responds to the app.config configuration parameter.
@@ -405,10 +409,18 @@ public function load(array $configs, ContainerBuilder $container)
405409
$this->registerPropertyInfoConfiguration($container, $loader);
406410
}
407411

408-
if ($this->isConfigEnabled($container, $config['lock'])) {
412+
if ($this->lockConfigEnabled = $this->isConfigEnabled($container, $config['lock'])) {
409413
$this->registerLockConfiguration($config['lock'], $container, $loader);
410414
}
411415

416+
if ($this->isConfigEnabled($container, $config['rate_limiter'])) {
417+
if (!interface_exists(LimiterInterface::class)) {
418+
throw new LogicException('Rate limiter support cannot be enabled as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".');
419+
}
420+
421+
$this->registerRateLimiterConfiguration($config['rate_limiter'], $container, $loader);
422+
}
423+
412424
if ($this->isConfigEnabled($container, $config['web_link'])) {
413425
if (!class_exists(HttpHeaderSerializer::class)) {
414426
throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed. Try running "composer require symfony/weblink".');
@@ -2170,6 +2182,48 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $
21702182
}
21712183
}
21722184

2185+
private function registerRateLimiterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader)
2186+
{
2187+
if (!$this->lockConfigEnabled) {
2188+
throw new LogicException('Rate limiter support cannot be enabled without enabling the Lock component.');
2189+
}
2190+
2191+
$loader->load('rate_limiter.php');
2192+
2193+
$locks = [];
2194+
$storages = [];
2195+
foreach ($config['limiters'] as $name => $limiterConfig) {
2196+
$limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter'));
2197+
2198+
if (!isset($locks[$limiterConfig['lock']])) {
2199+
$locks[$limiterConfig['lock']] = new Reference($limiterConfig['lock']);
2200+
}
2201+
$limiter->addArgument($locks[$limiterConfig['lock']]);
2202+
unset($limiterConfig['lock']);
2203+
2204+
if (!isset($storages[$limiterConfig['storage']])) {
2205+
$storageId = $limiterConfig['storage'];
2206+
// cache pools are configured by the FrameworkBundle, so they
2207+
// exists in the scoped ContainerBuilder provided to this method
2208+
if ($container->has($storageId)) {
2209+
if ($container->findDefinition($storageId)->hasTag('cache.pool')) {
2210+
$container->register('limiter.storage.'.$storageId, CacheStorage::class)->addArgument(new Reference($storageId));
2211+
$storageId = 'limiter.storage.'.$storageId;
2212+
}
2213+
}
2214+
2215+
$storages[$limiterConfig['storage']] = new Reference($storageId);
2216+
}
2217+
$limiter->replaceArgument(1, $storages[$limiterConfig['storage']]);
2218+
unset($limiterConfig['storage']);
2219+
2220+
$limiterConfig['id'] = $name;
2221+
$limiter->replaceArgument(0, $limiterConfig);
2222+
2223+
$container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter');
2224+
}
2225+
}
2226+
21732227
private function resolveTrustedHeaders(array $headers): int
21742228
{
21752229
$trustedHeaders = 0;

Resources/config/rate_limiter.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Limiter;
15+
16+
return static function (ContainerConfigurator $container) {
17+
$container->services()
18+
->set('limiter', Limiter::class)
19+
->abstract()
20+
->args([
21+
abstract_arg('config'),
22+
abstract_arg('storage'),
23+
])
24+
;
25+
};

Resources/config/schema/symfony-1.0.xsd

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
<xsd:element name="http-client" type="http_client" minOccurs="0" maxOccurs="1" />
3535
<xsd:element name="mailer" type="mailer" minOccurs="0" maxOccurs="1" />
3636
<xsd:element name="http-cache" type="http_cache" minOccurs="0" maxOccurs="1" />
37+
<xsd:element name="rate-limiter" type="rate_limiter" minOccurs="0" maxOccurs="1" />
3738
</xsd:choice>
3839

3940
<xsd:attribute name="http-method-override" type="xsd:boolean" />
@@ -634,4 +635,30 @@
634635
<xsd:enumeration value="full" />
635636
</xsd:restriction>
636637
</xsd:simpleType>
638+
639+
<xsd:complexType name="rate_limiter">
640+
<xsd:sequence>
641+
<xsd:element name="limiter" type="rate_limiter_limiter" minOccurs="0" maxOccurs="unbounded" />
642+
</xsd:sequence>
643+
<xsd:attribute name="enabled" type="xsd:boolean" />
644+
<xsd:attribute name="max-host-connections" type="xsd:integer" />
645+
<xsd:attribute name="mock-response-factory" type="xsd:string" />
646+
</xsd:complexType>
647+
648+
<xsd:complexType name="rate_limiter_limiter">
649+
<xsd:sequence>
650+
<xsd:element name="rate" type="rate_limiter_rate" minOccurs="0" />
651+
</xsd:sequence>
652+
<xsd:attribute name="name" type="xsd:string" />
653+
<xsd:attribute name="lock" type="xsd:string" />
654+
<xsd:attribute name="storage" type="xsd:string" />
655+
<xsd:attribute name="strategy" type="xsd:string" />
656+
<xsd:attribute name="limit" type="xsd:int" />
657+
<xsd:attribute name="interval" type="xsd:string" />
658+
</xsd:complexType>
659+
660+
<xsd:complexType name="rate_limiter_rate">
661+
<xsd:attribute name="interval" type="xsd:string" />
662+
<xsd:attribute name="amount" type="xsd:int" />
663+
</xsd:complexType>
637664
</xsd:schema>

Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,10 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor
531531
'debug' => '%kernel.debug%',
532532
'private_headers' => [],
533533
],
534+
'rate_limiter' => [
535+
'enabled' => false,
536+
'limiters' => [],
537+
],
534538
];
535539
}
536540
}

0 commit comments

Comments
 (0)