├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .scrutinizer.yml ├── Attribute └── RateLimit.php ├── CHANGELOG.md ├── DependencyInjection ├── Configuration.php └── NoxlogicRateLimitExtension.php ├── EventListener ├── BaseListener.php ├── HeaderModificationListener.php ├── OauthKeyGenerateListener.php └── RateLimitAnnotationListener.php ├── Events ├── CheckedRateLimitEvent.php ├── GenerateKeyEvent.php └── RateLimitEvents.php ├── Exception └── RateLimitExceptionInterface.php ├── LICENSE ├── NoxlogicRateLimitBundle.php ├── README.md ├── Resources ├── config │ └── services.xml ├── doc │ └── index.rst ├── meta │ └── LICENSE ├── translations │ └── messages.fr.xlf └── views │ └── Default │ └── index.html.twig ├── Service ├── RateLimitInfo.php ├── RateLimitService.php └── Storage │ ├── DoctrineCache.php │ ├── Memcache.php │ ├── PhpRedis.php │ ├── PhpRedisCluster.php │ ├── PsrCache.php │ ├── Redis.php │ ├── SimpleCache.php │ └── StorageInterface.php ├── Tests ├── Attribute │ └── RateLimitTest.php ├── DependencyInjection │ ├── ConfigurationTest.php │ └── NoxlogicRateLimitExtensionTest.php ├── EventListener │ ├── BaseListenerTest.php │ ├── HeaderModificationListenerTest.php │ ├── MockController.php │ ├── MockControllerWithAttributes.php │ ├── MockListener.php │ ├── MockStorage.php │ ├── OauthKeyGenerateListenerTest.php │ └── RateLimitAnnotationListenerTest.php ├── Events │ ├── CheckedRateLimitEventsTest.php │ ├── GenerateKeyEventsTest.php │ └── RateLimitEventsTest.php ├── Exception │ └── TestException.php ├── NoxlogicRateLimitBundleTest.php ├── Service │ ├── RateLimitInfoTest.php │ ├── RateLimitServiceTest.php │ └── Storage │ │ ├── DoctrineCacheTest.php │ │ ├── MemcacheTest.php │ │ ├── PhpRedisClusterTest.php │ │ ├── PhpRedisTest.php │ │ ├── PsrCacheTest.php │ │ ├── RedisTest.php │ │ └── SimpleCacheTest.php ├── TestCase.php ├── Util │ └── PathLimitProcessorTest.php ├── WebTestCase.php └── bootstrap.php ├── UPGRADE-2.0.md ├── Util └── PathLimitProcessor.php ├── composer.json ├── phpstan-baseline.neon ├── phpstan.dist.neon └── phpunit.xml.dist /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | run-tests: 9 | runs-on: ubuntu-24.04 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | php: [ "8.0", "8.1", "8.2", "8.3", "8.4" ] 14 | composer_flags: [ "", "--prefer-lowest" ] 15 | symfony_version: [ "^5.4", "^6.4" ] 16 | exclude: 17 | - php: "8.0" 18 | symfony_version: "^6.4" 19 | name: PHP ${{ matrix.php }} SF ${{ matrix.symfony_version }} ${{ matrix.composer_flags}} 20 | env: 21 | PHP: ${{ matrix.os }} 22 | COMPOSER_MEMORY_LIMIT: -1 23 | COMPOSER_FLAGS: ${{ matrix.composer_flags }} 24 | SYMFONY_VERSION: ${{ matrix.symfony_version }} 25 | PHP_VERSION: ${{ matrix.php }} 26 | steps: 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: redis 32 | ini-values: memory_limit=256M,post_max_size=256M 33 | - name: Checkout ratelimit bundle 34 | uses: actions/checkout@v2 35 | with: 36 | fetch-depth: 2 37 | - name: Install dependencies 38 | run: | 39 | composer self-update 40 | if [ "$SYMFONY_VERSION" != "" ]; then composer require "symfony/symfony:${SYMFONY_VERSION}" --no-update; fi; 41 | if [ "$SYMFONY_VERSION" = "^6.4" ]; then composer remove --dev "friendsofsymfony/oauth-server-bundle" --no-update; fi; 42 | COMPOSER_MEMORY_LIMIT=-1 composer update --prefer-dist --no-interaction $COMPOSER_FLAGS 43 | - name: Static analysis 44 | run: | 45 | ./vendor/bin/phpstan --memory-limit=-1 46 | - name: Run tests 47 | run: | 48 | SYMFONY_DEPRECATIONS_HELPER=weak vendor/bin/simple-phpunit --coverage-text --coverage-clover=coverage.clover 49 | - name: Upload coverage 50 | if: ${{ matrix.php == '8.2' && github.repository == 'jaytaph/RateLimitBundle' }} 51 | uses: sudo-bot/action-scrutinizer@latest 52 | with: 53 | cli-args: "--format=php-clover coverage.clover" 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | clover.xml 2 | html/ 3 | vendor/* 4 | composer.lock 5 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | inherit: true 2 | 3 | tools: 4 | external_code_coverage: true 5 | sensiolabs_security_checker: 6 | enabled: true 7 | 8 | filter: 9 | excluded_paths: 10 | - 'vendor/*' 11 | - 'tests/*' 12 | - 'app/*' 13 | - 'bin/*' 14 | - 'library/*' 15 | - 'Tests/*' 16 | -------------------------------------------------------------------------------- /Attribute/RateLimit.php: -------------------------------------------------------------------------------- 1 | methods = [$methods]; 34 | } else { 35 | $this->methods = $methods; 36 | } 37 | } 38 | 39 | public function getLimit(): int 40 | { 41 | return $this->limit; 42 | } 43 | 44 | public function setLimit(int $limit): void 45 | { 46 | $this->limit = $limit; 47 | } 48 | 49 | public function getMethods(): array 50 | { 51 | return $this->methods; 52 | } 53 | 54 | public function setMethods($methods): void 55 | { 56 | $this->methods = (array) $methods; 57 | } 58 | 59 | public function getPeriod(): int 60 | { 61 | return $this->period; 62 | } 63 | 64 | public function setPeriod(int $period): void 65 | { 66 | $this->period = $period; 67 | } 68 | 69 | public function getPayload(): mixed 70 | { 71 | return $this->payload; 72 | } 73 | 74 | public function setPayload(mixed $payload): void 75 | { 76 | $this->payload = $payload; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.17.0 2 | 2021-04-06 jaytaph 3 | - PHP8 compatibility added. 4 | 5 | ## 1.16.2 6 | 2021-03-13 jaytaph 7 | - Issue #115: Reserved characters in redis are sanitized (fixes IPv6 issues) 8 | 9 | ## 1.16.1 10 | 2021-03-12 jaytaph 11 | - Added support for framework extra bundle 6.0 in the requirements 12 | 13 | ## 1.16.0 14 | 2021-02-01 jaytaph 15 | - Moved from PSR-0 to PSR-4 to keep composer2 compatibility (Maxime Horcholle) 16 | - Added redis cluster functionality (DemigodCode) 17 | 18 | ## 1.15.1 19 | 2020-02-11 jaytaph 20 | - Fixed Symfony5 compatibility issues 21 | 22 | ## 1.15.0 23 | 2020-02-10 jaytaph 24 | - Added support for Symfony 5.x and removed support for < 3.4 and < 4.3 (kai) 25 | - Dropped support for PHP 7.2 or lower 26 | 27 | ## 1.14.0 28 | 2019-02-19 goetas 29 | - Added Payload to RateLimit to allow better exceptions 30 | 31 | ## 1.13.0 32 | 2019-01-04 axi 33 | - Added CheckedRateLimit event that allows RateLimit to be changed 34 | 35 | ## 1.12.0 36 | 2019-01-04 37 | - Something has gone wrong with the versions, just going to make a new one and 38 | move on 39 | 40 | ## 1.11.1 41 | 2018-07-02 mcfedr 42 | - Accept null rate response exception configuration 43 | 44 | ## 1.10.3 45 | 2019-01-04 DemigodCode 46 | - Deprecations in Symfony 4.2 47 | 48 | ## 1.10.2 49 | 2018-09-28 pierniq 50 | - Fixed setting calls in Redis/PhpRedis storage 51 | 52 | ## 1.10.1 53 | 2018-06-29 mcfedr 54 | - Change cache keys to be valid PSR-6 keys 55 | 56 | ## 1.10.0 57 | 2018-06-27 mcfedr 58 | - Add Psr-6 Cache storage engine 59 | 60 | ## 1.9.1 61 | 2018-06-27 mcfedr 62 | - Optimisation for Memcached storage engine 63 | - Reduce likely hood of infinite loop in memcached storage 64 | - Memcached storage will silently fail in the same way other storages fail 65 | 66 | ## 1.9.0 67 | 2018-06-27 mcfedr 68 | - Add Psr-16 Simple Cache storage engine 69 | - Optimisation for Doctrine Cache storage engine 70 | 71 | ## 1.8.2 72 | 2018-06-04 goetas 73 | - Fix Symfony 4 support by allowing newer versions of `framework-extra-bundle` 74 | - Fix travis tests as some just seem to fail 75 | 76 | ## 1.8.1 77 | 2018-05-24 mcfedr 78 | - Support for Symfony 4 79 | 80 | 2017-10-19 merk 81 | - Force $methods to be an array 82 | 83 | 2017-08-17 odoucet 84 | - Fix and improve Travis builds 85 | 86 | 2017-08-11 mcfedr 87 | - More efficient use of Redis 88 | - Option to disable fos listener 89 | - Easy to use RateLimitBundle without extra bundles 90 | - Add support for using a php redis client 91 | 92 | ## 1.7.0 93 | 2016-03-25 Joshua Thijsen 94 | Fixed issue where manual reset did not correctly reset in redis 95 | 96 | 2016-03-18 Scott Brown 97 | Implement reset of rate limit 98 | 99 | ## 1.6.0 100 | 101 | 2015-24-12 Roland Ekström 102 | Initial support for Symfony 3 103 | 104 | ## 1.5.0 105 | 106 | 2015-04-10 Sam Van der Borght 107 | [Security] Prevent ratelimit bypassing by encoding url paths 108 | 109 | 2015-01-05 Jonathan McLean 110 | Fix rate_response_exception 111 | 112 | ## 1.4 113 | 114 | 2014-12-10 Koen Vlaswinkel 115 | Add Doctrine Cache storage engine 116 | 117 | ## 1.3 118 | 119 | 2014-11-17 Joshua Thijssen 120 | Ratelimit can trigger exceptions 121 | 122 | ## 1.2 123 | 2014-07-15 Dan Spencer 124 | Added global rate limits 125 | 126 | ## 1.1 127 | 2014-07-08 Joshua Thijssen 128 | Added changelog to reflect changes in the different releases 129 | 130 | 2014-07-07 Tobias Berchtold 131 | removed dependency to constant used in Symfony > 2.3 132 | 133 | 2014-07-05 Alberto Fernández 134 | Improved README, added enabled configuration 135 | 136 | ## 1.0.1 137 | 2014-06-23 Joshua Thijssen 138 | Fixed installation documentation for 1.x 139 | 140 | ## 1.0 - Initial release 141 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 24 | 25 | $rootNode // @phpstan-ignore method.notFound 26 | ->canBeDisabled() 27 | ->children() 28 | ->enumNode('storage_engine') 29 | ->values(array('redis','memcache','doctrine', 'php_redis', 'php_redis_cluster', 'simple_cache', 'cache')) 30 | ->defaultValue('redis') 31 | ->info('The storage engine where all the rates will be stored') 32 | ->end() 33 | ->scalarNode('redis_client') 34 | ->defaultValue('default_client') 35 | ->info('The redis client to use for the redis storage engine') 36 | ->end() 37 | ->scalarNode('redis_service') 38 | ->defaultNull() 39 | ->info('The Redis service to use for the redis storage engine, should be instance of \\Predis\\Client') 40 | ->example('project.predis') 41 | ->end() 42 | ->scalarNode('php_redis_service') 43 | ->defaultNull() 44 | ->info('Service id of a php redis, should be an instance of \\Redis') 45 | ->example('project.redis') 46 | ->end() 47 | ->scalarNode('memcache_client') 48 | ->defaultValue('default') 49 | ->info('The memcache client to use for the memcache storage engine') 50 | ->end() 51 | ->scalarNode('memcache_service') 52 | ->defaultNull() 53 | ->info('The Memcached service to use for the memcache storage engine, should be instance of \\Memcached') 54 | ->example('project.memcached') 55 | ->end() 56 | ->scalarNode('doctrine_provider') 57 | ->defaultNull() 58 | ->info('The Doctrine Cache provider to use for the doctrine storage engine') 59 | ->example('my_apc_cache') 60 | ->end() 61 | ->scalarNode('doctrine_service') 62 | ->defaultNull() 63 | ->info('The Doctrine Cache service to use for the doctrine storage engine') 64 | ->example('project.my_apc_cache') 65 | ->end() 66 | ->scalarNode('simple_cache_service') 67 | ->defaultNull() 68 | ->info('Service id of a simple cache, should be an instance of \\Psr\\SimpleCache\\CacheInterface') 69 | ->example('project.cache') 70 | ->end() 71 | ->scalarNode('cache_service') 72 | ->defaultNull() 73 | ->info('Service id of a cache, should be an instance of \\Psr\\Cache\\CacheItemPoolInterface') 74 | ->example('project.cache') 75 | ->end() 76 | ->integerNode('rate_response_code') 77 | ->min(400) 78 | ->max(499) 79 | ->defaultValue(static::HTTP_TOO_MANY_REQUESTS) 80 | ->info('The HTTP status code to return when a client hits the rate limit') 81 | ->end() 82 | ->scalarNode('rate_response_exception') 83 | ->defaultNull() 84 | ->info('Optional exception class that will be returned when a client hits the rate limit') 85 | ->validate() 86 | ->always(function ($item) { 87 | if ($item && !is_subclass_of($item, '\Exception')) { 88 | throw new InvalidConfigurationException(sprintf("'%s' must inherit the \\Exception class", $item)); 89 | } 90 | return $item; 91 | }) 92 | ->end() 93 | ->end() 94 | ->scalarNode('rate_response_message') 95 | ->defaultValue('You exceeded the rate limit') 96 | ->info('The HTTP message to return when a client hits the rate limit') 97 | ->end() 98 | ->booleanNode('display_headers') 99 | ->defaultTrue() 100 | ->info('Should the ratelimit headers be automatically added to the response?') 101 | ->end() 102 | ->arrayNode('headers') 103 | ->addDefaultsIfNotSet() 104 | ->info('What are the different header names to add') 105 | ->children() 106 | ->scalarNode('limit')->defaultValue('X-RateLimit-Limit')->end() 107 | ->scalarNode('remaining')->defaultValue('X-RateLimit-Remaining')->end() 108 | ->scalarNode('reset')->defaultValue('X-RateLimit-Reset')->end() 109 | ->end() 110 | ->end() 111 | ->arrayNode('path_limits') 112 | ->defaultValue(array()) 113 | ->info('Rate limits for paths') 114 | ->prototype('array') 115 | ->children() 116 | ->scalarNode('path') 117 | ->isRequired() 118 | ->end() 119 | ->arrayNode('methods') 120 | ->prototype('enum') 121 | ->values(array('*', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH')) 122 | ->end() 123 | ->requiresAtLeastOneElement() 124 | ->defaultValue(array('*')) 125 | ->end() 126 | ->integerNode('limit') 127 | ->isRequired() 128 | ->min(0) 129 | ->end() 130 | ->integerNode('period') 131 | ->isRequired() 132 | ->min(0) 133 | ->end() 134 | ->end() 135 | ->end() 136 | ->end() 137 | ->booleanNode('fos_oauth_key_listener') 138 | ->defaultTrue() 139 | ->info('Enabled the FOS OAuthServerBundle listener') 140 | ->end() 141 | ->end() 142 | ; 143 | 144 | return $treeBuilder; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /DependencyInjection/NoxlogicRateLimitExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 26 | $this->loadServices($container, $config); 27 | 28 | } 29 | 30 | private function loadServices(ContainerBuilder $container, array $config): void 31 | { 32 | $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 33 | $loader->load('services.xml'); 34 | 35 | $container->setParameter('noxlogic_rate_limit.enabled', $config['enabled']); 36 | 37 | $container->setParameter('noxlogic_rate_limit.rate_response_exception', $config['rate_response_exception']); 38 | $container->setParameter('noxlogic_rate_limit.rate_response_code', $config['rate_response_code']); 39 | $container->setParameter('noxlogic_rate_limit.rate_response_message', $config['rate_response_message']); 40 | 41 | $container->setParameter('noxlogic_rate_limit.display_headers', $config['display_headers']); 42 | $container->setParameter('noxlogic_rate_limit.headers.limit.name', $config['headers']['limit']); 43 | $container->setParameter('noxlogic_rate_limit.headers.remaining.name', $config['headers']['remaining']); 44 | $container->setParameter('noxlogic_rate_limit.headers.reset.name', $config['headers']['reset']); 45 | 46 | $container->setParameter('noxlogic_rate_limit.path_limits', $config['path_limits']); 47 | 48 | switch ($config['storage_engine']) { 49 | case 'memcache': 50 | $container->setParameter('noxlogic_rate_limit.storage.class', 'Noxlogic\RateLimitBundle\Service\Storage\Memcache'); 51 | if (isset($config['memcache_client'])) { 52 | $service = 'memcache.' . $config['memcache_client']; 53 | } else { 54 | $service = $config['memcache_service']; 55 | } 56 | $container->getDefinition('noxlogic_rate_limit.storage')->replaceArgument( 57 | 0, 58 | new Reference($service) 59 | ); 60 | break; 61 | case 'redis': 62 | $container->setParameter('noxlogic_rate_limit.storage.class', 'Noxlogic\RateLimitBundle\Service\Storage\Redis'); 63 | if (isset($config['redis_service'])) { 64 | $service = $config['redis_service']; 65 | } else { 66 | $service = 'snc_redis.' . $config['redis_client']; 67 | } 68 | $container->getDefinition('noxlogic_rate_limit.storage')->replaceArgument( 69 | 0, 70 | new Reference($service) 71 | ); 72 | break; 73 | case 'doctrine': 74 | $container->setParameter('noxlogic_rate_limit.storage.class', 'Noxlogic\RateLimitBundle\Service\Storage\DoctrineCache'); 75 | if (isset($config['doctrine_provider'])) { 76 | $service = 'doctrine_cache.providers.' . $config['doctrine_provider']; 77 | } else { 78 | $service = $config['doctrine_service']; 79 | } 80 | $container->getDefinition('noxlogic_rate_limit.storage')->replaceArgument( 81 | 0, 82 | new Reference($service) 83 | ); 84 | break; 85 | case 'php_redis': 86 | $container->setParameter('noxlogic_rate_limit.storage.class', 'Noxlogic\RateLimitBundle\Service\Storage\PhpRedis'); 87 | $container->getDefinition('noxlogic_rate_limit.storage')->replaceArgument( 88 | 0, 89 | new Reference($config['php_redis_service']) 90 | ); 91 | break; 92 | case 'php_redis_cluster': 93 | $container->setParameter('noxlogic_rate_limit.storage.class', 'Noxlogic\RateLimitBundle\Service\Storage\PhpRedisCluster'); 94 | $container->getDefinition('noxlogic_rate_limit.storage')->replaceArgument( 95 | 0, 96 | new Reference($config['php_redis_service']) 97 | ); 98 | break; 99 | case 'simple_cache': 100 | $container->setParameter('noxlogic_rate_limit.storage.class', 'Noxlogic\RateLimitBundle\Service\Storage\SimpleCache'); 101 | $container->getDefinition('noxlogic_rate_limit.storage')->replaceArgument( 102 | 0, 103 | new Reference($config['simple_cache_service']) 104 | ); 105 | break; 106 | case 'cache': 107 | $container->setParameter('noxlogic_rate_limit.storage.class', 'Noxlogic\RateLimitBundle\Service\Storage\PsrCache'); 108 | $container->getDefinition('noxlogic_rate_limit.storage')->replaceArgument( 109 | 0, 110 | new Reference($config['cache_service']) 111 | ); 112 | break; 113 | } 114 | 115 | if ($config['fos_oauth_key_listener']) { 116 | $tokenStorageReference = new Reference('security.token_storage'); 117 | $container->getDefinition('noxlogic_rate_limit.oauth_key_generate_listener')->replaceArgument(0, $tokenStorageReference); 118 | } else { 119 | $container->removeDefinition('noxlogic_rate_limit.oauth_key_generate_listener'); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /EventListener/BaseListener.php: -------------------------------------------------------------------------------- 1 | parameters[$name] = $value; 20 | } 21 | 22 | /** 23 | * @param $name 24 | * @param mixed $default 25 | * @return mixed 26 | */ 27 | public function getParameter($name, $default = null) 28 | { 29 | return isset($this->parameters[$name]) ? $this->parameters[$name] : $default; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /EventListener/HeaderModificationListener.php: -------------------------------------------------------------------------------- 1 | parameters = $defaultParameters; 17 | } 18 | 19 | /** 20 | * @param ResponseEvent $event 21 | */ 22 | public function onKernelResponse($event) 23 | { 24 | $request = $event->getRequest(); 25 | 26 | // Check if we have a rate-limit-info object in our request attributes. If not, we didn't need to limit. 27 | $rateLimitInfo = $request->attributes->get('rate_limit_info', null); 28 | if (! $rateLimitInfo) { 29 | return; 30 | } 31 | 32 | // Check if we need to add our x-rate-limits to the headers 33 | if (! $this->getParameter('display_headers')) { 34 | return; 35 | } 36 | 37 | /** @var RateLimitInfo $rateLimitInfo */ 38 | 39 | $remaining = $rateLimitInfo->getLimit() - $rateLimitInfo->getCalls(); 40 | if ($remaining < 0) { 41 | $remaining = 0; 42 | } 43 | 44 | $response = $event->getResponse(); 45 | $response->headers->set($this->getParameter('header_limit_name'), $rateLimitInfo->getLimit()); 46 | $response->headers->set($this->getParameter('header_remaining_name'), (string) $remaining); 47 | $response->headers->set($this->getParameter('header_reset_name'), $rateLimitInfo->getResetTimestamp()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /EventListener/OauthKeyGenerateListener.php: -------------------------------------------------------------------------------- 1 | tokenStorage = $tokenStorage; 21 | } 22 | 23 | /** 24 | * @param GenerateKeyEvent $event 25 | */ 26 | public function onGenerateKey(GenerateKeyEvent $event) 27 | { 28 | $token = $this->tokenStorage->getToken(); 29 | if (! $token instanceof \FOS\OAuthServerBundle\Security\Authentication\Token\OAuthToken) { 30 | return; 31 | } 32 | 33 | $event->addToKey($token->getToken()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /EventListener/RateLimitAnnotationListener.php: -------------------------------------------------------------------------------- 1 | eventDispatcher = $eventDispatcher; 32 | $this->rateLimitService = $rateLimitService; 33 | $this->pathLimitProcessor = $pathLimitProcessor; 34 | } 35 | 36 | public function onKernelController(ControllerEvent $event): void 37 | { 38 | // Skip if the bundle isn't enabled (for instance in test environment) 39 | if( ! $this->getParameter('enabled', true)) { 40 | return; 41 | } 42 | 43 | // Skip if we aren't the main request 44 | if ($event->getRequestType() !== HttpKernelInterface::MASTER_REQUEST) { 45 | return; 46 | } 47 | 48 | // RateLimits used to be set by sensio/framework-extra-bundle by reading annotations 49 | // Tests also use that mechanism, we should probably keep it for retrocompatibility 50 | $request = $event->getRequest(); 51 | if ($request->attributes->has('_x-rate-limit')) { 52 | /** @var RateLimit[] $rateLimits */ 53 | $rateLimits = $request->attributes->get('_x-rate-limit', []); 54 | } else { 55 | $rateLimits = $this->getRateLimitsFromAttributes($event->getController()); 56 | } 57 | $rateLimit = $this->findBestMethodMatch($request, $rateLimits); 58 | 59 | // Another treatment before applying RateLimit ? 60 | $checkedRateLimitEvent = new CheckedRateLimitEvent($request, $rateLimit); 61 | $this->eventDispatcher->dispatch($checkedRateLimitEvent, RateLimitEvents::CHECKED_RATE_LIMIT); 62 | $rateLimit = $checkedRateLimitEvent->getRateLimit(); 63 | 64 | // No matching RateLimit found 65 | if (! $rateLimit) { 66 | return; 67 | } 68 | 69 | $key = $this->getKey($event, $rateLimit, $rateLimits); 70 | 71 | // Ratelimit the call 72 | $rateLimitInfo = $this->rateLimitService->limitRate($key); 73 | if (! $rateLimitInfo) { 74 | // Create new rate limit entry for this call 75 | $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); 76 | if (! $rateLimitInfo) { 77 | // @codeCoverageIgnoreStart 78 | return; 79 | // @codeCoverageIgnoreEnd 80 | } 81 | } 82 | 83 | 84 | // Store the current rating info in the request attributes 85 | $request->attributes->set('rate_limit_info', $rateLimitInfo); 86 | 87 | // Reset the rate limits 88 | if(time() >= $rateLimitInfo->getResetTimestamp()) { 89 | $this->rateLimitService->resetRate($key); 90 | $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); 91 | if (! $rateLimitInfo) { 92 | // @codeCoverageIgnoreStart 93 | return; 94 | // @codeCoverageIgnoreEnd 95 | } 96 | } 97 | 98 | // When we exceeded our limit, return a custom error response 99 | if ($rateLimitInfo->getCalls() > $rateLimitInfo->getLimit()) { 100 | 101 | // Throw an exception if configured. 102 | if ($this->getParameter('rate_response_exception')) { 103 | $class = $this->getParameter('rate_response_exception'); 104 | 105 | $e = new $class($this->getParameter('rate_response_message'), $this->getParameter('rate_response_code')); 106 | 107 | if ($e instanceof RateLimitExceptionInterface) { 108 | $e->setPayload($rateLimit->getPayload()); 109 | } 110 | 111 | throw $e; 112 | } 113 | 114 | $message = $this->getParameter('rate_response_message'); 115 | $code = $this->getParameter('rate_response_code'); 116 | $event->setController(function () use ($message, $code) { 117 | // @codeCoverageIgnoreStart 118 | return new Response($message, $code); 119 | // @codeCoverageIgnoreEnd 120 | }); 121 | $event->stopPropagation(); 122 | } 123 | 124 | } 125 | 126 | 127 | /** 128 | * @param RateLimit[] $rateLimits 129 | */ 130 | protected function findBestMethodMatch(Request $request, array $rateLimits): ?RateLimit 131 | { 132 | // Empty array, check the path limits 133 | if (count($rateLimits) === 0) { 134 | return $this->pathLimitProcessor->getRateLimit($request); 135 | } 136 | 137 | $best_match = null; 138 | foreach ($rateLimits as $rateLimit) { 139 | if (in_array($request->getMethod(), $rateLimit->getMethods(), true)) { 140 | $best_match = $rateLimit; 141 | } 142 | 143 | // Only match "default" annotation when we don't have a best match 144 | if ($best_match === null && count($rateLimit->methods) === 0) { 145 | $best_match = $rateLimit; 146 | } 147 | } 148 | 149 | return $best_match; 150 | } 151 | 152 | /** @param RateLimit[] $rateLimits */ 153 | private function getKey(ControllerEvent $event, RateLimit $rateLimit, array $rateLimits): string 154 | { 155 | // Let listeners manipulate the key 156 | $request = $event->getRequest(); 157 | $keyEvent = new GenerateKeyEvent($request, '', $rateLimit->getPayload()); 158 | 159 | $rateLimitMethods = implode('.', $rateLimit->getMethods()); 160 | $keyEvent->addToKey($rateLimitMethods); 161 | 162 | $rateLimitAlias = count($rateLimits) === 0 163 | ? str_replace('/', '.', $this->pathLimitProcessor->getMatchedPath($request)) 164 | : $this->getAliasForRequest($event); 165 | $keyEvent->addToKey($rateLimitAlias); 166 | $this->eventDispatcher->dispatch($keyEvent, RateLimitEvents::GENERATE_KEY); 167 | 168 | return $keyEvent->getKey(); 169 | } 170 | 171 | private function getAliasForRequest(ControllerEvent $event): string 172 | { 173 | $route = $event->getRequest()->attributes->get('_route'); 174 | if ($route) { 175 | return $route; 176 | } 177 | 178 | $controller = $event->getController(); 179 | 180 | if (is_string($controller) && str_contains($controller, '::')) { 181 | $controller = explode('::', $controller); 182 | } 183 | 184 | if (is_array($controller)) { 185 | return str_replace('\\', '.', is_string($controller[0]) ? $controller[0] : get_class($controller[0])) . '.' . $controller[1]; 186 | } 187 | 188 | if ($controller instanceof \Closure) { 189 | return 'closure'; 190 | } 191 | 192 | if (is_object($controller)) { 193 | return str_replace('\\', '.', get_class($controller[0])); 194 | } 195 | 196 | return 'other'; 197 | } 198 | 199 | /** 200 | * @return RateLimit[] 201 | */ 202 | private function getRateLimitsFromAttributes(string|array|object $controller): array 203 | { 204 | $rClass = $rMethod = null; 205 | if (\is_array($controller) && method_exists(...$controller)) { 206 | $rClass = new \ReflectionClass($controller[0]); 207 | $rMethod = new \ReflectionMethod($controller[0], $controller[1]); 208 | } elseif (\is_string($controller) && false !== $i = strpos($controller, '::')) { 209 | $rClass = new \ReflectionClass(substr($controller, 0, $i)); 210 | } elseif (\is_object($controller) && \is_callable([$controller, '__invoke'])) { 211 | $rMethod = new \ReflectionMethod($controller, '__invoke'); 212 | } else { 213 | $rMethod = new \ReflectionFunction($controller); 214 | } 215 | 216 | $attributes = []; 217 | foreach (array_merge($rClass?->getAttributes() ?? [], $rMethod?->getAttributes() ?? []) as $attribute) { 218 | if (RateLimit::class === $attribute->getName()) { 219 | $attributes[] = $attribute->newInstance(); 220 | } 221 | } 222 | 223 | return $attributes; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Events/CheckedRateLimitEvent.php: -------------------------------------------------------------------------------- 1 | request = $request; 18 | $this->rateLimit = $rateLimit; 19 | } 20 | 21 | public function getRateLimit(): ?RateLimit 22 | { 23 | return $this->rateLimit; 24 | } 25 | 26 | public function setRateLimit(?RateLimit $rateLimit = null): void 27 | { 28 | $this->rateLimit = $rateLimit; 29 | } 30 | 31 | public function getRequest(): Request 32 | { 33 | return $this->request; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Events/GenerateKeyEvent.php: -------------------------------------------------------------------------------- 1 | request = $request; 23 | $this->key = $key; 24 | $this->payload = $payload; 25 | } 26 | 27 | /** 28 | * @return string 29 | */ 30 | public function getKey() 31 | { 32 | return $this->key; 33 | } 34 | 35 | /** 36 | * @param string $key 37 | */ 38 | public function setKey($key) 39 | { 40 | $this->key = $key; 41 | } 42 | 43 | /** 44 | * @return Request 45 | */ 46 | public function getRequest() 47 | { 48 | return $this->request; 49 | } 50 | 51 | /** 52 | * @param $part 53 | */ 54 | public function addToKey($part) 55 | { 56 | if ($this->key) { 57 | $this->key .= '.'.$part; 58 | } else { 59 | $this->key = $part; 60 | } 61 | } 62 | 63 | /** 64 | * @return mixed 65 | */ 66 | public function getPayload() 67 | { 68 | return $this->payload; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Events/RateLimitEvents.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 47 | // .. 48 | ]; 49 | ``` 50 | 51 | ### Step 3: Install a storage engine 52 | 53 | #### Redis 54 | 55 | If you want to use Redis as your storage engine, you might want to install `SncRedisBundle`: 56 | 57 | * https://github.com/snc/SncRedisBundle 58 | 59 | #### Memcache 60 | 61 | If you want to use Memcache, you might want to install `LswMemcacheBundle` 62 | 63 | * https://github.com/LeaseWeb/LswMemcacheBundle 64 | 65 | #### Doctrine cache 66 | 67 | If you want to use Doctrine cache as your storage engine, you might want to install `DoctrineCacheBundle`: 68 | 69 | * https://github.com/doctrine/DoctrineCacheBundle 70 | 71 | Referer to their documentations for more details. You can change your storage engine with the `storage_engine` configuration parameter. See *Configuration reference*. 72 | 73 | ## Configuration 74 | 75 | ### Enable bundle only in production 76 | 77 | If you wish to enable the bundle only in production environment (so you can test without worrying about limit in your development environments), you can use the `enabled` configuration setting to enable/disable the bundle completely. It's enabled by default: 78 | 79 | ```yaml 80 | # config_dev.yml 81 | noxlogic_rate_limit: 82 | enabled: false 83 | ``` 84 | 85 | ### Configuration reference 86 | 87 | This is the default bundle configuration: 88 | 89 | ```yaml 90 | noxlogic_rate_limit: 91 | enabled: true 92 | 93 | # The storage engine where all the rates will be stored 94 | storage_engine: ~ # One of "redis"; "memcache"; "doctrine"; "php_redis"; "php_redis_cluster" 95 | 96 | # The redis client to use for the redis storage engine 97 | redis_client: default_client 98 | 99 | # The Redis service, use this if you dont use SncRedisBundle and want to specify a service to use 100 | # Should be instance of \Predis\Client 101 | redis_service: null # Example: project.predis 102 | 103 | # The Redis client to use for the php_redis storage engine 104 | # Depending on storage_engine an instance of \Redis or \RedisCluster 105 | php_redis_service: null # Example: project.redis 106 | 107 | # The memcache client to use for the memcache storage engine 108 | memcache_client: default 109 | 110 | # The Memcached service, use this if you dont use LswMemcacheBundle and want to specify a service to use 111 | # Should be instance of \Memcached 112 | memcache_service: null # Example: project.memcached 113 | 114 | # The Doctrine Cache provider to use for the doctrine storage engine 115 | doctrine_provider: null # Example: my_apc_cache 116 | 117 | # The Doctrine Cache service, use this if you dont use DoctrineCacheBundle and want to specify a service to use 118 | # Should be an instance of \Doctrine\Common\Cache\Cache 119 | doctrine_service: null # Example: project.my_apc_cache 120 | 121 | # The HTTP status code to return when a client hits the rate limit 122 | rate_response_code: 429 123 | 124 | # Optional exception class that will be returned when a client hits the rate limit 125 | rate_response_exception: null 126 | 127 | # The HTTP message to return when a client hits the rate limit 128 | rate_response_message: 'You exceeded the rate limit' 129 | 130 | # Should the ratelimit headers be automatically added to the response? 131 | display_headers: true 132 | 133 | # What are the different header names to add 134 | headers: 135 | limit: X-RateLimit-Limit 136 | remaining: X-RateLimit-Remaining 137 | reset: X-RateLimit-Reset 138 | 139 | # Rate limits for paths 140 | path_limits: 141 | path: ~ # Required 142 | methods: 143 | 144 | # Default: 145 | - * 146 | limit: ~ # Required 147 | period: ~ # Required 148 | 149 | # - { path: /api, limit: 1000, period: 3600 } 150 | # - { path: /dashboard, limit: 100, period: 3600, methods: ['GET', 'POST']} 151 | 152 | # Should the FOS OAuthServerBundle listener be enabled 153 | fos_oauth_key_listener: true 154 | ``` 155 | 156 | 157 | ## Usage 158 | 159 | ### Simple rate limiting 160 | 161 | To enable rate limiting, you only need to add the attribute to the specified action 162 | 163 | ```php 164 | generateKey(); 247 | 248 | $event->addToKey($key); 249 | // $event->setKey($key); // to overwrite key completely 250 | } 251 | } 252 | ``` 253 | 254 | Make sure to generate a key based on what is rate limited in your controllers. 255 | 256 | And example of a IP-based key generator can be: 257 | 258 | ```php 259 | getRequest(); 270 | $event->addToKey($request->getClientIp()); 271 | } 272 | } 273 | ``` 274 | 275 | 276 | ## Throwing exceptions 277 | 278 | Instead of returning a Response object when a rate limit has exceeded, it's also possible to throw an exception. This 279 | allows you to easily handle the rate limit on another level, for instance by capturing the ``kernel.exception`` event. 280 | 281 | 282 | ## Running tests 283 | 284 | If you want to run the tests use: 285 | 286 | ``` 287 | ./vendor/bin/simple-phpunit 288 | ``` 289 | -------------------------------------------------------------------------------- /Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Noxlogic\RateLimitBundle\EventListener\HeaderModificationListener 9 | Noxlogic\RateLimitBundle\EventListener\RateLimitAnnotationListener 10 | Noxlogic\RateLimitBundle\Service\RateLimitService 11 | Noxlogic\RateLimitBundle\EventListener\OauthKeyGenerateListener 12 | Noxlogic\RateLimitBundle\Util\PathLimitProcessor 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | %noxlogic_rate_limit.path_limits% 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | enabled 39 | %noxlogic_rate_limit.enabled% 40 | 41 | 42 | rate_response_code 43 | %noxlogic_rate_limit.rate_response_code% 44 | 45 | 46 | rate_response_message 47 | %noxlogic_rate_limit.rate_response_message% 48 | 49 | 50 | rate_response_exception 51 | %noxlogic_rate_limit.rate_response_exception% 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | rate_response_code 60 | %noxlogic_rate_limit.rate_response_code% 61 | 62 | 63 | display_headers 64 | %noxlogic_rate_limit.display_headers% 65 | 66 | 67 | header_limit_name 68 | %noxlogic_rate_limit.headers.limit.name% 69 | 70 | 71 | header_remaining_name 72 | %noxlogic_rate_limit.headers.remaining.name% 73 | 74 | 75 | header_reset_name 76 | %noxlogic_rate_limit.headers.reset.name% 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /Resources/doc/index.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaytaph/RateLimitBundle/07bdfaaec98eb02f2af862175a21b9cf3e6ee664/Resources/doc/index.rst -------------------------------------------------------------------------------- /Resources/meta/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 NoxLogic 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Resources/translations/messages.fr.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Symfony2 is great 7 | J'aime Symfony2 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Resources/views/Default/index.html.twig: -------------------------------------------------------------------------------- 1 | Hello {{ name }}! 2 | -------------------------------------------------------------------------------- /Service/RateLimitInfo.php: -------------------------------------------------------------------------------- 1 | limit; 17 | } 18 | 19 | /** 20 | * @param mixed $limit 21 | */ 22 | public function setLimit($limit) 23 | { 24 | $this->limit = $limit; 25 | } 26 | 27 | /** 28 | * @return mixed 29 | */ 30 | public function getCalls() 31 | { 32 | return $this->calls; 33 | } 34 | 35 | /** 36 | * @param mixed $calls 37 | */ 38 | public function setCalls($calls) 39 | { 40 | $this->calls = $calls; 41 | } 42 | 43 | /** 44 | * @return mixed 45 | */ 46 | public function getResetTimestamp() 47 | { 48 | return $this->resetTimestamp; 49 | } 50 | 51 | /** 52 | * @param mixed $resetTimestamp 53 | */ 54 | public function setResetTimestamp($resetTimestamp) 55 | { 56 | $this->resetTimestamp = $resetTimestamp; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Service/RateLimitService.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 20 | } 21 | 22 | /** 23 | * @return ?StorageInterface 24 | */ 25 | public function getStorage() 26 | { 27 | if (! $this->storage) { 28 | throw new \RuntimeException('Storage engine must be set prior to using the rate limit service'); 29 | } 30 | 31 | return $this->storage; 32 | } 33 | 34 | /** 35 | * 36 | */ 37 | public function limitRate($key) 38 | { 39 | return $this->storage->limitRate($key); 40 | } 41 | 42 | /** 43 | * 44 | */ 45 | public function createRate($key, $limit, $period) 46 | { 47 | return $this->storage->createRate($key, $limit, $period); 48 | } 49 | 50 | /** 51 | * 52 | */ 53 | public function resetRate($key) 54 | { 55 | return $this->storage->resetRate($key); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Service/Storage/DoctrineCache.php: -------------------------------------------------------------------------------- 1 | client = $client; 18 | } 19 | 20 | public function getRateInfo($key) 21 | { 22 | $info = $this->client->fetch($key); 23 | if ($info === false || !array_key_exists('limit', $info)) { 24 | return false; 25 | } 26 | 27 | return $this->createRateInfo($info); 28 | } 29 | 30 | public function limitRate($key) 31 | { 32 | $info = $this->client->fetch($key); 33 | if ($info === false || !array_key_exists('limit', $info)) { 34 | return false; 35 | } 36 | 37 | $info['calls']++; 38 | 39 | $expire = $info['reset'] - time(); 40 | 41 | $this->client->save($key, $info, $expire); 42 | 43 | return $this->createRateInfo($info); 44 | } 45 | 46 | public function createRate($key, $limit, $period) 47 | { 48 | $info = array(); 49 | $info['limit'] = $limit; 50 | $info['calls'] = 1; 51 | $info['reset'] = time() + $period; 52 | 53 | $this->client->save($key, $info, $period); 54 | 55 | return $this->createRateInfo($info); 56 | } 57 | 58 | public function resetRate($key) 59 | { 60 | $this->client->delete($key); 61 | 62 | return true; 63 | } 64 | 65 | private function createRateInfo(array $info) 66 | { 67 | $rateLimitInfo = new RateLimitInfo(); 68 | $rateLimitInfo->setLimit($info['limit']); 69 | $rateLimitInfo->setCalls($info['calls']); 70 | $rateLimitInfo->setResetTimestamp($info['reset']); 71 | 72 | return $rateLimitInfo; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Service/Storage/Memcache.php: -------------------------------------------------------------------------------- 1 | client = $client; 17 | } 18 | 19 | public function getRateInfo($key) 20 | { 21 | $info = $this->client->get($key); 22 | 23 | return $this->createRateInfo($info); 24 | } 25 | 26 | public function limitRate($key) 27 | { 28 | $cas = null; 29 | $i = 0; 30 | do { 31 | if (defined('Memcached::GET_EXTENDED')) { 32 | $_o = $this->client->get($key, null, \Memcached::GET_EXTENDED); 33 | $info = $_o['value'] ?? null; 34 | $cas = $_o['cas'] ?? null; 35 | } else { 36 | $info = $this->client->get($key, null, $cas); 37 | } 38 | if (!$info) { 39 | return false; 40 | } 41 | 42 | $info['calls']++; 43 | $this->client->cas($cas, $key, $info); 44 | } while ($this->client->getResultCode() == \Memcached::RES_DATA_EXISTS && $i++ < 5); 45 | 46 | return $this->createRateInfo($info); 47 | } 48 | 49 | public function createRate($key, $limit, $period) 50 | { 51 | $info = array(); 52 | $info['limit'] = $limit; 53 | $info['calls'] = 1; 54 | $info['reset'] = time() + $period; 55 | 56 | $this->client->set($key, $info, $period); 57 | 58 | return $this->createRateInfo($info); 59 | } 60 | 61 | public function resetRate($key) 62 | { 63 | $this->client->delete($key); 64 | return true; 65 | } 66 | 67 | private function createRateInfo(array $info) 68 | { 69 | $rateLimitInfo = new RateLimitInfo(); 70 | $rateLimitInfo->setLimit($info['limit']); 71 | $rateLimitInfo->setCalls($info['calls']); 72 | $rateLimitInfo->setResetTimestamp($info['reset']); 73 | 74 | return $rateLimitInfo; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Service/Storage/PhpRedis.php: -------------------------------------------------------------------------------- 1 | client = $client; 19 | } 20 | 21 | public function getRateInfo($key) 22 | { 23 | $key = $this->sanitizeRedisKey($key); 24 | 25 | $info = $this->client->hgetall($key); 26 | if (!isset($info['limit']) || !isset($info['calls']) || !isset($info['reset'])) { 27 | return false; 28 | } 29 | 30 | $rateLimitInfo = new RateLimitInfo(); 31 | $rateLimitInfo->setLimit($info['limit']); 32 | $rateLimitInfo->setCalls($info['calls']); 33 | $rateLimitInfo->setResetTimestamp($info['reset']); 34 | 35 | return $rateLimitInfo; 36 | } 37 | 38 | public function limitRate($key) 39 | { 40 | $key = $this->sanitizeRedisKey($key); 41 | 42 | $info = $this->getRateInfo($key); 43 | if (!$info) { 44 | return false; 45 | } 46 | 47 | $calls = $this->client->hincrby($key, 'calls', 1); 48 | $info->setCalls($calls); 49 | 50 | return $info; 51 | } 52 | 53 | public function createRate($key, $limit, $period) 54 | { 55 | $key = $this->sanitizeRedisKey($key); 56 | 57 | $reset = time() + $period; 58 | 59 | $this->client->hset($key, 'limit', $limit); 60 | $this->client->hset($key, 'calls', 1); 61 | $this->client->hset($key, 'reset', $reset); 62 | $this->client->expire($key, $period); 63 | 64 | $rateLimitInfo = new RateLimitInfo(); 65 | $rateLimitInfo->setLimit($limit); 66 | $rateLimitInfo->setCalls(1); 67 | $rateLimitInfo->setResetTimestamp($reset); 68 | 69 | return $rateLimitInfo; 70 | } 71 | 72 | public function resetRate($key) 73 | { 74 | $key = $this->sanitizeRedisKey($key); 75 | 76 | $this->client->del($key); 77 | 78 | return true; 79 | } 80 | 81 | /** 82 | * Sanitizies key so it can be used safely in REDIS 83 | * 84 | * @param $key 85 | * @return string|string[] 86 | */ 87 | protected function sanitizeRedisKey($key) { 88 | return str_replace(str_split('@{}()/\:'), '_', $key); 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /Service/Storage/PhpRedisCluster.php: -------------------------------------------------------------------------------- 1 | client = $client; 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /Service/Storage/PsrCache.php: -------------------------------------------------------------------------------- 1 | client = $client; 19 | } 20 | 21 | public function getRateInfo($key) 22 | { 23 | $item = $this->client->getItem($key); 24 | if (!$item->isHit()) { 25 | return false; 26 | } 27 | 28 | return $this->createRateInfo($item->get()); 29 | } 30 | 31 | public function limitRate($key) 32 | { 33 | $item = $this->client->getItem($key); 34 | if (!$item->isHit()) { 35 | return false; 36 | } 37 | 38 | $info = $item->get(); 39 | 40 | $info['calls']++; 41 | $item->set($info); 42 | $item->expiresAfter($info['reset'] - time()); 43 | 44 | $this->client->save($item); 45 | 46 | return $this->createRateInfo($info); 47 | } 48 | 49 | public function createRate($key, $limit, $period) 50 | { 51 | $info = [ 52 | 'limit' => $limit, 53 | 'calls' => 1, 54 | 'reset' => time() + $period, 55 | ]; 56 | $item = $this->client->getItem($key); 57 | $item->set($info); 58 | $item->expiresAfter($period); 59 | 60 | $this->client->save($item); 61 | 62 | return $this->createRateInfo($info); 63 | } 64 | 65 | public function resetRate($key) 66 | { 67 | $this->client->deleteItem($key); 68 | 69 | return true; 70 | } 71 | 72 | private function createRateInfo(array $info) 73 | { 74 | $rateLimitInfo = new RateLimitInfo(); 75 | $rateLimitInfo->setLimit($info['limit']); 76 | $rateLimitInfo->setCalls($info['calls']); 77 | $rateLimitInfo->setResetTimestamp($info['reset']); 78 | 79 | return $rateLimitInfo; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Service/Storage/Redis.php: -------------------------------------------------------------------------------- 1 | client = $client; 18 | } 19 | 20 | public function getRateInfo($key) 21 | { 22 | $key = $this->sanitizeRedisKey($key); 23 | 24 | $info = $this->client->hgetall($key); 25 | if (!isset($info['limit']) || !isset($info['calls']) || !isset($info['reset'])) { 26 | return false; 27 | } 28 | 29 | $rateLimitInfo = new RateLimitInfo(); 30 | $rateLimitInfo->setLimit($info['limit']); 31 | $rateLimitInfo->setCalls($info['calls']); 32 | $rateLimitInfo->setResetTimestamp($info['reset']); 33 | 34 | return $rateLimitInfo; 35 | } 36 | 37 | public function limitRate($key) 38 | { 39 | $key = $this->sanitizeRedisKey($key); 40 | 41 | $info = $this->getRateInfo($key); 42 | if (!$info) { 43 | return false; 44 | } 45 | 46 | $calls = $this->client->hincrby($key, 'calls', 1); 47 | $info->setCalls($calls); 48 | 49 | return $info; 50 | } 51 | 52 | public function createRate($key, $limit, $period) 53 | { 54 | $key = $this->sanitizeRedisKey($key); 55 | 56 | $reset = time() + $period; 57 | 58 | $this->client->hset($key, 'limit', (string) $limit); 59 | $this->client->hset($key, 'calls', '1'); 60 | $this->client->hset($key, 'reset', (string) $reset); 61 | $this->client->expire($key, $period); 62 | 63 | $rateLimitInfo = new RateLimitInfo(); 64 | $rateLimitInfo->setLimit($limit); 65 | $rateLimitInfo->setCalls(1); 66 | $rateLimitInfo->setResetTimestamp($reset); 67 | 68 | return $rateLimitInfo; 69 | } 70 | 71 | public function resetRate($key) 72 | { 73 | $key = $this->sanitizeRedisKey($key); 74 | 75 | $this->client->del($key); 76 | 77 | return true; 78 | } 79 | 80 | /** 81 | * Sanitizies key so it can be used safely in REDIS 82 | * 83 | * @param $key 84 | * @return string|string[] 85 | */ 86 | protected function sanitizeRedisKey($key) { 87 | return str_replace(str_split('@{}()/\:'), '_', $key); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /Service/Storage/SimpleCache.php: -------------------------------------------------------------------------------- 1 | client = $client; 18 | } 19 | 20 | public function getRateInfo($key) 21 | { 22 | $info = $this->client->get($key); 23 | if ($info === null || !array_key_exists('limit', $info)) { 24 | return false; 25 | } 26 | 27 | return $this->createRateInfo($info); 28 | } 29 | 30 | public function limitRate($key) 31 | { 32 | $info = $this->client->get($key); 33 | if ($info === null || !array_key_exists('limit', $info)) { 34 | return false; 35 | } 36 | 37 | $info['calls']++; 38 | $ttl = $info['reset'] - time(); 39 | 40 | $this->client->set($key, $info, $ttl); 41 | 42 | return $this->createRateInfo($info); 43 | } 44 | 45 | public function createRate($key, $limit, $period) 46 | { 47 | $info = [ 48 | 'limit' => $limit, 49 | 'calls' => 1, 50 | 'reset' => time() + $period, 51 | ]; 52 | $this->client->set($key, $info, $period); 53 | 54 | return $this->createRateInfo($info); 55 | } 56 | 57 | public function resetRate($key) 58 | { 59 | $this->client->delete($key); 60 | 61 | return true; 62 | } 63 | 64 | private function createRateInfo(array $info) 65 | { 66 | $rateLimitInfo = new RateLimitInfo(); 67 | $rateLimitInfo->setLimit($info['limit']); 68 | $rateLimitInfo->setCalls($info['calls']); 69 | $rateLimitInfo->setResetTimestamp($info['reset']); 70 | 71 | return $rateLimitInfo; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Service/Storage/StorageInterface.php: -------------------------------------------------------------------------------- 1 | assertEquals(-1, $attribute->limit); 15 | $this->assertEmpty($attribute->methods); 16 | $this->assertEquals(3600, $attribute->period); 17 | } 18 | 19 | public function testConstructionWithValues(): void 20 | { 21 | $attribute = new RateLimit( 22 | [], 23 | 1234, 24 | 1000 25 | ); 26 | $this->assertEquals(1234, $attribute->limit); 27 | $this->assertEquals(1000, $attribute->period); 28 | 29 | $attribute = new RateLimit( 30 | ['POST'], 31 | 1234, 32 | 1000 33 | ); 34 | $this->assertEquals(1234, $attribute->limit); 35 | $this->assertEquals(1000, $attribute->period); 36 | $this->assertEquals(['POST'], $attribute->methods); 37 | } 38 | 39 | public function testConstructionWithMethods(): void 40 | { 41 | $attribute = new RateLimit( 42 | ['POST', 'GET'], 43 | 1234, 44 | 1000 45 | ); 46 | $this->assertCount(2, $attribute->methods); 47 | } 48 | 49 | public function testConstructWithStringAsMethods(): void 50 | { 51 | $attribute = new RateLimit( 52 | 'POST', 53 | 1234, 54 | 1000 55 | ); 56 | $this->assertEquals(['POST'], $attribute->methods); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/DependencyInjection/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | processor = new Processor(); 23 | } 24 | 25 | private function getConfigs(array $configArray) 26 | { 27 | $configuration = new Configuration(); 28 | 29 | return $this->processor->processConfiguration($configuration, array($configArray)); 30 | } 31 | 32 | public function testUnconfiguredConfiguration() 33 | { 34 | $configuration = $this->getConfigs(array()); 35 | 36 | $this->assertSame(array( 37 | 'enabled' => true, 38 | 'storage_engine' => 'redis', 39 | 'redis_client' => 'default_client', 40 | 'redis_service' => null, 41 | 'php_redis_service' => null, 42 | 'memcache_client' => 'default', 43 | 'memcache_service' => null, 44 | 'doctrine_provider' => null, 45 | 'doctrine_service' => null, 46 | 'simple_cache_service' => null, 47 | 'cache_service' => null, 48 | 'rate_response_code' => 429, 49 | 'rate_response_exception' => null, 50 | 'rate_response_message' => 'You exceeded the rate limit', 51 | 'display_headers' => true, 52 | 'headers' => array( 53 | 'limit' => 'X-RateLimit-Limit', 54 | 'remaining' => 'X-RateLimit-Remaining', 55 | 'reset' => 'X-RateLimit-Reset', 56 | ), 57 | 'path_limits' => array(), 58 | 'fos_oauth_key_listener' => true 59 | ), $configuration); 60 | } 61 | 62 | public function testDisabledConfiguration() 63 | { 64 | $configuration = $this->getConfigs(array('enabled' => false)); 65 | 66 | $this->assertArrayHasKey('enabled', $configuration); 67 | $this->assertFalse($configuration['enabled']); 68 | } 69 | 70 | public function testPathLimitConfiguration() 71 | { 72 | $pathLimits = array( 73 | 'api' => array( 74 | 'path' => 'api/', 75 | 'methods' => array('GET'), 76 | 'limit' => 100, 77 | 'period' => 60 78 | ) 79 | ); 80 | 81 | $configuration = $this->getConfigs(array( 82 | 'path_limits' => $pathLimits 83 | )); 84 | 85 | $this->assertArrayHasKey('path_limits', $configuration); 86 | $this->assertEquals($pathLimits, $configuration['path_limits']); 87 | } 88 | 89 | public function testMultiplePathLimitConfiguration() 90 | { 91 | $pathLimits = array( 92 | 'api' => array( 93 | 'path' => 'api/', 94 | 'methods' => array('GET', 'POST'), 95 | 'limit' => 200, 96 | 'period' => 10 97 | ), 98 | 'api2' => array( 99 | 'path' => 'api2/', 100 | 'methods' => array('*'), 101 | 'limit' => 1000, 102 | 'period' => 15 103 | ) 104 | ); 105 | 106 | $configuration = $this->getConfigs(array( 107 | 'path_limits' => $pathLimits 108 | )); 109 | 110 | $this->assertArrayHasKey('path_limits', $configuration); 111 | $this->assertEquals($pathLimits, $configuration['path_limits']); 112 | } 113 | 114 | public function testDefaultPathLimitMethods() 115 | { 116 | $pathLimits = array( 117 | 'api' => array( 118 | 'path' => 'api/', 119 | 'methods' => array('GET', 'POST'), 120 | 'limit' => 200, 121 | 'period' => 10 122 | ), 123 | 'api2' => array( 124 | 'path' => 'api2/', 125 | 'limit' => 1000, 126 | 'period' => 15 127 | ) 128 | ); 129 | 130 | $configuration = $this->getConfigs(array( 131 | 'path_limits' => $pathLimits 132 | )); 133 | 134 | $pathLimits['api2']['methods'] = array('*'); 135 | 136 | $this->assertArrayHasKey('path_limits', $configuration); 137 | $this->assertEquals($pathLimits, $configuration['path_limits']); 138 | } 139 | 140 | public function testMustBeBasedOnExceptionClass() 141 | { 142 | $this->expectException(InvalidConfigurationException::class); 143 | $configuration = $this->getConfigs(array('rate_response_exception' => '\StdClass')); 144 | } 145 | 146 | /** 147 | * 148 | */ 149 | public function testMustBeBasedOnExceptionClass2() 150 | { 151 | $configuration = $this->getConfigs(array('rate_response_exception' => '\InvalidArgumentException')); 152 | 153 | # no exception triggered is ok. 154 | $this->assertTrue(true); 155 | } 156 | 157 | public function testMustBeBasedOnExceptionOrNull() 158 | { 159 | $configuration = $this->getConfigs(array('rate_response_exception' => null)); 160 | 161 | # no exception triggered is ok. 162 | $this->assertTrue(true); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Tests/DependencyInjection/NoxlogicRateLimitExtensionTest.php: -------------------------------------------------------------------------------- 1 | load(array(), $containerBuilder); 24 | 25 | $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.enabled'), true); 26 | $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.rate_response_code'), 429); 27 | $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.display_headers'), true); 28 | $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.headers.reset.name'), 'X-RateLimit-Reset'); 29 | } 30 | 31 | public function testStorageEngineParameterProvider() 32 | { 33 | $extension = new NoxlogicRateLimitExtension(); 34 | $containerBuilder = new ContainerBuilder(new ParameterBag()); 35 | $extension->load(array( 36 | 'noxlogic_rate_limit' => array( 37 | 'storage_engine' => 'doctrine', 38 | 'doctrine_provider' => 'redis_cache', 39 | ) 40 | ), $containerBuilder); 41 | 42 | $this->assertEquals('Noxlogic\RateLimitBundle\Service\Storage\DoctrineCache', $containerBuilder->getParameter('noxlogic_rate_limit.storage.class')); 43 | 44 | $storageDef = $containerBuilder->getDefinition('noxlogic_rate_limit.storage'); 45 | $this->assertEquals('doctrine_cache.providers.redis_cache', (string)($storageDef->getArgument(0))); 46 | } 47 | 48 | public function testStorageEngineParameterService() 49 | { 50 | $extension = new NoxlogicRateLimitExtension(); 51 | $containerBuilder = new ContainerBuilder(new ParameterBag()); 52 | $extension->load(array( 53 | 'noxlogic_rate_limit' => array( 54 | 'storage_engine' => 'doctrine', 55 | 'doctrine_service' => 'my.redis_cache', 56 | ) 57 | ), $containerBuilder); 58 | 59 | $this->assertEquals('Noxlogic\RateLimitBundle\Service\Storage\DoctrineCache', $containerBuilder->getParameter('noxlogic_rate_limit.storage.class')); 60 | 61 | $storageDef = $containerBuilder->getDefinition('noxlogic_rate_limit.storage'); 62 | $this->assertEquals('my.redis_cache', (string)($storageDef->getArgument(0))); 63 | } 64 | 65 | public function testParametersWhenDisabled() 66 | { 67 | $extension = new NoxlogicRateLimitExtension(); 68 | $containerBuilder = new ContainerBuilder(new ParameterBag()); 69 | $extension->load(array('enabled' => false), $containerBuilder); 70 | 71 | $this->assertEquals(429, $containerBuilder->getParameter('noxlogic_rate_limit.rate_response_code')); 72 | } 73 | 74 | public function testPathLimitsParameter() 75 | { 76 | $pathLimits = array( 77 | 'api' => array( 78 | 'path' => 'api/', 79 | 'methods' => array('GET'), 80 | 'limit' => 100, 81 | 'period' => 60 82 | ) 83 | ); 84 | 85 | $extension = new NoxlogicRateLimitExtension(); 86 | $containerBuilder = new ContainerBuilder(new ParameterBag()); 87 | $extension->load(array(array('path_limits' => $pathLimits)), $containerBuilder); 88 | 89 | $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.path_limits'), $pathLimits); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/EventListener/BaseListenerTest.php: -------------------------------------------------------------------------------- 1 | setParameter('foo', 'bar'); 14 | $this->assertEquals('bar', $base->getParameter('foo')); 15 | 16 | $base->setParameter('foo', 'baz'); 17 | $this->assertEquals('baz', $base->getParameter('foo')); 18 | } 19 | 20 | public function testDefaultValues() 21 | { 22 | $base = new MockListener(); 23 | 24 | $base->setParameter('foo', 'bar'); 25 | $this->assertEquals('baz', $base->getParameter('doesnotexist', 'baz')); 26 | 27 | $this->assertEquals('bar', $base->getParameter('foo', 'baz')); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/EventListener/HeaderModificationListenerTest.php: -------------------------------------------------------------------------------- 1 | createEvent(); 20 | 21 | $listener = new HeaderModificationListener(); 22 | $listener->setParameter('display_headers', true); 23 | $listener->setParameter('header_limit_name', 'X-RateLimit-Limit'); 24 | $listener->setParameter('header_remaining_name', 'X-RateLimit-Remaining'); 25 | $listener->setParameter('header_reset_name', 'X-RateLimit-Reset'); 26 | $listener->onKernelResponse($event); 27 | 28 | $this->assertFalse($event->getResponse()->headers->has('X-RateLimit-Limit')); 29 | $this->assertFalse($event->getResponse()->headers->has('X-RateLimit-Reset')); 30 | $this->assertFalse($event->getResponse()->headers->has('X-RateLimit-Remaining')); 31 | } 32 | 33 | 34 | public function testListenerWithInfo() 35 | { 36 | $rateLimitInfo = new RateLimitInfo(); 37 | $rateLimitInfo->setCalls(5); 38 | $rateLimitInfo->setLimit(10); 39 | $rateLimitInfo->setResetTimestamp(1520000); 40 | 41 | $event = $this->createEvent(); 42 | $event->getRequest()->attributes->set('rate_limit_info', $rateLimitInfo); 43 | 44 | $listener = new HeaderModificationListener(); 45 | $listener->setParameter('display_headers', true); 46 | $listener->setParameter('header_limit_name', 'X-RateLimit-Limit'); 47 | $listener->setParameter('header_remaining_name', 'X-RateLimit-Remaining'); 48 | $listener->setParameter('header_reset_name', 'X-RateLimit-Reset'); 49 | $listener->onKernelResponse($event); 50 | 51 | $this->assertEquals(10, $event->getResponse()->headers->has('X-RateLimit-Limit')); 52 | $this->assertEquals(5, $event->getResponse()->headers->has('X-RateLimit-Remaining')); 53 | $this->assertEquals(1520000, $event->getResponse()->headers->has('X-RateLimit-Reset')); 54 | } 55 | 56 | public function testListenerWithDisplayHeaderFalse() 57 | { 58 | $rateLimitInfo = new RateLimitInfo(); 59 | $rateLimitInfo->setCalls(5); 60 | $rateLimitInfo->setLimit(10); 61 | $rateLimitInfo->setResetTimestamp(1520000); 62 | 63 | $event = $this->createEvent(); 64 | $event->getRequest()->attributes->set('rate_limit_info', $rateLimitInfo); 65 | 66 | $listener = new HeaderModificationListener(); 67 | $listener->setParameter('display_headers', false); 68 | $listener->setParameter('header_limit_name', 'X-RateLimit-Limit'); 69 | $listener->setParameter('header_remaining_name', 'X-RateLimit-Remaining'); 70 | $listener->setParameter('header_reset_name', 'X-RateLimit-Reset'); 71 | $listener->onKernelResponse($event); 72 | 73 | $this->assertFalse($event->getResponse()->headers->has('X-RateLimit-Limit')); 74 | $this->assertFalse($event->getResponse()->headers->has('X-RateLimit-Reset')); 75 | $this->assertFalse($event->getResponse()->headers->has('X-RateLimit-Remaining')); 76 | } 77 | 78 | public function testListenerWithCustomHeaders() 79 | { 80 | $rateLimitInfo = new RateLimitInfo(); 81 | $rateLimitInfo->setCalls(5); 82 | $rateLimitInfo->setLimit(10); 83 | $rateLimitInfo->setResetTimestamp(1520000); 84 | 85 | $event = $this->createEvent(); 86 | $event->getRequest()->attributes->set('rate_limit_info', $rateLimitInfo); 87 | 88 | $listener = new HeaderModificationListener(); 89 | $listener->setParameter('display_headers', true); 90 | $listener->setParameter('header_limit_name', 'foo'); 91 | $listener->setParameter('header_remaining_name', 'bar'); 92 | $listener->setParameter('header_reset_name', 'baz'); 93 | $listener->onKernelResponse($event); 94 | 95 | $this->assertTrue($event->getResponse()->headers->has('foo')); 96 | $this->assertTrue($event->getResponse()->headers->has('bar')); 97 | $this->assertTrue($event->getResponse()->headers->has('baz')); 98 | } 99 | 100 | public function testListenerRemainingCannotBeNegative() 101 | { 102 | $rateLimitInfo = new RateLimitInfo(); 103 | $rateLimitInfo->setCalls(500); 104 | $rateLimitInfo->setLimit(10); 105 | $rateLimitInfo->setResetTimestamp(1520000); 106 | 107 | $event = $this->createEvent(); 108 | $event->getRequest()->attributes->set('rate_limit_info', $rateLimitInfo); 109 | 110 | $listener = new HeaderModificationListener(); 111 | $listener->setParameter('display_headers', true); 112 | $listener->setParameter('header_limit_name', 'X-RateLimit-Limit'); 113 | $listener->setParameter('header_remaining_name', 'X-RateLimit-Remaining'); 114 | $listener->setParameter('header_reset_name', 'X-RateLimit-Reset'); 115 | $listener->onKernelResponse($event); 116 | 117 | $this->assertEquals(0, $event->getResponse()->headers->get('X-RateLimit-Remaining')); 118 | } 119 | 120 | public function testListenerWithoutRateInfo() 121 | { 122 | $event = $this->createEvent(); 123 | 124 | $listener = new HeaderModificationListener(); 125 | $listener->setParameter('display_headers', true); 126 | $listener->setParameter('header_limit_name', 'X-RateLimit-Limit'); 127 | $listener->setParameter('header_remaining_name', 'X-RateLimit-Remaining'); 128 | $listener->setParameter('header_reset_name', 'X-RateLimit-Reset'); 129 | $listener->onKernelResponse($event); 130 | 131 | $this->assertFalse($event->getResponse()->headers->has('X-RateLimit-Limit')); 132 | $this->assertFalse($event->getResponse()->headers->has('X-RateLimit-Reset')); 133 | $this->assertFalse($event->getResponse()->headers->has('X-RateLimit-Remaining')); 134 | } 135 | 136 | /** 137 | * @return ResponseEvent 138 | */ 139 | protected function createEvent() 140 | { 141 | $kernel = $this->getMockBuilder(HttpKernelInterface::class)->getMock(); 142 | $request = new Request(); 143 | $response = new Response(); 144 | 145 | $event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response); 146 | 147 | return $event; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Tests/EventListener/MockController.php: -------------------------------------------------------------------------------- 1 | rates[$key]; 21 | 22 | $rateLimitInfo = new RateLimitInfo(); 23 | $rateLimitInfo->setCalls($info['calls']); 24 | $rateLimitInfo->setResetTimestamp($info['reset']); 25 | $rateLimitInfo->setLimit($info['limit']); 26 | return $rateLimitInfo; 27 | } 28 | 29 | /** 30 | * Limit the rate by one 31 | * 32 | * @param string $key 33 | * @return RateLimitInfo Rate limit info 34 | */ 35 | public function limitRate($key) 36 | { 37 | if (! isset($this->rates[$key])) { 38 | return null; 39 | } 40 | 41 | $this->rates[$key]['calls']++; 42 | return $this->getRateInfo($key); 43 | } 44 | 45 | /** 46 | * Create a new rate entry 47 | * 48 | * @param string $key 49 | * @param integer $limit 50 | * @param integer $period 51 | * @return \Noxlogic\RateLimitBundle\Service\RateLimitInfo 52 | */ 53 | public function createRate($key, $limit, $period) 54 | { 55 | $this->rates[$key] = array('calls' => 1, 'limit' => $limit, 'reset' => (time() + $period)); 56 | return $this->getRateInfo($key); 57 | } 58 | 59 | /** 60 | * Reset the rating 61 | * 62 | * @param $key 63 | */ 64 | public function resetRate($key) 65 | { 66 | unset($this->rates[$key]); 67 | } 68 | 69 | public function createMockRate($key, $limit, $period, $calls) 70 | { 71 | $this->rates[$key] = array('calls' => $calls, 'limit' => $limit, 'reset' => (time() + $period)); 72 | return $this->getRateInfo($key); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Tests/EventListener/OauthKeyGenerateListenerTest.php: -------------------------------------------------------------------------------- 1 | mockContext = $this->getMockBuilder('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface')->getMock(); 17 | } else { 18 | $this->mockContext = $this->getMockBuilder('Symfony\Component\Security\Core\SecurityContextInterface')->getMock(); 19 | } 20 | 21 | if (! class_exists('FOS\\OAuthServerBundle\\Security\\Authentication\\Token\\OAuthToken')) { 22 | $this->markTestSkipped("OAuth not found"); 23 | } 24 | } 25 | 26 | public function testListener() 27 | { 28 | $mockToken = $this->createMockToken(); 29 | 30 | $mockContext = $this->mockContext; 31 | $mockContext 32 | ->expects($this->any()) 33 | ->method('getToken') 34 | ->will($this->returnValue($mockToken)); 35 | 36 | $event = new GenerateKeyEvent(new Request(), 'foo'); 37 | 38 | $listener = new OauthKeyGenerateListener($mockContext); 39 | $listener->onGenerateKey($event); 40 | 41 | $this->assertEquals('foo.mocktoken', $event->getKey()); 42 | } 43 | 44 | public function testListenerWithoutOAuthToken() 45 | { 46 | $mockContext = $this->mockContext; 47 | $mockContext 48 | ->expects($this->any()) 49 | ->method('getToken') 50 | ->will($this->returnValue(new \StdClass())); 51 | 52 | $event = new GenerateKeyEvent(new Request(), 'foo'); 53 | 54 | $listener = new OauthKeyGenerateListener($mockContext); 55 | $listener->onGenerateKey($event); 56 | 57 | $this->assertEquals('foo', $event->getKey()); 58 | } 59 | 60 | private function createMockToken() 61 | { 62 | $oauthToken = $this->getMockBuilder('FOS\\OAuthServerBundle\\Security\\Authentication\\Token\\OAuthToken')->getMock(); 63 | $oauthToken 64 | ->expects($this->any()) 65 | ->method('getToken') 66 | ->will($this->returnValue('mocktoken')) 67 | ; 68 | 69 | return $oauthToken; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/EventListener/RateLimitAnnotationListenerTest.php: -------------------------------------------------------------------------------- 1 | mockStorage = new MockStorage(); 30 | $this->mockPathLimitProcessor = $this->getMockBuilder('Noxlogic\RateLimitBundle\Util\PathLimitProcessor') 31 | ->disableOriginalConstructor() 32 | ->getMock(); 33 | } 34 | 35 | protected function getMockStorage() 36 | { 37 | return $this->mockStorage; 38 | } 39 | 40 | 41 | public function testReturnedWhenNotEnabled(): void 42 | { 43 | $listener = $this->createListener($this->never()); 44 | $listener->setParameter('enabled', false); 45 | 46 | $event = $this->createEvent(); 47 | $listener->onKernelController($event); 48 | } 49 | 50 | 51 | public function testReturnedWhenNotAMasterRequest(): void 52 | { 53 | $listener = $this->createListener($this->never()); 54 | 55 | $event = $this->createEvent(HttpKernelInterface::SUB_REQUEST); 56 | $listener->onKernelController($event); 57 | } 58 | 59 | 60 | public function testReturnedWhenNoControllerFound(): void 61 | { 62 | $listener = $this->createListener($this->once()); 63 | 64 | $kernel = $this->getMockBuilder('Symfony\\Component\\HttpKernel\\HttpKernelInterface')->getMock(); 65 | $request = new Request(); 66 | 67 | $event = new ControllerEvent($kernel, static function() {}, $request, HttpKernelInterface::MASTER_REQUEST); 68 | 69 | $listener->onKernelController($event); 70 | } 71 | 72 | 73 | public function testReturnedWhenNoAttributesFound(): void 74 | { 75 | $listener = $this->createListener($this->once()); 76 | 77 | $event = $this->createEvent(); 78 | $listener->onKernelController($event); 79 | } 80 | 81 | public function testDelegatesToPathLimitProcessorWhenNoAttributesFound(): void 82 | { 83 | $request = new Request(); 84 | $event = $this->createEvent(HttpKernelInterface::MASTER_REQUEST, $request); 85 | 86 | $listener = $this->createListener($this->once()); 87 | 88 | $this->mockPathLimitProcessor->expects($this->once()) 89 | ->method('getRateLimit') 90 | ->with($request); 91 | 92 | $listener->onKernelController($event); 93 | } 94 | 95 | public function testDispatchIsCalled(): void 96 | { 97 | $listener = $this->createListener($this->exactly(2)); 98 | 99 | $event = $this->createEvent(); 100 | $event->getRequest()->attributes->set('_x-rate-limit', array( 101 | new RateLimit([], 100, 3600), 102 | )); 103 | 104 | $listener->onKernelController($event); 105 | } 106 | 107 | public function testDispatchIsCalledWithAttributes(): void 108 | { 109 | $listener = $this->createListener($this->exactly(2)); 110 | 111 | $event = $this->createEvent( 112 | HttpKernelInterface::MASTER_REQUEST, 113 | null, 114 | new MockControllerWithAttributes() 115 | ); 116 | 117 | $listener->onKernelController($event); 118 | } 119 | 120 | public function testDispatchIsCalledIfThePathLimitProcessorReturnsARateLimit(): void 121 | { 122 | $event = $this->createEvent(HttpKernelInterface::MASTER_REQUEST); 123 | 124 | $listener = $this->createListener($this->exactly(2)); 125 | $rateLimit = new RateLimit( 126 | [], 127 | 100, 128 | 200 129 | ); 130 | 131 | $this->mockPathLimitProcessor 132 | ->expects($this->any()) 133 | ->method('getRateLimit') 134 | ->willReturn($rateLimit); 135 | 136 | $listener->onKernelController($event); 137 | } 138 | 139 | public function testIsRateLimitSetInRequest(): void 140 | { 141 | $listener = $this->createListener($this->any()); 142 | 143 | $event = $this->createEvent(); 144 | $event->getRequest()->attributes->set('_x-rate-limit', array( 145 | new RateLimit([], 5, 10), 146 | )); 147 | 148 | 149 | $this->assertNull($event->getRequest()->attributes->get('rate_limit_info')); 150 | 151 | // Create initial ratelimit in storage 152 | $listener->onKernelController($event); 153 | $this->assertArrayHasKey('rate_limit_info', $event->getRequest()->attributes->all()); 154 | 155 | // Add second ratelimit in storage 156 | $listener->onKernelController($event); 157 | $this->assertArrayHasKey('rate_limit_info', $event->getRequest()->attributes->all()); 158 | } 159 | 160 | public function testRateLimit(): void 161 | { 162 | $listener = $this->createListener($this->any()); 163 | 164 | $event = $this->createEvent(); 165 | $event->getRequest()->attributes->set('_x-rate-limit', array( 166 | new RateLimit([], 5, 5), 167 | )); 168 | 169 | $listener->onKernelController($event); 170 | self::assertIsArray($event->getController()); 171 | $listener->onKernelController($event); 172 | self::assertIsArray( $event->getController()); 173 | $listener->onKernelController($event); 174 | self::assertIsArray($event->getController()); 175 | $listener->onKernelController($event); 176 | self::assertIsArray($event->getController()); 177 | $listener->onKernelController($event); 178 | self::assertIsArray($event->getController()); 179 | $listener->onKernelController($event); 180 | self::assertIsNotArray($event->getController()); 181 | $listener->onKernelController($event); 182 | self::assertIsNotArray($event->getController()); 183 | $listener->onKernelController($event); 184 | self::assertIsNotArray($event->getController()); 185 | } 186 | 187 | public function testRateLimitThrottling(): void 188 | { 189 | $listener = $this->createListener($this->any()); 190 | 191 | $event = $this->createEvent(); 192 | $event->getRequest()->attributes->set('_x-rate-limit', array( 193 | new RateLimit([], 5, 3), 194 | )); 195 | 196 | // Throttled 197 | $storage = $this->getMockStorage(); 198 | $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, 10, 6); 199 | $listener->onKernelController($event); 200 | self::assertIsNotArray($event->getController()); 201 | } 202 | 203 | public function testRateLimitExpiring(): void 204 | { 205 | $listener = $this->createListener($this->any()); 206 | 207 | $event = $this->createEvent(); 208 | $event->getRequest()->attributes->set('_x-rate-limit', array( 209 | new RateLimit([], 5, 3), 210 | )); 211 | 212 | // Expired 213 | $storage = $this->getMockStorage(); 214 | $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, -10, 12); 215 | $listener->onKernelController($event); 216 | self::assertIsArray($event->getController()); 217 | } 218 | 219 | public function testBestMethodMatch(): void 220 | { 221 | $listener = $this->createListener($this->any()); 222 | $method = new ReflectionMethod(get_class($listener), 'findBestMethodMatch'); 223 | $method->setAccessible(true); 224 | 225 | $request = new Request(); 226 | 227 | $annotations = array( 228 | new RateLimit([], 100, 3600), 229 | new RateLimit('GET', 100, 3600), 230 | new RateLimit(['POST', 'PUT'], 100, 3600), 231 | ); 232 | 233 | // Find the method that matches the string 234 | $request->setMethod('GET'); 235 | $this->assertEquals( 236 | $annotations[1], 237 | $method->invoke($listener, $request, $annotations) 238 | ); 239 | 240 | // Method not found, use the default one 241 | $request->setMethod('DELETE'); 242 | $this->assertEquals( 243 | $annotations[0], 244 | $method->invoke($listener, $request, $annotations) 245 | ); 246 | 247 | // Find best match based in methods in array 248 | $request->setMethod('PUT'); 249 | $this->assertEquals( 250 | $annotations[2], 251 | $method->invoke($listener, $request, $annotations) 252 | ); 253 | } 254 | 255 | 256 | public function testFindNoAttributes(): void 257 | { 258 | $listener = $this->createListener($this->any()); 259 | $method = new ReflectionMethod(get_class($listener), 'findBestMethodMatch'); 260 | $method->setAccessible(true); 261 | 262 | $request = new Request(); 263 | 264 | $annotations = array(); 265 | 266 | $request->setMethod('PUT'); 267 | $this->assertNull($method->invoke($listener, $request, $annotations)); 268 | 269 | $request->setMethod('GET'); 270 | $this->assertNull($method->invoke($listener, $request, $annotations)); 271 | } 272 | 273 | 274 | public function testFindBestMethodMatchNotMatchingAnnotations(): void 275 | { 276 | $listener = $this->createListener($this->any()); 277 | $method = new ReflectionMethod(get_class($listener), 'findBestMethodMatch'); 278 | $method->setAccessible(true); 279 | 280 | $request = new Request(); 281 | 282 | $annotations = array( 283 | new RateLimit('GET', 100, 3600), 284 | ); 285 | 286 | $request->setMethod('PUT'); 287 | $this->assertNull($method->invoke($listener, $request, $annotations)); 288 | 289 | $request->setMethod('GET'); 290 | $this->assertEquals( 291 | $annotations[0], 292 | $method->invoke($listener, $request, $annotations) 293 | ); 294 | } 295 | 296 | 297 | public function testFindBestMethodMatchMatchingMultipleAnnotations(): void 298 | { 299 | $listener = $this->createListener($this->any()); 300 | $method = new ReflectionMethod(get_class($listener), 'findBestMethodMatch'); 301 | $method->setAccessible(true); 302 | 303 | $request = new Request(); 304 | 305 | $annotations = array( 306 | new RateLimit('GET', 100, 3600), 307 | new RateLimit(['GET','PUT'], 200, 7200), 308 | ); 309 | 310 | $request->setMethod('PUT'); 311 | $this->assertEquals($annotations[1], $method->invoke($listener, $request, $annotations)); 312 | 313 | $request->setMethod('GET'); 314 | $this->assertEquals($annotations[1], $method->invoke($listener, $request, $annotations)); 315 | } 316 | 317 | protected function createEvent( 318 | int $requestType = HttpKernelInterface::MASTER_REQUEST, 319 | ?Request $request = null, 320 | ?MockController $controller = null, 321 | ): ControllerEvent 322 | { 323 | $kernel = $this->getMockBuilder('Symfony\\Component\\HttpKernel\\HttpKernelInterface')->getMock(); 324 | 325 | $controller = $controller ?? new MockController(); 326 | $action = 'mockAction'; 327 | 328 | $request = $request ?? new Request(); 329 | 330 | return new ControllerEvent($kernel, array($controller, $action), $request, $requestType); 331 | } 332 | 333 | 334 | protected function createListener($expects): RateLimitAnnotationListener 335 | { 336 | $mockDispatcher = $this->getMockBuilder('Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface')->getMock(); 337 | $mockDispatcher 338 | ->expects($expects) 339 | ->method('dispatch'); 340 | 341 | $rateLimitService = new RateLimitService(); 342 | $rateLimitService->setStorage($this->getMockStorage()); 343 | 344 | return new RateLimitAnnotationListener( 345 | $mockDispatcher, 346 | $rateLimitService, 347 | $this->mockPathLimitProcessor 348 | ); 349 | } 350 | 351 | public function testRateLimitKeyGenerationEventHasPayload(): void 352 | { 353 | $event = $this->createEvent(); 354 | $request = $event->getRequest(); 355 | $request->attributes->set('_x-rate-limit', array( 356 | new RateLimit([], 5, 3, ['foo']), 357 | )); 358 | 359 | $generated = false; 360 | $mockDispatcher = $this->getMockBuilder('Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface')->getMock(); 361 | $generatedCallback = function ($name, $event) use ($request, &$generated) { 362 | if ($name !== RateLimitEvents::GENERATE_KEY) { 363 | return; 364 | } 365 | $generated = true; 366 | $this->assertSame(RateLimitEvents::GENERATE_KEY, $name); 367 | $this->assertSame($request, $event->getRequest()); 368 | $this->assertSame(['foo'], $event->getPayload()); 369 | $this->assertSame('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', $event->getKey()); 370 | }; 371 | $mockDispatcher 372 | ->expects($this->any()) 373 | ->method('dispatch') 374 | ->willReturnCallback(function ($arg1, $arg2) use ($generatedCallback) { 375 | if ($arg1 instanceof Event) { 376 | $generatedCallback($arg2, $arg1); 377 | return $arg1; 378 | } else { 379 | $generatedCallback($arg1, $arg2); 380 | return $arg2; 381 | } 382 | }); 383 | 384 | $storage = $this->getMockStorage(); 385 | $storage->createMockRate('test-key', 5, 10, 1); 386 | 387 | $rateLimitService = $this->getMockBuilder('Noxlogic\RateLimitBundle\Service\RateLimitService') 388 | ->getMock(); 389 | 390 | $listener = new RateLimitAnnotationListener($mockDispatcher, $rateLimitService, $this->mockPathLimitProcessor); 391 | $listener->onKernelController($event); 392 | 393 | $this->assertTrue($generated, 'Generate key event not dispatched'); 394 | } 395 | 396 | public function testRateLimitThrottlingWithExceptionAndPayload(): void 397 | { 398 | $listener = $this->createListener($this->any()); 399 | $listener->setParameter('rate_response_exception', 'Noxlogic\RateLimitBundle\Tests\Exception\TestException'); 400 | $listener->setParameter('rate_response_code', 123); 401 | $listener->setParameter('rate_response_message', 'a message'); 402 | 403 | $event = $this->createEvent(); 404 | $event->getRequest()->attributes->set('_x-rate-limit', array( 405 | new RateLimit([], 5, 3, ['foo']), 406 | )); 407 | 408 | // Throttled 409 | $storage = $this->getMockStorage(); 410 | $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, 10, 6); 411 | 412 | try { 413 | $listener->onKernelController($event); 414 | 415 | $this->assertFalse(true, 'Exception not being thrown'); 416 | } catch (\Exception $e) { 417 | $this->assertInstanceOf('Noxlogic\RateLimitBundle\Tests\Exception\TestException', $e); 418 | $this->assertSame(123, $e->getCode()); 419 | $this->assertSame('a message', $e->getMessage()); 420 | $this->assertSame(['foo'], $e->payload); 421 | } 422 | } 423 | 424 | public function testRateLimitThrottlingWithException(): void 425 | { 426 | $this->expectException(\BadFunctionCallException::class); 427 | $this->expectExceptionCode(123); 428 | $this->expectExceptionMessage('a message'); 429 | $listener = $this->createListener($this->any()); 430 | $listener->setParameter('rate_response_exception', '\BadFunctionCallException'); 431 | $listener->setParameter('rate_response_code', 123); 432 | $listener->setParameter('rate_response_message', 'a message'); 433 | 434 | $event = $this->createEvent(); 435 | $event->getRequest()->attributes->set('_x-rate-limit', array( 436 | new RateLimit([], 5, 3), 437 | )); 438 | 439 | // Throttled 440 | $storage = $this->getMockStorage(); 441 | $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, 10, 6); 442 | $listener->onKernelController($event); 443 | } 444 | 445 | public function testRateLimitThrottlingWithMessages(): void 446 | { 447 | $listener = $this->createListener($this->any()); 448 | $listener->setParameter('rate_response_code', 123); 449 | $listener->setParameter('rate_response_message', 'a message'); 450 | 451 | $event = $this->createEvent(); 452 | $event->getRequest()->attributes->set('_x-rate-limit', array( 453 | new RateLimit([], 5, 3), 454 | )); 455 | 456 | // Throttled 457 | $storage = $this->getMockStorage(); 458 | $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, 10, 6); 459 | 460 | /** @var Response $response */ 461 | $listener->onKernelController($event); 462 | 463 | // Call the controller, it will return a response object 464 | $a = $event->getController(); 465 | $response = $a(); 466 | 467 | $this->assertEquals($response->getStatusCode(), 123); 468 | $this->assertEquals($response->getContent(), "a message"); 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /Tests/Events/CheckedRateLimitEventsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(null, $event->getRateLimit()); 19 | } 20 | 21 | public function testRequest(): void 22 | { 23 | $request = new Request(); 24 | $event = new CheckedRateLimitEvent($request, null); 25 | 26 | $this->assertEquals($request, $event->getRequest()); 27 | } 28 | 29 | public function testSetRateLimit(): void 30 | { 31 | $request = new Request(); 32 | $rateLimit = new RateLimit(); 33 | 34 | $event = new CheckedRateLimitEvent($request, $rateLimit); 35 | 36 | $this->assertEquals($rateLimit, $event->getRateLimit()); 37 | 38 | $event->setRateLimit($rateLimit); 39 | $this->assertEquals($rateLimit, $event->getRateLimit()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/Events/GenerateKeyEventsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals("", $event->getKey()); 18 | } 19 | 20 | public function testRequest() 21 | { 22 | $request = new Request(); 23 | $event = new GenerateKeyEvent($request, ""); 24 | 25 | $this->assertEquals($request, $event->getRequest()); 26 | } 27 | 28 | public function testPayload() 29 | { 30 | $request = new Request(); 31 | $event = new GenerateKeyEvent($request, "", 'bar'); 32 | 33 | $this->assertSame('bar', $event->getPayload()); 34 | } 35 | 36 | public function testAddKey() 37 | { 38 | $request = new Request(); 39 | $event = new GenerateKeyEvent($request, "foo"); 40 | 41 | $this->assertEquals("foo", $event->getKey()); 42 | 43 | $event->addToKey("bar"); 44 | $this->assertEquals("foo.bar", $event->getKey()); 45 | 46 | $event->addToKey("baz"); 47 | $this->assertEquals("foo.bar.baz", $event->getKey()); 48 | 49 | $event->addToKey(""); 50 | $this->assertEquals("foo.bar.baz.", $event->getKey()); 51 | } 52 | 53 | public function testSetKey() 54 | { 55 | $request = new Request(); 56 | $event = new GenerateKeyEvent($request, "foo"); 57 | 58 | $this->assertEquals("foo", $event->getKey()); 59 | 60 | $event->setKey("bar"); 61 | $this->assertEquals("bar", $event->getKey()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/Events/RateLimitEventsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('ratelimit.generate.key', RateLimitEvents::GENERATE_KEY); 15 | $this->assertEquals('ratelimit.checked.ratelimit', RateLimitEvents::CHECKED_RATE_LIMIT); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/Exception/TestException.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/NoxlogicRateLimitBundleTest.php: -------------------------------------------------------------------------------- 1 | getMock('\\Symfony\\Component\\DependencyInjection\\ContainerBuilder'); 13 | // $container->expects($this->exactly(0)) 14 | // ->method('addCompilerPass') 15 | // ->with($this->isInstanceOf('\\Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface')); 16 | // 17 | $bundle = new NoxlogicRateLimitBundle(); 18 | $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\NoxlogicRateLimitBundle', $bundle); 19 | // $bundle->build($container); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/Service/RateLimitInfoTest.php: -------------------------------------------------------------------------------- 1 | setLimit(1234); 16 | $this->assertEquals(1234, $rateInfo->getLimit()); 17 | 18 | $rateInfo->setCalls(5); 19 | $this->assertEquals(5, $rateInfo->getCalls()); 20 | 21 | $rateInfo->setResetTimestamp(100000); 22 | $this->assertEquals(100000, $rateInfo->getResetTimestamp()); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Tests/Service/RateLimitServiceTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder('Noxlogic\\RateLimitBundle\\Service\\Storage\\StorageInterface')->getMock(); 18 | 19 | $service = new RateLimitService(); 20 | $service->setStorage($mockStorage); 21 | 22 | $this->assertEquals($mockStorage, $service->getStorage()); 23 | } 24 | 25 | public function testRuntimeExceptionWhenNoStorageIsSet() 26 | { 27 | $this->expectException(\RuntimeException::class); 28 | $service = new RateLimitService(); 29 | $service->getStorage(); 30 | } 31 | 32 | 33 | public function testLimitRate() 34 | { 35 | $mockStorage = $this->getMockBuilder('Noxlogic\\RateLimitBundle\\Service\\Storage\\StorageInterface')->getMock(); 36 | $mockStorage 37 | ->expects($this->once()) 38 | ->method('limitRate') 39 | ->with('testkey'); 40 | 41 | $service = new RateLimitService(); 42 | $service->setStorage($mockStorage); 43 | $service->limitRate('testkey'); 44 | } 45 | 46 | public function testcreateRate() 47 | { 48 | $mockStorage = $this->getMockBuilder('Noxlogic\\RateLimitBundle\\Service\\Storage\\StorageInterface')->getMock(); 49 | $mockStorage 50 | ->expects($this->once()) 51 | ->method('createRate') 52 | ->with('testkey', 10, 100); 53 | 54 | $service = new RateLimitService(); 55 | $service->setStorage($mockStorage); 56 | $service->createRate('testkey', 10, 100); 57 | } 58 | 59 | public function testResetRate() 60 | { 61 | $mockStorage = $this->getMockBuilder('Noxlogic\\RateLimitBundle\\Service\\Storage\\StorageInterface')->getMock(); 62 | $mockStorage 63 | ->expects($this->once()) 64 | ->method('resetRate') 65 | ->with('testkey'); 66 | 67 | $service = new RateLimitService(); 68 | $service->setStorage($mockStorage); 69 | $service->resetRate('testkey'); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/Service/Storage/DoctrineCacheTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder('Doctrine\\Common\\Cache\\Cache') 14 | ->getMock(); 15 | $client->expects($this->once()) 16 | ->method('fetch') 17 | ->with('foo') 18 | ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234))); 19 | 20 | $storage = new DoctrineCache($client); 21 | $rli = $storage->getRateInfo('foo'); 22 | $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo', $rli); 23 | $this->assertEquals(100, $rli->getLimit()); 24 | $this->assertEquals(50, $rli->getCalls()); 25 | $this->assertEquals(1234, $rli->getResetTimestamp()); 26 | } 27 | 28 | public function testCreateRate() 29 | { 30 | $client = $this->getMockBuilder('Doctrine\\Common\\Cache\\Cache') 31 | ->getMock(); 32 | $client->expects($this->once()) 33 | ->method('save'); 34 | 35 | $storage = new DoctrineCache($client); 36 | $storage->createRate('foo', 100, 123); 37 | } 38 | 39 | 40 | public function testLimitRateNoKey() 41 | { 42 | $client = $this->getMockBuilder('Doctrine\\Common\\Cache\\Cache') 43 | ->getMock(); 44 | $client->expects($this->once()) 45 | ->method('fetch') 46 | ->with('foo') 47 | ->will($this->returnValue(false)); 48 | 49 | $storage = new DoctrineCache($client); 50 | $this->assertFalse($storage->limitRate('foo')); 51 | } 52 | 53 | public function testLimitRateWithKey() 54 | { 55 | $client = $this->getMockBuilder('Doctrine\\Common\\Cache\\Cache') 56 | ->getMock(); 57 | 58 | $info['limit'] = 100; 59 | $info['calls'] = 50; 60 | $info['reset'] = 1234; 61 | 62 | $client->expects($this->exactly(1)) 63 | ->method('fetch') 64 | ->with('foo') 65 | ->will($this->returnValue($info)); 66 | $client->expects($this->once()) 67 | ->method('save'); 68 | 69 | $storage = new DoctrineCache($client); 70 | $storage->limitRate('foo'); 71 | } 72 | 73 | 74 | 75 | public function testResetRate() 76 | { 77 | $client = $this->getMockBuilder('Doctrine\\Common\\Cache\\Cache') 78 | ->getMock(); 79 | $client->expects($this->once()) 80 | ->method('delete') 81 | ->with('foo'); 82 | 83 | $storage = new DoctrineCache($client); 84 | $this->assertTrue($storage->resetRate('foo')); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Tests/Service/Storage/MemcacheTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('MemCached extension not installed'); 14 | } 15 | } 16 | 17 | public function testGetRateInfo() 18 | { 19 | $client = @$this->getMockBuilder('\\Memcached') 20 | ->setMethods(array('get')) 21 | ->getMock(); 22 | $client->expects($this->once()) 23 | ->method('get') 24 | ->with('foo') 25 | ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234))); 26 | 27 | $storage = new Memcache($client); 28 | $rli = $storage->getRateInfo('foo'); 29 | $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo', $rli); 30 | $this->assertEquals(100, $rli->getLimit()); 31 | $this->assertEquals(50, $rli->getCalls()); 32 | $this->assertEquals(1234, $rli->getResetTimestamp()); 33 | } 34 | 35 | public function testCreateRate() 36 | { 37 | $client = @$this->getMockBuilder('\\Memcached') 38 | ->setMethods(array('set', 'get')) 39 | ->getMock(); 40 | $client->expects($this->exactly(1)) 41 | ->method('set'); 42 | 43 | $storage = new Memcache($client); 44 | $storage->createRate('foo', 100, 123); 45 | } 46 | 47 | 48 | public function testLimitRateNoKey() 49 | { 50 | $client = @$this->getMockBuilder('\\Memcached') 51 | ->setMethods(array('get','getResultCode')) 52 | ->getMock(); 53 | $client->expects($this->any()) 54 | ->method('getResultCode') 55 | ->willReturn(\Memcached::RES_SUCCESS); 56 | $client->expects($this->atLeastOnce()) 57 | ->method('get') 58 | ->with('foo') 59 | ->will($this->returnValue(array('limit' => 100, 'calls' => 1, 'reset' => 1234))); 60 | 61 | $storage = new Memcache($client); 62 | $storage->limitRate('foo'); 63 | } 64 | 65 | public function testLimitRateWithKey() 66 | { 67 | $client = @$this->getMockBuilder('\\Memcached') 68 | ->setMethods(array('get','cas','getResultCode')) 69 | ->getMock(); 70 | $client->expects($this->any()) 71 | ->method('getResultCode') 72 | ->willReturn(\Memcached::RES_SUCCESS); 73 | $client->expects($this->exactly(1)) 74 | ->method('get') 75 | ->with('foo') 76 | ->willReturn(false); 77 | 78 | $storage = new Memcache($client); 79 | $storage->limitRate('foo'); 80 | } 81 | 82 | public function testResetRate() 83 | { 84 | $client = @$this->getMockBuilder('\\Memcached') 85 | ->setMethods(array('delete')) 86 | ->getMock(); 87 | $client->expects($this->once()) 88 | ->method('delete') 89 | ->with('foo'); 90 | 91 | $storage = new Memcache($client); 92 | $this->assertTrue($storage->resetRate('foo')); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /Tests/Service/Storage/PhpRedisClusterTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Php Redis client not installed'); 14 | } 15 | } 16 | 17 | protected function getRedisMock() { 18 | return $this->getMockBuilder('\RedisCluster')->disableOriginalConstructor(); 19 | } 20 | 21 | protected function getStorage($client) { 22 | return new PhpRedisCluster($client); 23 | } 24 | } -------------------------------------------------------------------------------- /Tests/Service/Storage/PhpRedisTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Php Redis client not installed'); 14 | } 15 | } 16 | 17 | protected function getRedisMock() { 18 | return $this->getMockBuilder('\Redis'); 19 | } 20 | 21 | protected function getStorage($client) { 22 | return new PhpRedis($client); 23 | } 24 | 25 | public function testgetRateInfo() 26 | { 27 | $client = $this->getRedisMock() 28 | ->setMethods(array('hgetall')) 29 | ->getMock(); 30 | $client->expects($this->once()) 31 | ->method('hgetall') 32 | ->with('foo') 33 | ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234))); 34 | 35 | $storage = $this->getStorage($client); 36 | $rli = $storage->getRateInfo('foo'); 37 | $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo', $rli); 38 | $this->assertEquals(100, $rli->getLimit()); 39 | $this->assertEquals(50, $rli->getCalls()); 40 | $this->assertEquals(1234, $rli->getResetTimestamp()); 41 | } 42 | 43 | public function testcreateRate() 44 | { 45 | $client = $this->getRedisMock() 46 | ->setMethods(array('hset', 'expire', 'hgetall')) 47 | ->getMock(); 48 | $client->expects($this->once()) 49 | ->method('expire') 50 | ->with('foo', 123); 51 | $client->expects($this->exactly(3)) 52 | ->method('hset') 53 | ->withConsecutive( 54 | array('foo', 'limit', 100), 55 | array('foo', 'calls', 1), 56 | array('foo', 'reset') 57 | ); 58 | 59 | $storage = $this->getStorage($client); 60 | $storage->createRate('foo', 100, 123); 61 | } 62 | 63 | 64 | public function testLimitRateNoKey() 65 | { 66 | $client = $this->getRedisMock() 67 | ->setMethods(array('hgetall')) 68 | ->getMock(); 69 | $client->expects($this->once()) 70 | ->method('hgetall') 71 | ->with('foo') 72 | ->will($this->returnValue([])); 73 | 74 | $storage = $this->getStorage($client); 75 | $this->assertFalse($storage->limitRate('foo')); 76 | } 77 | 78 | public function testLimitRateWithKey() 79 | { 80 | $client = $this->getRedisMock() 81 | ->setMethods(array('hincrby', 'hgetall')) 82 | ->getMock(); 83 | $client->expects($this->once()) 84 | ->method('hgetall') 85 | ->with('foo') 86 | ->will($this->returnValue([ 87 | 'limit' => 1, 88 | 'calls' => 1, 89 | 'reset' => 1, 90 | ])); 91 | $client->expects($this->once()) 92 | ->method('hincrby') 93 | ->with('foo', 'calls', 1) 94 | ->will($this->returnValue(2)); 95 | 96 | $storage = $this->getStorage($client); 97 | $storage->limitRate('foo'); 98 | } 99 | 100 | 101 | 102 | public function testresetRate() 103 | { 104 | $client = $this->getRedisMock() 105 | ->setMethods(array('del')) 106 | ->getMock(); 107 | $client->expects($this->once()) 108 | ->method('del') 109 | ->with('foo'); 110 | 111 | $storage = $this->getStorage($client); 112 | $this->assertTrue($storage->resetRate('foo')); 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /Tests/Service/Storage/PsrCacheTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder('Psr\\Cache\\CacheItemInterface') 13 | ->getMock(); 14 | $item->expects($this->once()) 15 | ->method('isHit') 16 | ->willReturn(true); 17 | $item->expects($this->once()) 18 | ->method('get') 19 | ->willReturn(array('limit' => 100, 'calls' => 50, 'reset' => 1234)); 20 | 21 | $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') 22 | ->getMock(); 23 | $client->expects($this->once()) 24 | ->method('getItem') 25 | ->with('foo') 26 | ->will($this->returnValue($item)); 27 | 28 | $storage = new PsrCache($client); 29 | $rli = $storage->getRateInfo('foo'); 30 | $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo', $rli); 31 | $this->assertEquals(100, $rli->getLimit()); 32 | $this->assertEquals(50, $rli->getCalls()); 33 | $this->assertEquals(1234, $rli->getResetTimestamp()); 34 | } 35 | 36 | public function testCreateRate() 37 | { 38 | $item = $this->getMockBuilder('Psr\\Cache\\CacheItemInterface') 39 | ->getMock(); 40 | $item->expects($this->once()) 41 | ->method('set') 42 | ->willReturn(true); 43 | $item->expects($this->once()) 44 | ->method('expiresAfter') 45 | ->willReturn(true); 46 | 47 | $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') 48 | ->getMock(); 49 | $client->expects($this->once()) 50 | ->method('getItem') 51 | ->with('foo') 52 | ->will($this->returnValue($item)); 53 | $client->expects($this->once()) 54 | ->method('save') 55 | ->with($item) 56 | ->willReturn(true); 57 | 58 | $storage = new PsrCache($client); 59 | $storage->createRate('foo', 100, 123); 60 | } 61 | 62 | 63 | public function testLimitRateNoKey() 64 | { 65 | $item = $this->getMockBuilder('Psr\\Cache\\CacheItemInterface') 66 | ->getMock(); 67 | $item->expects($this->once()) 68 | ->method('isHit') 69 | ->willReturn(false); 70 | 71 | $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') 72 | ->getMock(); 73 | $client->expects($this->once()) 74 | ->method('getItem') 75 | ->with('foo') 76 | ->will($this->returnValue($item)); 77 | 78 | $storage = new PsrCache($client); 79 | $this->assertFalse($storage->limitRate('foo')); 80 | } 81 | 82 | public function testLimitRateWithKey() 83 | { 84 | $item = $this->getMockBuilder('Psr\\Cache\\CacheItemInterface') 85 | ->getMock(); 86 | $item->expects($this->once()) 87 | ->method('isHit') 88 | ->willReturn(true); 89 | $item->expects($this->once()) 90 | ->method('get') 91 | ->willReturn(array('limit' => 100, 'calls' => 50, 'reset' => 1234)); 92 | $item->expects($this->once()) 93 | ->method('set'); 94 | $item->expects($this->once()) 95 | ->method('expiresAfter'); 96 | 97 | $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') 98 | ->getMock(); 99 | $client->expects($this->once()) 100 | ->method('getItem') 101 | ->with('foo') 102 | ->will($this->returnValue($item)); 103 | $client->expects($this->once()) 104 | ->method('save') 105 | ->with($item) 106 | ->willReturn(true); 107 | 108 | $storage = new PsrCache($client); 109 | $storage->limitRate('foo'); 110 | } 111 | 112 | public function testResetRate() 113 | { 114 | $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') 115 | ->getMock(); 116 | $client->expects($this->once()) 117 | ->method('deleteItem') 118 | ->with('foo') 119 | ->willReturn(true); 120 | 121 | $storage = new PsrCache($client); 122 | $this->assertTrue($storage->resetRate('foo')); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Tests/Service/Storage/RedisTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder('Predis\\Client') 13 | ->setMethods(array('hgetall')) 14 | ->getMock(); 15 | $client->expects($this->once()) 16 | ->method('hgetall') 17 | ->with('foo') 18 | ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234))); 19 | 20 | $storage = new Redis($client); 21 | $rli = $storage->getRateInfo('foo'); 22 | $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo', $rli); 23 | $this->assertEquals(100, $rli->getLimit()); 24 | $this->assertEquals(50, $rli->getCalls()); 25 | $this->assertEquals(1234, $rli->getResetTimestamp()); 26 | } 27 | 28 | public function testcreateRate() 29 | { 30 | $client = $this->getMockBuilder('Predis\\Client') 31 | ->setMethods(array('hset', 'expire', 'hgetall')) 32 | ->getMock(); 33 | $client->expects($this->once()) 34 | ->method('expire') 35 | ->with('foo', 123); 36 | $client->expects($this->exactly(3)) 37 | ->method('hset') 38 | ->withConsecutive( 39 | array('foo', 'limit', 100), 40 | array('foo', 'calls', 1), 41 | array('foo', 'reset') 42 | ); 43 | 44 | $storage = new Redis($client); 45 | $storage->createRate('foo', 100, 123); 46 | } 47 | 48 | 49 | public function testLimitRateNoKey() 50 | { 51 | $client = $this->getMockBuilder('Predis\\Client') 52 | ->setMethods(array('hgetall')) 53 | ->getMock(); 54 | $client->expects($this->once()) 55 | ->method('hgetall') 56 | ->with('foo') 57 | ->will($this->returnValue([])); 58 | 59 | $storage = new Redis($client); 60 | $this->assertFalse($storage->limitRate('foo')); 61 | } 62 | 63 | public function testLimitRateWithKey() 64 | { 65 | $client = $this->getMockBuilder('Predis\\Client') 66 | ->setMethods(array('hexists', 'hincrby', 'hgetall')) 67 | ->getMock(); 68 | $client->expects($this->once()) 69 | ->method('hgetall') 70 | ->with('foo') 71 | ->will($this->returnValue([ 72 | 'limit' => 1, 73 | 'calls' => 1, 74 | 'reset' => 1, 75 | ])); 76 | $client->expects($this->once()) 77 | ->method('hincrby') 78 | ->with('foo', 'calls', 1) 79 | ->will($this->returnValue(2)); 80 | 81 | $storage = new Redis($client); 82 | $storage->limitRate('foo'); 83 | } 84 | 85 | 86 | 87 | public function testresetRate() 88 | { 89 | $client = $this->getMockBuilder('Predis\\Client') 90 | ->setMethods(array('del')) 91 | ->getMock(); 92 | $client->expects($this->once()) 93 | ->method('del') 94 | ->with('foo'); 95 | 96 | $storage = new Redis($client); 97 | $this->assertTrue($storage->resetRate('foo')); 98 | } 99 | 100 | public function testSanitizeKey() 101 | { 102 | $client = $this->getMockBuilder('Predis\\Client') 103 | ->setMethods(array('del')) 104 | ->getMock(); 105 | $client->expects($this->once()) 106 | ->method('del') 107 | ->with('PUT.POST.api_foo.2800_xxx_yyyy_zzzz_d1__41_zz_zz_x_xx_yyyy'); 108 | 109 | $storage = new Redis($client); 110 | $this->assertTrue($storage->resetRate('PUT.POST.api_foo.2800:xxx:yyyy:zzzz:d1@@41:zz{zz:x}xx:yyyy')); 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /Tests/Service/Storage/SimpleCacheTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder('Psr\\SimpleCache\\CacheInterface') 13 | ->getMock(); 14 | $client->expects($this->once()) 15 | ->method('get') 16 | ->with('foo') 17 | ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234))); 18 | 19 | $storage = new SimpleCache($client); 20 | $rli = $storage->getRateInfo('foo'); 21 | $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo', $rli); 22 | $this->assertEquals(100, $rli->getLimit()); 23 | $this->assertEquals(50, $rli->getCalls()); 24 | $this->assertEquals(1234, $rli->getResetTimestamp()); 25 | } 26 | 27 | public function testCreateRate() 28 | { 29 | $client = $this->getMockBuilder('Psr\\SimpleCache\\CacheInterface') 30 | ->getMock(); 31 | $client->expects($this->once()) 32 | ->method('set'); 33 | 34 | $storage = new SimpleCache($client); 35 | $storage->createRate('foo', 100, 123); 36 | } 37 | 38 | 39 | public function testLimitRateNoKey() 40 | { 41 | $client = $this->getMockBuilder('Psr\\SimpleCache\\CacheInterface') 42 | ->getMock(); 43 | $client->expects($this->once()) 44 | ->method('get') 45 | ->with('foo') 46 | ->will($this->returnValue(null)); 47 | 48 | $storage = new SimpleCache($client); 49 | $this->assertFalse($storage->limitRate('foo')); 50 | } 51 | 52 | public function testLimitRateWithKey() 53 | { 54 | $client = $this->getMockBuilder('Psr\\SimpleCache\\CacheInterface') 55 | ->getMock(); 56 | 57 | $info['limit'] = 100; 58 | $info['calls'] = 50; 59 | $info['reset'] = 1234; 60 | 61 | $client->expects($this->exactly(1)) 62 | ->method('get') 63 | ->with('foo') 64 | ->will($this->returnValue($info)); 65 | $client->expects($this->once()) 66 | ->method('set'); 67 | 68 | $storage = new SimpleCache($client); 69 | $storage->limitRate('foo'); 70 | } 71 | 72 | public function testResetRate() 73 | { 74 | $client = $this->getMockBuilder('Psr\\SimpleCache\\CacheInterface') 75 | ->getMock(); 76 | $client->expects($this->once()) 77 | ->method('delete') 78 | ->with('foo'); 79 | 80 | $storage = new SimpleCache($client); 81 | $this->assertTrue($storage->resetRate('foo')); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Tests/TestCase.php: -------------------------------------------------------------------------------- 1 | getRateLimit(new Request()); 21 | 22 | $this->assertNull($result); 23 | } 24 | 25 | /** @test */ 26 | public function itReturnARateLimitIfItMatchesPathAndMethod(): void 27 | { 28 | $plp = new PathLimitProcessor(array( 29 | 'api' => array( 30 | 'path' => 'api/', 31 | 'methods' => array('GET'), 32 | 'limit' => 100, 33 | 'period' => 60 34 | ) 35 | )); 36 | 37 | $result = $plp->getRateLimit( 38 | Request::create('/api/', 'GET') 39 | ); 40 | 41 | $this->assertInstanceOf( 42 | 'Noxlogic\RateLimitBundle\Attribute\RateLimit', 43 | $result 44 | ); 45 | 46 | $this->assertEquals(100, $result->getLimit()); 47 | $this->assertEquals(60, $result->getPeriod()); 48 | $this->assertEquals(array('GET'), $result->getMethods()); 49 | } 50 | 51 | /** @test */ 52 | public function itReturnARateLimitIfItMatchesSubPathWithUrlEncodedString() 53 | { 54 | $plp = new PathLimitProcessor(array( 55 | 'api' => array( 56 | 'path' => 'api', 57 | 'methods' => array('GET'), 58 | 'limit' => 100, 59 | 'period' => 60 60 | ) 61 | )); 62 | 63 | $result = $plp->getRateLimit( 64 | Request::create('%2Fapi%2Fusers', 'GET') 65 | ); 66 | 67 | $this->assertInstanceOf( 68 | 'Noxlogic\RateLimitBundle\Attribute\RateLimit', 69 | $result 70 | ); 71 | 72 | $this->assertEquals(100, $result->getLimit()); 73 | $this->assertEquals(60, $result->getPeriod()); 74 | $this->assertEquals(array('GET'), $result->getMethods()); 75 | } 76 | 77 | /** @test */ 78 | public function itWorksWhenMultipleMethodsAreSpecified(): void 79 | { 80 | $plp = new PathLimitProcessor(array( 81 | 'api' => array( 82 | 'path' => 'api/', 83 | 'methods' => array('GET', 'POST'), 84 | 'limit' => 1000, 85 | 'period' => 600 86 | ) 87 | )); 88 | 89 | $result = $plp->getRateLimit( 90 | Request::create('/api/', 'POST') 91 | ); 92 | 93 | $this->assertEquals(1000, $result->getLimit()); 94 | $this->assertEquals(600, $result->getPeriod()); 95 | $this->assertEquals(array('GET', 'POST'), $result->getMethods()); 96 | } 97 | 98 | /** @test */ 99 | public function itReturnsTheCorrectRateLimitWithMultiplePathLimits(): void 100 | { 101 | $plp = new PathLimitProcessor(array( 102 | 'api' => array( 103 | 'path' => 'api/', 104 | 'methods' => array('GET', 'POST'), 105 | 'limit' => 1000, 106 | 'period' => 600 107 | ), 108 | 'api2' => array( 109 | 'path' => 'api2/', 110 | 'methods' => array('POST'), 111 | 'limit' => 20, 112 | 'period' => 15 113 | ) 114 | )); 115 | 116 | $result = $plp->getRateLimit( 117 | Request::create('/api2/', 'POST') 118 | ); 119 | 120 | $this->assertEquals(20, $result->getLimit()); 121 | $this->assertEquals(15, $result->getPeriod()); 122 | $this->assertEquals(array('POST'), $result->getMethods()); 123 | } 124 | 125 | /** @test */ 126 | public function itWorksWithLimitsOnSamePathButDifferentMethods(): void 127 | { 128 | $plp = new PathLimitProcessor(array( 129 | 'api_get' => array( 130 | 'path' => 'api/', 131 | 'methods' => array('GET'), 132 | 'limit' => 1000, 133 | 'period' => 600 134 | ), 135 | 'api_post' => array( 136 | 'path' => 'api/', 137 | 'methods' => array('POST'), 138 | 'limit' => 200, 139 | 'period' => 150 140 | ) 141 | )); 142 | 143 | $result = $plp->getRateLimit( 144 | Request::create('/api/', 'POST') 145 | ); 146 | 147 | $this->assertEquals(200, $result->getLimit()); 148 | $this->assertEquals(150, $result->getPeriod()); 149 | $this->assertEquals(array('POST'), $result->getMethods()); 150 | } 151 | 152 | /** @test */ 153 | public function itMatchesAstrixAsAnyMethod(): void 154 | { 155 | $plp = new PathLimitProcessor(array( 156 | 'api' => array( 157 | 'path' => 'api/', 158 | 'methods' => array('*'), 159 | 'limit' => 100, 160 | 'period' => 60 161 | ) 162 | )); 163 | 164 | $result = $plp->getRateLimit( 165 | Request::create('/api/users/emails', 'GET') 166 | ); 167 | 168 | $this->assertEquals(100, $result->getLimit()); 169 | $this->assertEquals(60, $result->getPeriod()); 170 | $this->assertEquals(array('*'), $result->getMethods()); 171 | 172 | $result = $plp->getRateLimit( 173 | Request::create('/api/users/emails', 'PUT') 174 | ); 175 | 176 | $this->assertEquals(100, $result->getLimit()); 177 | $this->assertEquals(60, $result->getPeriod()); 178 | $this->assertEquals(array('*'), $result->getMethods()); 179 | 180 | $result = $plp->getRateLimit( 181 | Request::create('/api/users/emails', 'POST') 182 | ); 183 | 184 | $this->assertEquals(100, $result->getLimit()); 185 | $this->assertEquals(60, $result->getPeriod()); 186 | $this->assertEquals(array('*'), $result->getMethods()); 187 | } 188 | 189 | /** @test */ 190 | function itMatchesAstrixAsAnyPath() 191 | { 192 | $plp = new PathLimitProcessor(array( 193 | 'api' => array( 194 | 'path' => '*', 195 | 'methods' => array('GET'), 196 | 'limit' => 100, 197 | 'period' => 60 198 | ) 199 | )); 200 | 201 | $result = $plp->getRateLimit(Request::create('/api')); 202 | 203 | $this->assertEquals(100, $result->getLimit()); 204 | $this->assertEquals(60, $result->getPeriod()); 205 | $this->assertEquals(array('GET'), $result->getMethods()); 206 | 207 | $result = $plp->getRateLimit(Request::create('/api/users')); 208 | 209 | $this->assertEquals(100, $result->getLimit()); 210 | $this->assertEquals(60, $result->getPeriod()); 211 | $this->assertEquals(array('GET'), $result->getMethods()); 212 | 213 | $result = $plp->getRateLimit(Request::create('/api/users/emails')); 214 | 215 | $this->assertEquals(100, $result->getLimit()); 216 | $this->assertEquals(60, $result->getPeriod()); 217 | $this->assertEquals(array('GET'), $result->getMethods()); 218 | } 219 | 220 | /** @test */ 221 | public function itMatchesWhenAccessSubPaths(): void 222 | { 223 | $plp = new PathLimitProcessor(array( 224 | 'api' => array( 225 | 'path' => 'api/', 226 | 'methods' => array('GET'), 227 | 'limit' => 100, 228 | 'period' => 60 229 | ) 230 | )); 231 | 232 | $result = $plp->getRateLimit( 233 | Request::create('/api/users/emails', 'GET') 234 | ); 235 | 236 | $this->assertEquals(100, $result->getLimit()); 237 | $this->assertEquals(60, $result->getPeriod()); 238 | $this->assertEquals(array('GET'), $result->getMethods()); 239 | } 240 | 241 | /** @test */ 242 | public function itReturnsNullIfThereIsNoMatchingPath(): void 243 | { 244 | $plp = new PathLimitProcessor(array( 245 | 'api' => array( 246 | 'path' => 'api/users/emails', 247 | 'methods' => array('GET'), 248 | 'limit' => 100, 249 | 'period' => 60 250 | ) 251 | )); 252 | 253 | $result = $plp->getRateLimit( 254 | Request::create('/api', 'GET') 255 | ); 256 | 257 | $this->assertNull($result); 258 | } 259 | 260 | /** @test */ 261 | public function itMatchesTheMostSpecificPathFirst(): void 262 | { 263 | $plp = new PathLimitProcessor(array( 264 | 'api' => array( 265 | 'path' => 'api', 266 | 'methods' => array('GET'), 267 | 'limit' => 5, 268 | 'period' => 1 269 | ), 270 | 'api_emails' => array( 271 | 'path' => 'api/users/emails', 272 | 'methods' => array('GET'), 273 | 'limit' => 100, 274 | 'period' => 60 275 | ) 276 | )); 277 | 278 | $result = $plp->getRateLimit( 279 | Request::create('/api/users/emails', 'GET') 280 | ); 281 | 282 | $this->assertEquals(100, $result->getLimit()); 283 | $this->assertEquals(60, $result->getPeriod()); 284 | $this->assertEquals(array('GET'), $result->getMethods()); 285 | } 286 | 287 | /** @test */ 288 | public function itReturnsTheMatchedPath(): void 289 | { 290 | $plp = new PathLimitProcessor(array( 291 | 'api' => array( 292 | 'path' => 'api/', 293 | 'methods' => array('GET', 'POST'), 294 | 'limit' => 1000, 295 | 'period' => 600 296 | ) 297 | )); 298 | 299 | $path = $plp->getMatchedPath( 300 | Request::create('/api/', 'POST') 301 | ); 302 | 303 | $this->assertEquals('api', $path); 304 | } 305 | 306 | /** @test */ 307 | public function itReturnsTheCorrectPathForADifferentSetup(): void 308 | { 309 | $plp = new PathLimitProcessor(array( 310 | 'api' => array( 311 | 'path' => 'api', 312 | 'methods' => array('GET'), 313 | 'limit' => 5, 314 | 'period' => 1 315 | ), 316 | 'api_emails' => array( 317 | 'path' => 'api/users/emails', 318 | 'methods' => array('GET'), 319 | 'limit' => 100, 320 | 'period' => 60 321 | ) 322 | )); 323 | 324 | $path = $plp->getMatchedPath( 325 | Request::create('/api/users/emails', 'GET') 326 | ); 327 | 328 | $this->assertEquals('api/users/emails', $path); 329 | } 330 | 331 | /** @test */ 332 | public function itReturnsTheCorrectMatchedPathForSubPaths(): void 333 | { 334 | $plp = new PathLimitProcessor(array( 335 | 'api' => array( 336 | 'path' => 'api/', 337 | 'methods' => array('GET'), 338 | 'limit' => 100, 339 | 'period' => 60 340 | ) 341 | )); 342 | 343 | $path = $plp->getMatchedPath( 344 | Request::create('/api/users/emails', 'GET') 345 | ); 346 | 347 | $this->assertEquals('api', $path); 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /Tests/WebTestCase.php: -------------------------------------------------------------------------------- 1 | pathLimits = $pathLimits; 16 | 17 | // Clean up any extra slashes from the config 18 | foreach ($this->pathLimits as &$pathLimit) { 19 | $pathLimit['path'] = trim($pathLimit['path'], '/'); 20 | } 21 | 22 | // Order the configs so that the most specific paths 23 | // are matched first 24 | usort($this->pathLimits, static function($a, $b) { 25 | return substr_count($b['path'], '/') - substr_count($a['path'], '/'); 26 | }); 27 | } 28 | 29 | public function getRateLimit(Request $request): ?RateLimit 30 | { 31 | $path = trim(urldecode($request->getPathInfo()), '/'); 32 | $method = $request->getMethod(); 33 | 34 | foreach ($this->pathLimits as $pathLimit) { 35 | if ($this->requestMatched($pathLimit, $path, $method)) { 36 | return new RateLimit( 37 | $pathLimit['methods'], 38 | $pathLimit['limit'], 39 | $pathLimit['period'] 40 | ); 41 | } 42 | } 43 | 44 | return null; 45 | } 46 | 47 | public function getMatchedPath(Request $request) 48 | { 49 | $path = trim($request->getPathInfo(), '/'); 50 | $method = $request->getMethod(); 51 | 52 | foreach ($this->pathLimits as $pathLimit) { 53 | if ($this->requestMatched($pathLimit, $path, $method)) { 54 | return $pathLimit['path']; 55 | } 56 | } 57 | 58 | return ''; 59 | } 60 | 61 | private function requestMatched($pathLimit, $path, $method): bool 62 | { 63 | return $this->methodMatched($pathLimit['methods'], $method) 64 | && $this->pathMatched($pathLimit['path'], $path); 65 | } 66 | 67 | private function methodMatched(array $expectedMethods, $method): bool 68 | { 69 | foreach ($expectedMethods as $expectedMethod) { 70 | if ($expectedMethod === '*' || $expectedMethod === $method) { 71 | return true; 72 | } 73 | } 74 | 75 | return false; 76 | } 77 | 78 | private function pathMatched($expectedPath, $path): bool 79 | { 80 | if ($expectedPath === '*') { 81 | return true; 82 | } 83 | 84 | $expectedParts = explode('/', $expectedPath); 85 | $actualParts = explode('/', $path); 86 | 87 | if (count($actualParts) < count($expectedParts)) { 88 | return false; 89 | } 90 | 91 | foreach ($expectedParts as $key => $value) { 92 | if ($value !== $actualParts[$key]) { 93 | return false; 94 | } 95 | } 96 | 97 | return true; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "noxlogic/ratelimit-bundle", 3 | "description": "This bundle provides functionality to limit calls to actions based on rate limits", 4 | "keywords": [ "x-rate-limit", "api", "rest" ], 5 | "type": "symfony-bundle", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Joshua Thijssen", 10 | "email": "jthijssen@noxlogic.nl" 11 | } 12 | ], 13 | "require": { 14 | "php": "^8.0", 15 | "symfony/framework-bundle": "^5.4.2|^6.4" 16 | }, 17 | "require-dev": { 18 | "symfony/phpunit-bridge": ">=5.4", 19 | "psr/simple-cache": "^1.0|^2.0", 20 | "doctrine/cache": "^1.5", 21 | "psr/cache": "^1.0|^2.0", 22 | "predis/predis": "^1.1|^2.0", 23 | "friendsofsymfony/oauth-server-bundle": "^1.5|^2.0@dev", 24 | "phpstan/phpstan": "^2.1" 25 | }, 26 | "suggest": { 27 | "snc/redis-bundle": "Use Redis as a storage engine.", 28 | "leaseweb/memcache-bundle": "Use Memcache as a storage engine.", 29 | "doctrine/doctrine-cache-bundle": "Use Doctrine Cache as a storage engine.", 30 | "friendsofsymfony/oauth-server-bundle": "Throttle using OAuth access tokens." 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Noxlogic\\RateLimitBundle\\": "" 35 | } 36 | }, 37 | "extra": { 38 | "branch-alias": { 39 | "dev-main": "2.x-dev" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: '#^Method Noxlogic\\RateLimitBundle\\Attribute\\RateLimit\:\:__construct\(\) has parameter \$methods with no type specified\.$#' 5 | identifier: missingType.parameter 6 | count: 1 7 | path: Attribute/RateLimit.php 8 | 9 | - 10 | message: '#^Method Noxlogic\\RateLimitBundle\\Attribute\\RateLimit\:\:getMethods\(\) return type has no value type specified in iterable type array\.$#' 11 | identifier: missingType.iterableValue 12 | count: 1 13 | path: Attribute/RateLimit.php 14 | 15 | - 16 | message: '#^Method Noxlogic\\RateLimitBundle\\Attribute\\RateLimit\:\:setMethods\(\) has parameter \$methods with no type specified\.$#' 17 | identifier: missingType.parameter 18 | count: 1 19 | path: Attribute/RateLimit.php 20 | 21 | - 22 | message: '#^Property Noxlogic\\RateLimitBundle\\Attribute\\RateLimit\:\:\$methods type has no value type specified in iterable type array\.$#' 23 | identifier: missingType.iterableValue 24 | count: 1 25 | path: Attribute/RateLimit.php 26 | 27 | - 28 | message: '#^Method Noxlogic\\RateLimitBundle\\DependencyInjection\\NoxlogicRateLimitExtension\:\:loadServices\(\) has parameter \$config with no value type specified in iterable type array\.$#' 29 | identifier: missingType.iterableValue 30 | count: 1 31 | path: DependencyInjection/NoxlogicRateLimitExtension.php 32 | 33 | - 34 | message: '#^Method Noxlogic\\RateLimitBundle\\EventListener\\BaseListener\:\:getParameter\(\) has parameter \$name with no type specified\.$#' 35 | identifier: missingType.parameter 36 | count: 1 37 | path: EventListener/BaseListener.php 38 | 39 | - 40 | message: '#^Method Noxlogic\\RateLimitBundle\\EventListener\\BaseListener\:\:setParameter\(\) has no return type specified\.$#' 41 | identifier: missingType.return 42 | count: 1 43 | path: EventListener/BaseListener.php 44 | 45 | - 46 | message: '#^Method Noxlogic\\RateLimitBundle\\EventListener\\BaseListener\:\:setParameter\(\) has parameter \$name with no type specified\.$#' 47 | identifier: missingType.parameter 48 | count: 1 49 | path: EventListener/BaseListener.php 50 | 51 | - 52 | message: '#^Method Noxlogic\\RateLimitBundle\\EventListener\\BaseListener\:\:setParameter\(\) has parameter \$value with no type specified\.$#' 53 | identifier: missingType.parameter 54 | count: 1 55 | path: EventListener/BaseListener.php 56 | 57 | - 58 | message: '#^Property Noxlogic\\RateLimitBundle\\EventListener\\BaseListener\:\:\$parameters type has no value type specified in iterable type array\.$#' 59 | identifier: missingType.iterableValue 60 | count: 1 61 | path: EventListener/BaseListener.php 62 | 63 | - 64 | message: '#^Method Noxlogic\\RateLimitBundle\\EventListener\\HeaderModificationListener\:\:__construct\(\) has parameter \$defaultParameters with no value type specified in iterable type array\.$#' 65 | identifier: missingType.iterableValue 66 | count: 1 67 | path: EventListener/HeaderModificationListener.php 68 | 69 | - 70 | message: '#^Method Noxlogic\\RateLimitBundle\\EventListener\\HeaderModificationListener\:\:onKernelResponse\(\) has no return type specified\.$#' 71 | identifier: missingType.return 72 | count: 1 73 | path: EventListener/HeaderModificationListener.php 74 | 75 | - 76 | message: '#^Method Noxlogic\\RateLimitBundle\\EventListener\\RateLimitAnnotationListener\:\:getRateLimitsFromAttributes\(\) has parameter \$controller with no value type specified in iterable type array\.$#' 77 | identifier: missingType.iterableValue 78 | count: 1 79 | path: EventListener/RateLimitAnnotationListener.php 80 | 81 | - 82 | message: '#^Method Noxlogic\\RateLimitBundle\\Events\\GenerateKeyEvent\:\:__construct\(\) has parameter \$key with no type specified\.$#' 83 | identifier: missingType.parameter 84 | count: 1 85 | path: Events/GenerateKeyEvent.php 86 | 87 | - 88 | message: '#^Method Noxlogic\\RateLimitBundle\\Events\\GenerateKeyEvent\:\:__construct\(\) has parameter \$payload with no type specified\.$#' 89 | identifier: missingType.parameter 90 | count: 1 91 | path: Events/GenerateKeyEvent.php 92 | 93 | - 94 | message: '#^Method Noxlogic\\RateLimitBundle\\Events\\GenerateKeyEvent\:\:addToKey\(\) has no return type specified\.$#' 95 | identifier: missingType.return 96 | count: 1 97 | path: Events/GenerateKeyEvent.php 98 | 99 | - 100 | message: '#^Method Noxlogic\\RateLimitBundle\\Events\\GenerateKeyEvent\:\:addToKey\(\) has parameter \$part with no type specified\.$#' 101 | identifier: missingType.parameter 102 | count: 1 103 | path: Events/GenerateKeyEvent.php 104 | 105 | - 106 | message: '#^Method Noxlogic\\RateLimitBundle\\Events\\GenerateKeyEvent\:\:setKey\(\) has no return type specified\.$#' 107 | identifier: missingType.return 108 | count: 1 109 | path: Events/GenerateKeyEvent.php 110 | 111 | - 112 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo\:\:setCalls\(\) has no return type specified\.$#' 113 | identifier: missingType.return 114 | count: 1 115 | path: Service/RateLimitInfo.php 116 | 117 | - 118 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo\:\:setLimit\(\) has no return type specified\.$#' 119 | identifier: missingType.return 120 | count: 1 121 | path: Service/RateLimitInfo.php 122 | 123 | - 124 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo\:\:setResetTimestamp\(\) has no return type specified\.$#' 125 | identifier: missingType.return 126 | count: 1 127 | path: Service/RateLimitInfo.php 128 | 129 | - 130 | message: '#^Property Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo\:\:\$calls has no type specified\.$#' 131 | identifier: missingType.property 132 | count: 1 133 | path: Service/RateLimitInfo.php 134 | 135 | - 136 | message: '#^Property Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo\:\:\$limit has no type specified\.$#' 137 | identifier: missingType.property 138 | count: 1 139 | path: Service/RateLimitInfo.php 140 | 141 | - 142 | message: '#^Property Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo\:\:\$resetTimestamp has no type specified\.$#' 143 | identifier: missingType.property 144 | count: 1 145 | path: Service/RateLimitInfo.php 146 | 147 | - 148 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitService\:\:createRate\(\) has no return type specified\.$#' 149 | identifier: missingType.return 150 | count: 1 151 | path: Service/RateLimitService.php 152 | 153 | - 154 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitService\:\:createRate\(\) has parameter \$key with no type specified\.$#' 155 | identifier: missingType.parameter 156 | count: 1 157 | path: Service/RateLimitService.php 158 | 159 | - 160 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitService\:\:createRate\(\) has parameter \$limit with no type specified\.$#' 161 | identifier: missingType.parameter 162 | count: 1 163 | path: Service/RateLimitService.php 164 | 165 | - 166 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitService\:\:createRate\(\) has parameter \$period with no type specified\.$#' 167 | identifier: missingType.parameter 168 | count: 1 169 | path: Service/RateLimitService.php 170 | 171 | - 172 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitService\:\:limitRate\(\) has no return type specified\.$#' 173 | identifier: missingType.return 174 | count: 1 175 | path: Service/RateLimitService.php 176 | 177 | - 178 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitService\:\:limitRate\(\) has parameter \$key with no type specified\.$#' 179 | identifier: missingType.parameter 180 | count: 1 181 | path: Service/RateLimitService.php 182 | 183 | - 184 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitService\:\:resetRate\(\) has no return type specified\.$#' 185 | identifier: missingType.return 186 | count: 1 187 | path: Service/RateLimitService.php 188 | 189 | - 190 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitService\:\:resetRate\(\) has parameter \$key with no type specified\.$#' 191 | identifier: missingType.parameter 192 | count: 1 193 | path: Service/RateLimitService.php 194 | 195 | - 196 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\RateLimitService\:\:setStorage\(\) has no return type specified\.$#' 197 | identifier: missingType.return 198 | count: 1 199 | path: Service/RateLimitService.php 200 | 201 | - 202 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\DoctrineCache\:\:createRate\(\) has no return type specified\.$#' 203 | identifier: missingType.return 204 | count: 1 205 | path: Service/Storage/DoctrineCache.php 206 | 207 | - 208 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\DoctrineCache\:\:createRateInfo\(\) has no return type specified\.$#' 209 | identifier: missingType.return 210 | count: 1 211 | path: Service/Storage/DoctrineCache.php 212 | 213 | - 214 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\DoctrineCache\:\:createRateInfo\(\) has parameter \$info with no value type specified in iterable type array\.$#' 215 | identifier: missingType.iterableValue 216 | count: 1 217 | path: Service/Storage/DoctrineCache.php 218 | 219 | - 220 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\DoctrineCache\:\:resetRate\(\) has no return type specified\.$#' 221 | identifier: missingType.return 222 | count: 1 223 | path: Service/Storage/DoctrineCache.php 224 | 225 | - 226 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\DoctrineCache\:\:resetRate\(\) has parameter \$key with no type specified\.$#' 227 | identifier: missingType.parameter 228 | count: 1 229 | path: Service/Storage/DoctrineCache.php 230 | 231 | - 232 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\Memcache\:\:createRate\(\) has no return type specified\.$#' 233 | identifier: missingType.return 234 | count: 1 235 | path: Service/Storage/Memcache.php 236 | 237 | - 238 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\Memcache\:\:createRateInfo\(\) has no return type specified\.$#' 239 | identifier: missingType.return 240 | count: 1 241 | path: Service/Storage/Memcache.php 242 | 243 | - 244 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\Memcache\:\:createRateInfo\(\) has parameter \$info with no value type specified in iterable type array\.$#' 245 | identifier: missingType.iterableValue 246 | count: 1 247 | path: Service/Storage/Memcache.php 248 | 249 | - 250 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\Memcache\:\:resetRate\(\) has no return type specified\.$#' 251 | identifier: missingType.return 252 | count: 1 253 | path: Service/Storage/Memcache.php 254 | 255 | - 256 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\Memcache\:\:resetRate\(\) has parameter \$key with no type specified\.$#' 257 | identifier: missingType.parameter 258 | count: 1 259 | path: Service/Storage/Memcache.php 260 | 261 | - 262 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\PhpRedis\:\:createRate\(\) has no return type specified\.$#' 263 | identifier: missingType.return 264 | count: 1 265 | path: Service/Storage/PhpRedis.php 266 | 267 | - 268 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\PhpRedis\:\:resetRate\(\) has no return type specified\.$#' 269 | identifier: missingType.return 270 | count: 1 271 | path: Service/Storage/PhpRedis.php 272 | 273 | - 274 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\PhpRedis\:\:resetRate\(\) has parameter \$key with no type specified\.$#' 275 | identifier: missingType.parameter 276 | count: 1 277 | path: Service/Storage/PhpRedis.php 278 | 279 | - 280 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\PhpRedis\:\:sanitizeRedisKey\(\) has parameter \$key with no type specified\.$#' 281 | identifier: missingType.parameter 282 | count: 1 283 | path: Service/Storage/PhpRedis.php 284 | 285 | - 286 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\PsrCache\:\:createRate\(\) has no return type specified\.$#' 287 | identifier: missingType.return 288 | count: 1 289 | path: Service/Storage/PsrCache.php 290 | 291 | - 292 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\PsrCache\:\:createRateInfo\(\) has no return type specified\.$#' 293 | identifier: missingType.return 294 | count: 1 295 | path: Service/Storage/PsrCache.php 296 | 297 | - 298 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\PsrCache\:\:createRateInfo\(\) has parameter \$info with no value type specified in iterable type array\.$#' 299 | identifier: missingType.iterableValue 300 | count: 1 301 | path: Service/Storage/PsrCache.php 302 | 303 | - 304 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\PsrCache\:\:resetRate\(\) has no return type specified\.$#' 305 | identifier: missingType.return 306 | count: 1 307 | path: Service/Storage/PsrCache.php 308 | 309 | - 310 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\PsrCache\:\:resetRate\(\) has parameter \$key with no type specified\.$#' 311 | identifier: missingType.parameter 312 | count: 1 313 | path: Service/Storage/PsrCache.php 314 | 315 | - 316 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\Redis\:\:createRate\(\) has no return type specified\.$#' 317 | identifier: missingType.return 318 | count: 1 319 | path: Service/Storage/Redis.php 320 | 321 | - 322 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\Redis\:\:resetRate\(\) has no return type specified\.$#' 323 | identifier: missingType.return 324 | count: 1 325 | path: Service/Storage/Redis.php 326 | 327 | - 328 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\Redis\:\:resetRate\(\) has parameter \$key with no type specified\.$#' 329 | identifier: missingType.parameter 330 | count: 1 331 | path: Service/Storage/Redis.php 332 | 333 | - 334 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\Redis\:\:sanitizeRedisKey\(\) has parameter \$key with no type specified\.$#' 335 | identifier: missingType.parameter 336 | count: 1 337 | path: Service/Storage/Redis.php 338 | 339 | - 340 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\SimpleCache\:\:createRate\(\) has no return type specified\.$#' 341 | identifier: missingType.return 342 | count: 1 343 | path: Service/Storage/SimpleCache.php 344 | 345 | - 346 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\SimpleCache\:\:createRateInfo\(\) has no return type specified\.$#' 347 | identifier: missingType.return 348 | count: 1 349 | path: Service/Storage/SimpleCache.php 350 | 351 | - 352 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\SimpleCache\:\:createRateInfo\(\) has parameter \$info with no value type specified in iterable type array\.$#' 353 | identifier: missingType.iterableValue 354 | count: 1 355 | path: Service/Storage/SimpleCache.php 356 | 357 | - 358 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\SimpleCache\:\:resetRate\(\) has no return type specified\.$#' 359 | identifier: missingType.return 360 | count: 1 361 | path: Service/Storage/SimpleCache.php 362 | 363 | - 364 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\SimpleCache\:\:resetRate\(\) has parameter \$key with no type specified\.$#' 365 | identifier: missingType.parameter 366 | count: 1 367 | path: Service/Storage/SimpleCache.php 368 | 369 | - 370 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\StorageInterface\:\:createRate\(\) has no return type specified\.$#' 371 | identifier: missingType.return 372 | count: 1 373 | path: Service/Storage/StorageInterface.php 374 | 375 | - 376 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\StorageInterface\:\:resetRate\(\) has no return type specified\.$#' 377 | identifier: missingType.return 378 | count: 1 379 | path: Service/Storage/StorageInterface.php 380 | 381 | - 382 | message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\StorageInterface\:\:resetRate\(\) has parameter \$key with no type specified\.$#' 383 | identifier: missingType.parameter 384 | count: 1 385 | path: Service/Storage/StorageInterface.php 386 | 387 | - 388 | message: '#^Method Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:__construct\(\) has parameter \$pathLimits with no value type specified in iterable type array\.$#' 389 | identifier: missingType.iterableValue 390 | count: 1 391 | path: Util/PathLimitProcessor.php 392 | 393 | - 394 | message: '#^Method Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:getMatchedPath\(\) has no return type specified\.$#' 395 | identifier: missingType.return 396 | count: 1 397 | path: Util/PathLimitProcessor.php 398 | 399 | - 400 | message: '#^Method Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:methodMatched\(\) has parameter \$expectedMethods with no value type specified in iterable type array\.$#' 401 | identifier: missingType.iterableValue 402 | count: 1 403 | path: Util/PathLimitProcessor.php 404 | 405 | - 406 | message: '#^Method Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:methodMatched\(\) has parameter \$method with no type specified\.$#' 407 | identifier: missingType.parameter 408 | count: 1 409 | path: Util/PathLimitProcessor.php 410 | 411 | - 412 | message: '#^Method Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:pathMatched\(\) has parameter \$expectedPath with no type specified\.$#' 413 | identifier: missingType.parameter 414 | count: 1 415 | path: Util/PathLimitProcessor.php 416 | 417 | - 418 | message: '#^Method Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:pathMatched\(\) has parameter \$path with no type specified\.$#' 419 | identifier: missingType.parameter 420 | count: 1 421 | path: Util/PathLimitProcessor.php 422 | 423 | - 424 | message: '#^Method Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:requestMatched\(\) has parameter \$method with no type specified\.$#' 425 | identifier: missingType.parameter 426 | count: 1 427 | path: Util/PathLimitProcessor.php 428 | 429 | - 430 | message: '#^Method Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:requestMatched\(\) has parameter \$path with no type specified\.$#' 431 | identifier: missingType.parameter 432 | count: 1 433 | path: Util/PathLimitProcessor.php 434 | 435 | - 436 | message: '#^Method Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:requestMatched\(\) has parameter \$pathLimit with no type specified\.$#' 437 | identifier: missingType.parameter 438 | count: 1 439 | path: Util/PathLimitProcessor.php 440 | 441 | - 442 | message: '#^Property Noxlogic\\RateLimitBundle\\Util\\PathLimitProcessor\:\:\$pathLimits type has no value type specified in iterable type array\.$#' 443 | identifier: missingType.iterableValue 444 | count: 1 445 | path: Util/PathLimitProcessor.php 446 | -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - Attribute/ 5 | - DependencyInjection/ 6 | - EventListener/ 7 | - Events/ 8 | - Exception/ 9 | - Resources/ 10 | - Service/ 11 | - Util/ 12 | excludePaths: 13 | - EventListener/OauthKeyGenerateListener.php 14 | includes: 15 | - phpstan-baseline.neon 16 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | Tests 16 | 17 | 18 | 19 | 20 | 21 | . 22 | 23 | Resources 24 | Tests 25 | vendor 26 | 27 | 28 | 29 | 30 | --------------------------------------------------------------------------------