Skip to content

Commit 0a587d7

Browse files
committed
[TwigBridge][TwigBundle] Add new rate_limit() Twig function
1 parent 73ce8e5 commit 0a587d7

File tree

13 files changed

+309
-1
lines changed

13 files changed

+309
-1
lines changed

src/Symfony/Bridge/Twig/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
* Add `rate_limit()` Twig function
7+
48
7.3
59
---
610

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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\Bridge\Twig\Extension;
13+
14+
use Twig\Extension\AbstractExtension;
15+
use Twig\TwigFunction;
16+
17+
/**
18+
* @author Santiago San Martin <sanmartindev@gmail.com>
19+
*/
20+
final class RateLimiterExtension extends AbstractExtension
21+
{
22+
public function getFunctions(): array
23+
{
24+
return [
25+
new TwigFunction('rate_limit', [RateLimiterRuntime::class, 'rateLimit']),
26+
];
27+
}
28+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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\Bridge\Twig\Extension;
13+
14+
use Psr\Container\ContainerInterface;
15+
use Symfony\Component\ExpressionLanguage\Expression;
16+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
17+
use Symfony\Component\HttpFoundation\RequestStack;
18+
use Symfony\Component\RateLimiter\RateLimiterFactory;
19+
20+
/**
21+
* @author Santiago San Martin <sanmartindev@gmail.com>
22+
*/
23+
final class RateLimiterRuntime
24+
{
25+
public function __construct(
26+
private readonly ContainerInterface $rateLimiterLocator,
27+
private readonly RequestStack $requestStack,
28+
private ?ExpressionLanguage $expressionLanguage = null,
29+
) {
30+
}
31+
32+
public function rateLimit(string $rateLimiterName, string|Expression|null $key = null, int $tokens = 1): bool
33+
{
34+
if (!class_exists(RateLimiterFactory::class)) {
35+
throw new \LogicException('The "rate_limit()" function requires symfony/rate-limiter. Try running "composer require symfony/rate-limiter".');
36+
}
37+
38+
/** @var RateLimiterFactory $rateLimiterFactory */
39+
$rateLimiterFactory = $this->rateLimiterLocator->get('limiter.'.$rateLimiterName);
40+
41+
$limiter = $rateLimiterFactory->create($this->generateKey($key));
42+
43+
return $limiter->consume($tokens)->isAccepted();
44+
}
45+
46+
private function generateKey(string|Expression|null $key): ?string
47+
{
48+
if (!$key instanceof Expression) {
49+
return $key;
50+
}
51+
52+
$this->expressionLanguage ??= new ExpressionLanguage();
53+
54+
return (string) $this->expressionLanguage->evaluate($key, [
55+
'request' => $this->requestStack->getMainRequest(),
56+
]);
57+
}
58+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{% set limit = rate_limit('anonymous_api', 'custom_key', 50) %}
2+
{% if limit %}
3+
custom_key: allowed
4+
{% else %}
5+
custom_key: denied
6+
{% endif %}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{% set limit = rate_limit('anonymous_api', 'exceed_key') %}
2+
{% if limit %}
3+
exceeded_limit: allowed
4+
{% else %}
5+
exceeded_limit: denied
6+
{% endif %}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{% set limit = rate_limit('anonymous_api', expression('request.getClientIp()')) %}
2+
{% if limit %}
3+
expression_key: allowed
4+
{% else %}
5+
expression_key: denied
6+
{% endif %}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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\Bridge\Twig\Tests\Extension;
13+
14+
use PHPUnit\Framework\Attributes\DataProvider;
15+
use PHPUnit\Framework\Attributes\RequiresMethod;
16+
use PHPUnit\Framework\TestCase;
17+
use Psr\Container\ContainerInterface;
18+
use Symfony\Bridge\Twig\Extension\ExpressionExtension;
19+
use Symfony\Bridge\Twig\Extension\RateLimiterExtension;
20+
use Symfony\Bridge\Twig\Extension\RateLimiterRuntime;
21+
use Symfony\Component\ExpressionLanguage\Expression;
22+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
23+
use Symfony\Component\HttpFoundation\Request;
24+
use Symfony\Component\HttpFoundation\RequestStack;
25+
use Symfony\Component\RateLimiter\LimiterInterface;
26+
use Symfony\Component\RateLimiter\RateLimit;
27+
use Symfony\Component\RateLimiter\RateLimiterFactory;
28+
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
29+
use Twig\Environment;
30+
use Twig\Loader\ArrayLoader;
31+
use Twig\RuntimeLoader\RuntimeLoaderInterface;
32+
33+
#[RequiresMethod(RateLimiterFactory::class, '__construct')]
34+
class RateLimiterExtensionTest extends TestCase
35+
{
36+
#[DataProvider('provideRateLimitTemplatesUsingExpression')]
37+
public function testRateLimiterWithExpression(
38+
string $templateFile,
39+
bool $isAccepted,
40+
int|string $expectedConsume,
41+
Expression $expression,
42+
string $expectedOutput,
43+
): void {
44+
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '127.0.0.1']);
45+
$requestStack = new RequestStack();
46+
$requestStack->push($request);
47+
48+
$expectedKey = (new ExpressionLanguage())->evaluate($expression, ['request' => $request]);
49+
50+
$this->doTestRateLimiter(
51+
templateFile: $templateFile,
52+
isAccepted: $isAccepted,
53+
expectedConsume: $expectedConsume,
54+
expectedLimiterKey: $expectedKey,
55+
expectedOutput: $expectedOutput,
56+
requestStack: $requestStack
57+
);
58+
}
59+
60+
#[DataProvider('provideRateLimitTemplatesWithoutExpression')]
61+
public function testRateLimiterWithoutExpression(
62+
string $templateFile,
63+
bool $isAccepted,
64+
int|string $expectedConsume,
65+
string $expectedKey,
66+
string $expectedOutput,
67+
): void {
68+
$requestStack = new RequestStack();
69+
$requestStack->push(Request::create('/'));
70+
71+
$this->doTestRateLimiter(
72+
templateFile: $templateFile,
73+
isAccepted: $isAccepted,
74+
expectedConsume: $expectedConsume,
75+
expectedLimiterKey: $expectedKey,
76+
expectedOutput: $expectedOutput,
77+
requestStack: $requestStack
78+
);
79+
}
80+
81+
private function doTestRateLimiter(
82+
string $templateFile,
83+
bool $isAccepted,
84+
int|string $expectedConsume,
85+
int|string $expectedLimiterKey,
86+
string $expectedOutput,
87+
RequestStack $requestStack,
88+
): void {
89+
$rateLimit = $this->createMock(RateLimit::class);
90+
$rateLimit->expects($this->once())
91+
->method('isAccepted')
92+
->willReturn($isAccepted);
93+
94+
$limiter = $this->createMock(LimiterInterface::class);
95+
$limiter->expects($this->once())
96+
->method('consume')
97+
->with($expectedConsume)
98+
->willReturn($rateLimit);
99+
100+
$factory = $this->createMock(RateLimiterFactoryInterface::class);
101+
$factory->expects($this->once())
102+
->method('create')
103+
->with($expectedLimiterKey)
104+
->willReturn($limiter);
105+
106+
$container = $this->createMock(ContainerInterface::class);
107+
$container->expects($this->once())
108+
->method('get')
109+
->with('limiter.anonymous_api')
110+
->willReturn($factory);
111+
112+
$runtime = new RateLimiterRuntime($container, $requestStack);
113+
$twig = $this->createTwigEnvironment($runtime);
114+
115+
$templateCode = file_get_contents(__DIR__.'/Fixtures/templates/rate_limiter/'.$templateFile);
116+
$twig->setLoader(new ArrayLoader(['template' => $templateCode]));
117+
118+
$this->assertSame($expectedOutput, trim($twig->render('template')));
119+
}
120+
121+
public static function provideRateLimitTemplatesUsingExpression(): array
122+
{
123+
$expression = new Expression('request.getClientIp()');
124+
125+
return [
126+
['expression_key.twig', true, 1, $expression, 'expression_key: allowed'],
127+
['expression_key.twig', false, 1, $expression, 'expression_key: denied'],
128+
];
129+
}
130+
131+
public static function provideRateLimitTemplatesWithoutExpression(): array
132+
{
133+
return [
134+
['custom_key.twig', true, 50, 'custom_key', 'custom_key: allowed'],
135+
['custom_key.twig', false, 50, 'custom_key', 'custom_key: denied'],
136+
['exceeded_limit.twig', false, 1, 'exceed_key', 'exceeded_limit: denied'],
137+
['exceeded_limit.twig', true, 1, 'exceed_key', 'exceeded_limit: allowed'],
138+
];
139+
}
140+
141+
private function createTwigEnvironment(RateLimiterRuntime $rateLimiterRuntime): Environment
142+
{
143+
$twig = new Environment(new ArrayLoader([]), ['debug' => true, 'cache' => false]);
144+
$twig->addExtension(new RateLimiterExtension());
145+
$twig->addExtension(new ExpressionExtension());
146+
147+
$loader = $this->createMock(RuntimeLoaderInterface::class);
148+
$loader->method('load')
149+
->willReturnMap([
150+
[RateLimiterRuntime::class, $rateLimiterRuntime],
151+
]);
152+
153+
$twig->addRuntimeLoader($loader);
154+
155+
return $twig;
156+
}
157+
}

src/Symfony/Bridge/Twig/UndefinedCallableHandler.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class UndefinedCallableHandler
5858
'field_help' => 'form',
5959
'field_errors' => 'form',
6060
'field_choices' => 'form',
61+
'rate_limit' => 'rate-limiter',
6162
'logout_url' => 'security-http',
6263
'logout_path' => 'security-http',
6364
'is_granted' => 'security-core',

src/Symfony/Bridge/Twig/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
"symfony/workflow": "^6.4|^7.0|^8.0",
5555
"twig/cssinliner-extra": "^3",
5656
"twig/inky-extra": "^3",
57-
"twig/markdown-extra": "^3"
57+
"twig/markdown-extra": "^3",
58+
"symfony/rate-limiter": "^7.3|^8.0"
5859
},
5960
"conflict": {
6061
"phpdocumentor/reflection-docblock": "<3.2.2",

src/Symfony/Bundle/TwigBundle/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
* Add support for `rate_limit()` Twig function
7+
48
7.3
59
---
610

0 commit comments

Comments
 (0)