8000 Integrated into the FrameworkBundle · symfony/symfony@8653296 · GitHub
[go: up one dir, main page]

Skip to content

Commit 8653296

Browse files
committed
Integrated into the FrameworkBundle
1 parent f85a396 commit 8653296

File tree

8 files changed

+318
-2
lines changed

8 files changed

+318
-2
lines changed

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

Lines changed: 39 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,41 @@ 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) { return ['limiters' => $v]; })
1724+
->end()
1725+
->children()
1726+
->arrayNode('limiters')
1727+
->useAttributeAsKey('name')
1728+
->arrayPrototype()
1729+
->children()
1730+
->scalarNode('lock')->defaultNull()->end()
1731+
->scalarNode('storage')->isRequired()->end()
1732+
->scalarNode('strategy')->isRequired()->end()
1733+
->integerNode('limit')->isRequired()->end()
1734+
->scalarNode('interval')->end()
1735+
->arrayNode('rate')
1736+
->children()
1737+
->scalarNode('interval')->isRequired()->end()
1738+
->integerNode('amount')->defaultValue(1)->end()
1739+
->end()
1740+
->end()
1741+
->end()
1742+
->end()
1743+
->end()
1744+
->end()
1745+
->end()
1746+
->end()
1747+
;
1748+
}
17101749
}

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

Lines changed: 59 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\LimiterFactory;
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,52 @@ 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+
$limiterFactory = $container->setDefinition($factoryId = 'limiter.'.$name.'_factory', new ChildDefinition('limiter.factory'));
2197+
2198+
if (!isset($locks[$limiterConfig['lock']])) {
2199+
$locks[$limiterConfig['lock']] = new Reference($limiterConfig['lock']);
2200+
}
2201+
$limiterFactory->addArgument($locks[$limiterConfig['lock']]);
2202+
unset($limiterConfig['lock']);
2203+
2204+
if (!isset($storages[$limiterConfig['storage']])) {
2205+
$storageId = $limiterConfig['storage'];
2206+
if ($container->has($storageId)) {
2207+
// cache pools are configured by the FrameworkBundle, so they
2208+
// exists in the scoped ContainerBuilder provided to this method
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+
$limiterFactory->replaceArgument(1, $storages[$limiterConfig['storage']]);
2218+
unset($limiterConfig['storage']);
2219+
2220+
$limiterConfig['id'] = $name;
2221+
$limiterFactory->replaceArgument(0, $limiterConfig);
2222+
2223+
$container->register($limiterId = 'limiter.'.$name, LimiterInterface::class)
2224+
->setFactory([new Reference($factoryId), 'createLimiter']);
2225+
2226+
$container->registerAliasForArgument($factoryId, LimiterFactory::class, $name.'.limiter_factory');
2227+
$container->registerAliasForArgument($limiterId, LimiterInterface::class, $name.'.limiter');
2228+
}
2229+
}
2230+
21732231
private function resolveTrustedHeaders(array $headers): int
21742232
{
21752233
$trustedHeaders = 0;
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 <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+
16+
return static function (ContainerConfigurator $container) {
17+
$container->services()
18+
->set('limiter.factory', LimiterFactory::class)
19+
->abstract()
20+
->args([
21+
abstract_arg('config'),
22+
abstract_arg('storage'),
23+
])
24+
;
25+
};

src/Symfony/Bundle/FrameworkBundle/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>

src/Symfony/Bundle/FrameworkBundle/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
}

src/Symfony/Component/Lock/LockFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* @author Jérémy Derussé <jeremy@derusse.com>
2222
* @author Hamza Amrouche <hamza.simperfit@gmail.com>
2323
*/
24-
class kLockFactory implements LoggerAwareInterface
24+
class LockFactory implements LoggerAwareInterface
2525
{
2626
use LoggerAwareTrait;
2727

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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\Options;
17+
use Symfony\Component\OptionsResolver\OptionsResolver;
18+
use Symfony\Component\RateLimiter\Storage\StorageInterface;
19+
20+
/**
21+
* @author Wouter de Jong <wouter@wouterj.nl>
22+
*
23+
* @final
24+
*/
25+
class LimiterFactory
26+
{
27+
private $config;
28+
private $storage;
29+
private $lockFactory;
30+
31+
public function __construct(array $config, StorageInterface $storage, ?LockFactory $lockFactory)
32+
{
33+
$this->storage = $storage;
34+
$this->lockFactory = $lockFactory;
35+
36+
$options = new OptionsResolver();
37+
self::configureOptions($options);
38+
39+
$this->config = $options->resolve($config);
40+
}
41+
42+
public function createLimiter(?string $key = null): LimiterInterface
43+
{
44+
$id = $this->config['id'].$key;
45+
$lock = $this->lockFactory ? $this->lockFactory->createLock($id) : new NoLock();
46+
47+
switch ($this->config['strategy']) {
48+
case 'token_bucket':
49+
return new TokenBucketLimiter($id, $this->config['limit'], $this->config['rate'], $this->storage, $lock);
50+
51+
case 'fixed_window':
52+
return new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock);
53+
54+
default:
55+
throw new \LogicException(sprintf('Limiter strategy "%s" does not exists, it must be either "token_bucket" or "fixed_window".', $this->config['strategy']));
56+
}
57+
}
58+
59+
protected static function configureOptions(OptionsResolver $options): void
60+
{
61+
$intervalNormalizer = static function (Options $options, string $interval): \DateInterval {
62+
try {
63+
return (new \DateTimeImmutable())->diff(new \DateTimeImmutable('+'.$interval));
64+
} catch (\Exception $e) {
65+
if (!preg_match('/Failed to parse time string \(\+([^)]+)\)/', $e->getMessage(), $m)) {
66+
throw $e;
67+
}
68+
69+
throw new \LogicException(sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $m[1]));
70+
}
71+
};
72+
73+
$options
74+
->define('id')->required()
75+
->define('strategy')
76+
->required()
77+
->allowedValues('token_bucket', 'fixed_window')
78+
79+
->define('limit')->allowedTypes('int')
80+
->define('interval')->allowedTypes('string')->normalize($intervalNormalizer)
81+
->define('rate')
82+
->default(function (OptionsResolver $rate) use ($intervalNormalizer) {
83+
$rate
84+
->define('amount')->allowedTypes('int')->default(1)
85+
->define('interval')->allowedTypes('string')->normalize($intervalNormalizer)
86+
;
87+
})
88+
->normalize(function (Options $options, $value) {
89+
if (!isset($value['interval'])) {
90+
return null;
91+
}
92+
93+
return new Rate($value['interval'], $value['amount']);
94+
})
95+
;
96+
}
97+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Lock\LockFactory;
16+
use Symfony\Component\RateLimiter\FixedWindowLimiter;
17+
use Symfony\Component\RateLimiter\LimiterFactory;
18+
use Symfony\Component\RateLimiter\Storage\StorageInterface;
19+
use Symfony\Component\RateLimiter\TokenBucketLimiter;
20+
21+
class LimiterFactoryTest extends TestCase
22+
{
23+
public function testTokenBucket()
24+
{
25+
$factory = $this->createFactory([
26+
'id' => 'test',
27+
'strategy' => 'token_bucket',
28+
'limit' => 10,
29+
'rate' => ['interval' => '1 second'],
30+
]);
31+
$limiter = $factory->createLimiter('127.0.0.1');
32+
33+
$this->assertInstanceOf(TokenBucketLimiter::class, $limiter);
34+
}
35+
36+
public function testFixedWindow()
37+
{
38+
$factory = $this->createFactory([
39+
'id' => 'test',
40+
'strategy' => 'fixed_window',
41+
'limit' => 10,
42+
'interval' => '1 minute',
43+
]);
44+
$limiter = $factory->createLimiter();
45+
46+
$this->assertInstanceOf(FixedWindowLimiter::class, $limiter);
47+
}
48+
49+
public function testWrongInterval()
50+
{
51+
$this->expectException(\LogicException::class);
52+
$this->expectExceptionMessage('Cannot parse interval "1 minut", please use a valid unit as described on https://www.php.net/datetime.formats.relative.');
53+
54+
$this->createFactory([
55+
'id' => 'test',
56+
'strategy' => 'fixed_window',
57+
'limit' => 10,
58+
'interval' => '1 minut',
59+
]);
60+
}
61+
62+
private function createFactory(array $options)
63+
{
64+
return new LimiterFactory($options, $this->createMock(StorageInterface::class), $this->createMock(LockFactory::class));
65+
}
66+
}

0 commit comments

Comments
 (0)
0