Skip to content

Commit 9305b7b

Browse files
committed
NEXT-23422 - Added rate limiter to adding a line item
1 parent d7223dc commit 9305b7b

File tree

17 files changed

+452
-40
lines changed

17 files changed

+452
-40
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
title: Added rate limiter to adding a line item
3+
issue: NEXT-23422
4+
author: Michel Bade
5+
author_email: [email protected]
6+
author_github: @cyl3x
7+
---
8+
# Core
9+
* Added `cart_add_line_item` to rate limiter configuration in `Shopwar\Core\Framework\Resources\config\packages\shopware`
10+
* Added constant `CART_ADD_LINE_ITEM` in `Shopware\Core\Framework\RateLimiter\RateLimiter`.
11+
* Added rate limiter `Shopware\Core\Framework\RateLimiter\Policy\SystemConfigLimiter` and policy type `system_config` to allow limitation configuration with `SystemConfigService`
12+
* Added policy type `system_config` to `Shopware\Core\Framework\RateLimiter`
13+
___
14+
# API
15+
* Added rate limitation for api route `store-api.checkout.cart.add`

config-schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@
198198
},
199199
"newsletter_form": {
200200
"$ref": "#/definitions/rate_limiter_config"
201+
},
202+
"cart_add_line_item_from": {
203+
"$ref": "#/definitions/rate_limiter_config"
201204
}
202205
},
203206
"title": "RateLimiter"

phpstan-baseline.neon

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -895,16 +895,6 @@ parameters:
895895
count: 1
896896
path: src/Core/Checkout/Cart/SalesChannel/AbstractCartItemAddRoute.php
897897

898-
-
899-
message: "#^Method Shopware\\\\Core\\\\Checkout\\\\Cart\\\\SalesChannel\\\\CartItemAddRoute\\:\\:add\\(\\) has parameter \\$items with no value type specified in iterable type array\\.$#"
900-
count: 1
901-
path: src/Core/Checkout/Cart/SalesChannel/CartItemAddRoute.php
902-
903-
-
904-
message: "#^PHPDoc tag @var for variable \\$item has no value type specified in iterable type array\\.$#"
905-
count: 1
906-
path: src/Core/Checkout/Cart/SalesChannel/CartItemAddRoute.php
907-
908898
-
909899
message: "#^PHPDoc tag @var for variable \\$item has no value type specified in iterable type array\\.$#"
910900
count: 1
@@ -16918,11 +16908,6 @@ parameters:
1691816908
count: 1
1691916909
path: src/Core/Framework/DependencyInjection/CompilerPass/BusinessEventRegisterCompilerPass.php
1692016910

16921-
-
16922-
message: "#^Method Shopware\\\\Core\\\\Framework\\\\DependencyInjection\\\\CompilerPass\\\\RateLimiterCompilerPass\\:\\:setConfigDefaults\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
16923-
count: 1
16924-
path: src/Core/Framework/DependencyInjection/CompilerPass/RateLimiterCompilerPass.php
16925-
1692616911
-
1692716912
message: "#^Method Shopware\\\\Core\\\\Framework\\\\DependencyInjection\\\\FrameworkExtension\\:\\:addShopwareConfig\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
1692816913
count: 1
@@ -17698,16 +17683,6 @@ parameters:
1769817683
count: 1
1769917684
path: src/Core/Framework/RateLimiter/Policy/TimeBackoffLimiter.php
1770017685

17701-
-
17702-
message: "#^Method Shopware\\\\Core\\\\Framework\\\\RateLimiter\\\\RateLimiterFactory\\:\\:__construct\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
17703-
count: 1
17704-
path: src/Core/Framework/RateLimiter/RateLimiterFactory.php
17705-
17706-
-
17707-
message: "#^Property Shopware\\\\Core\\\\Framework\\\\RateLimiter\\\\RateLimiterFactory\\:\\:\\$config type has no value type specified in iterable type array\\.$#"
17708-
count: 1
17709-
path: src/Core/Framework/RateLimiter/RateLimiterFactory.php
17710-
1771117686
-
1771217687
message: "#^Method Shopware\\\\Core\\\\Framework\\\\Routing\\\\AbstractRouteScope\\:\\:getRoutePrefixes\\(\\) return type has no value type specified in iterable type array\\.$#"
1771317688
count: 1
@@ -22538,11 +22513,6 @@ parameters:
2253822513
count: 1
2253922514
path: src/Core/Framework/Test/Plugin/_fixture/plugins/SwagTestWithBundle/src/SwagTestWithBundle.php
2254022515

22541-
-
22542-
message: "#^Property Shopware\\\\Core\\\\Framework\\\\Test\\\\RateLimiter\\\\Policy\\\\TimeBackoffLimiterTest\\:\\:\\$config type has no value type specified in iterable type array\\.$#"
22543-
count: 1
22544-
path: src/Core/Framework/Test/RateLimiter/Policy/TimeBackoffLimiterTest.php
22545-
2254622516
-
2254722517
message: "#^Method Shopware\\\\Core\\\\Framework\\\\Test\\\\Routing\\\\ApiRequestContextResolverTest\\:\\:addRoleToIntegration\\(\\) has parameter \\$privileges with no value type specified in iterable type array\\.$#"
2254822518
count: 1

src/Core/Checkout/Cart/SalesChannel/CartItemAddRoute.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
use Shopware\Core\Checkout\Cart\Event\AfterLineItemAddedEvent;
99
use Shopware\Core\Checkout\Cart\Event\BeforeLineItemAddedEvent;
1010
use Shopware\Core\Checkout\Cart\Event\CartChangedEvent;
11+
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
1112
use Shopware\Core\Checkout\Cart\LineItemFactoryRegistry;
1213
use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
14+
use Shopware\Core\Framework\RateLimiter\RateLimiter;
1315
use Shopware\Core\Framework\Routing\Annotation\RouteScope;
1416
use Shopware\Core\Framework\Routing\Annotation\Since;
1517
use Shopware\Core\System\SalesChannel\SalesChannelContext;
@@ -42,19 +44,23 @@ class CartItemAddRoute extends AbstractCartItemAddRoute
4244
*/
4345
private $lineItemFactory;
4446

47+
private RateLimiter $rateLimiter;
48+
4549
/**
4650
* @internal
4751
*/
4852
public function __construct(
4953
CartCalculator $cartCalculator,
5054
CartPersisterInterface $cartPersister,
5155
EventDispatcherInterface $eventDispatcher,
52-
LineItemFactoryRegistry $lineItemFactory
56+
LineItemFactoryRegistry $lineItemFactory,
57+
RateLimiter $rateLimiter
5358
) {
5459
$this->cartCalculator = $cartCalculator;
5560
$this->cartPersister = $cartPersister;
5661
$this->eventDispatcher = $eventDispatcher;
5762
$this->lineItemFactory = $lineItemFactory;
63+
$this->rateLimiter = $rateLimiter;
5864
}
5965

6066
public function getDecorated(): AbstractCartItemAddRoute
@@ -65,19 +71,26 @@ public function getDecorated(): AbstractCartItemAddRoute
6571
/**
6672
* @Since("6.3.0.0")
6773
* @Route("/store-api/checkout/cart/line-item", name="store-api.checkout.cart.add", methods={"POST"})
74+
*
75+
* @param array<LineItem> $items
6876
*/
6977
public function add(Request $request, Cart $cart, SalesChannelContext $context, ?array $items): CartResponse
7078
{
7179
if ($items === null) {
7280
$items = [];
7381

74-
/** @var array $item */
82+
/** @var array<mixed> $item */
7583
foreach ($request->request->all('items') as $item) {
7684
$items[] = $this->lineItemFactory->create($item, $context);
7785
}
7886
}
7987

8088
foreach ($items as $item) {
89+
if ($request->getClientIp() !== null) {
90+
$cacheKey = ($item->getReferencedId() ?? $item->getId()) . '-' . $request->getClientIp();
91+
$this->rateLimiter->ensureAccepted(RateLimiter::CART_ADD_LINE_ITEM, $cacheKey);
92+
}
93+
8194
$alreadyExists = $cart->has($item->getId());
8295
$cart->add($item);
8396

src/Core/Checkout/DependencyInjection/cart.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
<argument type="service" id="Shopware\Core\Checkout\Cart\CartPersister"/>
104104
<argument type="service" id="event_dispatcher"/>
105105
<argument type="service" id="Shopware\Core\Checkout\Cart\LineItemFactoryRegistry"/>
106+
<argument type="service" id="shopware.rate_limiter"/>
106107
</service>
107108

108109
<service id="Shopware\Core\Checkout\Cart\SalesChannel\CartOrderRoute" public="true">

src/Core/Framework/DependencyInjection/CompilerPass/RateLimiterCompilerPass.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Shopware\Core\Framework\RateLimiter\RateLimiter;
66
use Shopware\Core\Framework\RateLimiter\RateLimiterFactory;
7+
use Shopware\Core\System\SystemConfig\SystemConfigService;
78
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
89
use Symfony\Component\DependencyInjection\ContainerBuilder;
910
use Symfony\Component\DependencyInjection\Definition;
@@ -35,6 +36,7 @@ public function process(ContainerBuilder $container): void
3536
$cacheDef->addArgument(new Reference($config['cache_pool']));
3637

3738
$def->addArgument($cacheDef);
39+
$def->addArgument(new Reference(SystemConfigService::class));
3840
$def->addArgument(new Reference($config['lock_factory']));
3941

4042
$rateLimiter->addMethodCall('registerLimiterFactory', [$name, $def]);
@@ -43,6 +45,9 @@ public function process(ContainerBuilder $container): void
4345
$container->setDefinition('shopware.rate_limiter', $rateLimiter);
4446
}
4547

48+
/**
49+
* @param array<string, array<string, int|string>|bool|string|int> $config
50+
*/
4651
private function setConfigDefaults(array &$config): void
4752
{
4853
if (!\array_key_exists('enabled', $config)) {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Shopware\Core\Framework\RateLimiter\Policy;
4+
5+
use Shopware\Core\System\SystemConfig\SystemConfigService;
6+
use Symfony\Component\Lock\LockInterface;
7+
use Symfony\Component\RateLimiter\Storage\StorageInterface;
8+
9+
class SystemConfigLimiter extends TimeBackoffLimiter
10+
{
11+
/**
12+
* @param array<string, string|int> $limits
13+
*/
14+
public function __construct(SystemConfigService $systemConfigService, string $id, array $limits, \DateInterval $reset, StorageInterface $storage, ?LockInterface $lock = null)
15+
{
16+
foreach ($limits as $idx => $limit) {
17+
if (!isset($limit['domain'])) {
18+
continue;
19+
}
20+
21+
$sysLimit = $systemConfigService->get($limit['domain']);
22+
$limits[$idx]['limit'] = $sysLimit && (int) $sysLimit !== 0 ? (int) $sysLimit : \PHP_INT_MAX;
23+
unset($limits[$idx]['domain']);
24+
}
25+
26+
parent::__construct($id, $limits, $reset, $storage, $lock);
27+
}
28+
}

src/Core/Framework/RateLimiter/RateLimiter.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class RateLimiter
2020

2121
public const NEWSLETTER_FORM = 'newsletter_form';
2222

23+
public const CART_ADD_LINE_ITEM = 'cart_add_line_item';
24+
2325
/**
2426
* @var array<string, RateLimiterFactory>
2527
*/

src/Core/Framework/RateLimiter/RateLimiterFactory.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace Shopware\Core\Framework\RateLimiter;
44

5+
use Shopware\Core\Framework\RateLimiter\Policy\SystemConfigLimiter;
56
use Shopware\Core\Framework\RateLimiter\Policy\TimeBackoffLimiter;
7+
use Shopware\Core\System\SystemConfig\SystemConfigService;
68
use Symfony\Component\Lock\LockFactory;
79
use Symfony\Component\Lock\NoLock;
810
use Symfony\Component\RateLimiter\LimiterInterface;
@@ -12,19 +14,27 @@
1214

1315
class RateLimiterFactory
1416
{
17+
/**
18+
* @var array<mixed>
19+
*/
1520
private array $config;
1621

1722
private StorageInterface $storage;
1823

1924
private ?LockFactory $lockFactory;
2025

26+
private SystemConfigService $systemConfigService;
27+
2128
/**
2229
* @internal
30+
*
31+
* @param array<string, array<int|string, array<string, int|string>|string>|bool|int|string> $config
2332
*/
24-
public function __construct(array $config, StorageInterface $storage, ?LockFactory $lockFactory = null)
33+
public function __construct(array $config, StorageInterface $storage, SystemConfigService $systemConfigService, ?LockFactory $lockFactory = null)
2534
{
2635
$this->config = $config;
2736
$this->storage = $storage;
37+
$this->systemConfigService = $systemConfigService;
2838
$this->lockFactory = $lockFactory;
2939
}
3040

@@ -45,6 +55,10 @@ public function create(?string $key = null): LimiterInterface
4555
return new TimeBackoffLimiter($id, $this->config['limits'], $this->config['reset'], $this->storage, $lock);
4656
}
4757

58+
if ($this->config['policy'] === 'system_config') {
59+
return new SystemConfigLimiter($this->systemConfigService, $id, $this->config['limits'], $this->config['reset'], $this->storage, $lock);
60+
}
61+
4862
// prevent symfony errors due to customized values
4963
$this->config = \array_filter($this->config, static function ($key): bool {
5064
return !\in_array($key, ['enabled', 'reset', 'cache_pool', 'lock_factory', 'limits'], true);

src/Core/Framework/Resources/config/packages/shopware.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ shopware:
143143
interval: '60 seconds'
144144
- limit: 10
145145
interval: '90 seconds'
146+
cart_add_line_item:
147+
enabled: true
148+
policy: 'system_config'
149+
reset: '1 hours'
150+
limits:
151+
- domain: 'core.cart.lineItemAddLimit'
152+
interval: '60 seconds'
146153

147154
admin_worker:
148155
enable_admin_worker: true

src/Core/System/Resources/config/cart.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
<label>Time in minutes for a customer to finalize a transaction</label>
3030
<label lang="de-DE">Zeit in Minuten, die ein Kunde Zeit hat eine Transaktion abzuschließen</label>
3131
</input-field>
32+
33+
<input-field type="int">
34+
<name>lineItemAddLimit</name>
35+
<defaultValue>0</defaultValue>
36+
<label>Maximum addable products to cart per minute through API</label>
37+
<label lang="de-DE">Maximal hinzufügbare Produkte pro Minute in den Warenkorb durch die API</label>
38+
</input-field>
3239
</card>
3340

3441
<card>

src/Core/Framework/Test/RateLimiter/RateLimiterFactoryTest.php renamed to tests/integration/php/Core/Framework/RateLimiter/Policy/RateLimiterFactoryTest.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
<?php declare(strict_types=1);
22

3-
namespace Shopware\Core\Framework\Test\RateLimiter;
3+
namespace Shopware\Tests\Core\Framework\RateLimiter\Policy;
44

55
use PHPUnit\Framework\TestCase;
66
use Shopware\Core\Framework\RateLimiter\Policy\TimeBackoffLimiter;
77
use Shopware\Core\Framework\RateLimiter\RateLimiterFactory;
88
use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
9+
use Shopware\Core\System\SystemConfig\SystemConfigService;
910
use Symfony\Component\Lock\LockFactory;
1011
use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter;
1112
use Symfony\Component\RateLimiter\Storage\StorageInterface;
1213

1314
/**
1415
* @internal
16+
*
17+
* @covers \Shopware\Core\Framework\RateLimiter\RateLimiterFactory
1518
*/
1619
class RateLimiterFactoryTest extends TestCase
1720
{
@@ -37,7 +40,8 @@ public function testFactoryShouldReturnCustomPolicy(): void
3740
],
3841
],
3942
$this->createMock(StorageInterface::class),
40-
$this->createMock(LockFactory::class)
43+
$this->createMock(SystemConfigService::class),
44+
$this->createMock(LockFactory::class),
4145
);
4246

4347
static::assertInstanceOf(TimeBackoffLimiter::class, $factory->create('example'));
@@ -54,7 +58,8 @@ public function testFactoryShouldUseSymfonyFactory(): void
5458
'rate' => ['interval' => '60 seconds'],
5559
],
5660
$this->createMock(StorageInterface::class),
57-
$this->createMock(LockFactory::class)
61+
$this->createMock(SystemConfigService::class),
62+
$this->createMock(LockFactory::class),
5863
);
5964

6065
static::assertInstanceOf(TokenBucketLimiter::class, $factory->create('example'));
@@ -89,7 +94,8 @@ public function testFactoryShouldUseSymfonyFactoryOverrideDefaultConfig(): void
8994
],
9095
),
9196
$this->createMock(StorageInterface::class),
92-
$this->createMock(LockFactory::class)
97+
$this->createMock(SystemConfigService::class),
98+
$this->createMock(LockFactory::class),
9399
);
94100

95101
static::assertInstanceOf(TokenBucketLimiter::class, $factory->create('example'));

src/Core/Framework/Test/RateLimiter/Policy/TimeBackoffLimiterTest.php renamed to tests/integration/php/Core/Framework/RateLimiter/Policy/TimeBackoffLimiterTest.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
<?php declare(strict_types=1);
22

3-
namespace Shopware\Core\Framework\Test\RateLimiter\Policy;
3+
namespace Shopware\Tests\Core\Framework\RateLimiter\Policy;
44

55
use PHPUnit\Framework\TestCase;
66
use Shopware\Core\Checkout\Test\Customer\SalesChannel\CustomerTestTrait;
77
use Shopware\Core\Framework\RateLimiter\Policy\TimeBackoff;
88
use Shopware\Core\Framework\RateLimiter\RateLimiterFactory;
99
use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
1010
use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour;
11+
use Shopware\Core\System\SystemConfig\SystemConfigService;
1112
use Symfony\Component\Cache\Adapter\ArrayAdapter;
1213
use Symfony\Component\Lock\LockFactory;
1314
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
@@ -17,13 +18,18 @@
1718

1819
/**
1920
* @internal
21+
*
22+
* @covers \Shopware\Core\Framework\RateLimiter\Policy\TimeBackoffLimiter
2023
*/
2124
class TimeBackoffLimiterTest extends TestCase
2225
{
2326
use IntegrationTestBehaviour;
2427
use CustomerTestTrait;
2528
use SalesChannelApiTestBehaviour;
2629

30+
/**
31+
* @var array<mixed>
32+
*/
2733
private array $config;
2834

2935
private LimiterInterface $limiter;
@@ -58,6 +64,7 @@ public function setUp(): void
5864
$factory = new RateLimiterFactory(
5965
$this->config,
6066
new CacheStorage(new ArrayAdapter()),
67+
$this->createMock(SystemConfigService::class),
6168
$this->createMock(LockFactory::class)
6269
);
6370

0 commit comments

Comments
 (0)