Skip to content

Commit b09f4b0

Browse files
committed
Integrated into the FrameworkBundle
1 parent 2930517 commit b09f4b0

File tree

5 files changed

+261
-1
lines changed

5 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
}
@@ -1641,4 +1643,67 @@ private function addNotifierSection(ArrayNodeDefinition $rootNode)
16411643
->end()
16421644
;
16431645
}
1646+
1647+
private function addRateLimiterSection(ArrayNodeDefinition $rootNode)
1648+
{
1649+
$rootNode
1650+
->children()
1651+
->arrayNode('rate_limiter')
1652+
->info('Rate limiter configuration')
1653+
->{!class_exists(FullStack::class) && class_exists(TokenBucketLimiter::class) ? 'canBeDisabled' : 'canBeEnabled'}()
1654+
->fixXmlConfig('limiter')
1655+
->beforeNormalization()
1656+
->ifTrue(function ($v) { return \is_array($v) && !isset($v['limiters']) && !isset($v['limiter']); })
1657+
->then(function (array $v) { return ['limiters' => $v]; })
1658+
->end()
1659+
->children()
1660+
->arrayNode('limiters')
1661+
->useAttributeAsKey('name')
1662+
->arrayPrototype()
1663+
->children()
1664+
->scalarNode('lock')->defaultNull()->end()
1665+
->scalarNode('storage')->isRequired()->end()
1666+
->arrayNode('bucket')
1667+
->children()
1668+
->integerNode('max_burst')->isRequired()->end()
1669+
->arrayNode('rate')
1670+
->children()
1671+
->scalarNode('interval')
1672+
->isRequired()
1673+
->cannotBeEmpty()
1674+
->validate()
1675+
->ifTrue(function ($v) {
1676+
return !preg_match('/P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?/', $v);
1677+
})
1678+
->thenInvalid('"interval" must be a valid DateInterval spec: P1DT2H3M4S')
1679+
->end()
1680+
->end()
1681+
->integerNode('amount')->defaultValue(1)->end()
1682+
->end()
1683+
->end()
1684+
->end()
1685+
->end()
1686+
->arrayNode('window')
1687+
->children()
1688+
->integerNode('limit')->isRequired()->end()
1689+
->scalarNode('interval')
1690+
->isRequired()
1691+
->cannotBeEmpty()
1692+
->validate()
1693+
->ifTrue(function ($v) {
1694+
return !preg_match('/P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?/', $v);
1695+
})
1696+
->thenInvalid('"interval" must be a valid DateInterval spec: P1DT2H3M4S')
1697+
->end()
1698+
->end()
1699+
->end()
1700+
->end()
1701+
->end()
1702+
->end()
1703+
->end()
1704+
->end()
1705+
->end()
1706+
->end()
1707+
;
1708+
}
16441709
}

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@
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\Storage\CacheStorage;
120+
use Symfony\Component\RateLimiter\TokenBucketLimiter;
118121
use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader;
119122
use Symfony\Component\Routing\Loader\AnnotationFileLoader;
120123
use Symfony\Component\Security\Core\Security;
@@ -162,6 +165,7 @@ class FrameworkExtension extends Extension
162165
private $messengerConfigEnabled = false;
163166
private $mailerConfigEnabled = false;
164167
private $httpClientConfigEnabled = false;
168+
private $lockConfigEnabled = false;
165169

166170
/**
167171
* Responds to the app.config configuration parameter.
@@ -394,10 +398,18 @@ public function load(array $configs, ContainerBuilder $container)
394398
$this->registerPropertyInfoConfiguration($container, $loader);
395399
}
396400

397-
if ($this->isConfigEnabled($container, $config['lock'])) {
401+
if ($this->lockConfigEnabled = $this->isConfigEnabled($container, $config['lock'])) {
398402
$this->registerLockConfiguration($config['lock'], $container, $loader);
399403
}
400404

405+
if ($this->isConfigEnabled($container, $config['rate_limiter'])) {
406+
if (!class_exists(TokenBucketLimiter::class)) {
407+
throw new LogicException('Rate limiter support cannot be enabled as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".');
408+
}
409+
410+
$this->registerRateLimiterConfiguration($config['rate_limiter'], $container, $loader);
411+
}
412+
401413
if ($this->isConfigEnabled($container, $config['web_link'])) {
402414
if (!class_exists(HttpHeaderSerializer::class)) {
403415
throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed. Try running "composer require symfony/weblink".');
@@ -2099,6 +2111,47 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $
20992111
}
21002112
}
21012113

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+
21022155
private function resolveTrustedHeaders(array $headers): int
21032156
{
21042157
$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/Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,10 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor
520520
'debug' => '%kernel.debug%',
521521
'private_headers' => [],
522522
],
523+
'rate_limiter' => [
524+
'enabled' => false,
525+
'limiters' => [],
526+
],
523527
];
524528
}
525529
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
21+
/**
22+
* @author Wouter de Jong <wouter@wouterj.nl>
23+
*
24+
* @final
25+
*/
26+
class LimiterFactory
27+
{
28+
private $config;
29+
private $storage;
30+
private $lockFactory;
31+
32+
public function __construct(array $config, StorageInterface $storage, ?LockFactory $lockFactory)
33+
{
34+
$this->storage = $storage;
35+
$this->lockFactory = $lockFactory;
36+
37+
$options = new OptionsResolver();
38+
self::configureOptions($options);
39+
40+
$this->config = array_filter($options->resolve($config), function ($v) { return $v !== []; });
41+
42+
if (\count($this->config) < 2) {
43+
throw new InvalidOptionsException('No limiter algorithm configured, it is required to set either "window" or "bucket" config options.');
44+
} elseif (\count($this->config) > 2) {
45+
// indicates that more than one algorithm is configured
46+
unset($this->config['default_id']);
47+
48+
throw new InvalidOptionsException(sprintf('Cannot configure more than one limiter algorithm, but configuration is passed for: "%s".', implode('", "', array_keys($this->config))));
49+
}
50+
}
51+
52+
public function createLimiter(?string $id = null): LimiterInterface
53+
{
54+
$id = $id ?? $this->config['default_id'];
55+
$lock = $this->lockFactory ? $this->lockFactory->createLock($id) : new NoLock();
56+
57+
if (isset($this->config['bucket'])) {
58+
return new TokenBucketLimiter(new TokenBucket($id, $this->config['bucket']['max_burst'], $this->config['bucket']['rate']), $this->storage, $lock);
59+
}
60+
61+
// @todo
62+
//return new SlidingWindowLimiter(new Window($id, $this->config['window']));
63+
}
64+
65+
protected static function configureOptions(OptionsResolver $options): void
66+
{
67+
$options
68+
->define('default_id')->required()
69+
->define('bucket')
70+
->default(function (OptionsResolver $bucket) {
71+
$bucket
72+
->define('max_burst')->allowedTypes('int')
73+
->define('rate')
74+
->default(function (OptionsResolver $rate) {
75+
$rate
76+
->define('amount')->allowedTypes('int')
77+
->define('interval')->allowedValues(function ($interval) {
78+
return preg_match('/P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?/', $interval);
79+
})
80+
;
81+
})
82+
->normalize(function (Options $options, $value) {
83+
return new Rate(new \DateInterval($value['interval']), $value['amount']);
84+
})
85+
;
86+
})
87+
->allowedValues(function ($bucketValue) {
88+
if ([] === $bucketValue || (isset($bucketValue['max_burst']) && isset($bucketValue['rate']))) {
89+
return true;
90+
}
91+
92+
throw new InvalidOptionsException('The bucket[max_burst] and bucket[rate] options must be configured.');
93+
})
94+
95+
->define('window')
96+
->default(function (OptionsResolver $window) {
97+
$window
98+
->define('limit')->allowedTypes('int')
99+
->define('interval')->allowedValues(function ($interval) {
100+
return preg_match('/P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?/', $interval);
101+
})
102+
;
103+
})
104+
->allowedValues(function ($windowValue) {
105+
if ([] === $windowValue || (isset($windowValue['limit']) && isset($windowValue['sampling_period']))) {
106+
return true;
107+
}
108+
109+
throw new InvalidOptionsException('The window[limit] and window[interval] options must be configured.');
110+
})
111+
;
112+
}
113+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy