From 2a4aa5d0703dcd4f6ed47436790ee5f01039ca77 Mon Sep 17 00:00:00 2001 From: Deeka Wong <8337659+huangdijia@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:45:00 +0800 Subject: [PATCH 1/7] feat: add IS_REPEATABLE support to RateLimit annotation Enable Attribute::IS_REPEATABLE flag on RateLimit annotation. Refactor RateLimitAspect to handle multiple annotations. Maintain backward compatibility with single annotation usage. Add comprehensive tests for new functionality. This change allows developers to apply multiple rate limit rules on a single method, enabling sophisticated rate limiting strategies. BREAKING CHANGE: None - fully backward compatible --- src/rate-limit/src/Annotation/RateLimit.php | 2 +- src/rate-limit/src/Aspect/RateLimitAspect.php | 36 +++-- tests/RateLimit/MultipleRateLimitExample.php | 129 ++++++++++++++++++ tests/RateLimit/RepeatableAnnotationTest.php | 105 ++++++++++++++ 4 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 tests/RateLimit/MultipleRateLimitExample.php create mode 100644 tests/RateLimit/RepeatableAnnotationTest.php diff --git a/src/rate-limit/src/Annotation/RateLimit.php b/src/rate-limit/src/Annotation/RateLimit.php index 263c927ca..04974d2e0 100644 --- a/src/rate-limit/src/Annotation/RateLimit.php +++ b/src/rate-limit/src/Annotation/RateLimit.php @@ -15,7 +15,7 @@ use FriendsOfHyperf\RateLimit\Algorithm; use Hyperf\Di\Annotation\AbstractAnnotation; -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class RateLimit extends AbstractAnnotation { /** diff --git a/src/rate-limit/src/Aspect/RateLimitAspect.php b/src/rate-limit/src/Aspect/RateLimitAspect.php index 8d66bac6b..3a3a45fa4 100644 --- a/src/rate-limit/src/Aspect/RateLimitAspect.php +++ b/src/rate-limit/src/Aspect/RateLimitAspect.php @@ -35,25 +35,33 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint) { $metadata = $proceedingJoinPoint->getAnnotationMetadata(); - /** @var null|RateLimit $annotation */ - $annotation = $metadata->method[RateLimit::class] ?? null; + /** @var null|RateLimit|RateLimit[] $annotations */ + $annotations = $metadata->method[RateLimit::class] ?? null; - if (! $annotation) { + if (! $annotations) { return $proceedingJoinPoint->process(); } - $key = $this->resolveKey($annotation->key, $proceedingJoinPoint); - $limiter = $this->factory->make($annotation->algorithm, $annotation->pool); - - if ($limiter->tooManyAttempts($key, $annotation->maxAttempts, $annotation->decay)) { - $availableIn = $limiter->availableIn($key); - $message = Str::replaceArray('%d', [(string) $availableIn], $annotation->response); + // Ensure annotations is an array for consistent handling + if (! is_array($annotations)) { + $annotations = [$annotations]; + } - throw new RateLimitException( - $message, - $annotation->responseCode, - $availableIn - ); + // Process all rate limit rules: any rule triggered will reject the request + foreach ($annotations as $annotation) { + $key = $this->resolveKey($annotation->key, $proceedingJoinPoint); + $limiter = $this->factory->make($annotation->algorithm, $annotation->pool); + + if ($limiter->tooManyAttempts($key, $annotation->maxAttempts, $annotation->decay)) { + $availableIn = $limiter->availableIn($key); + $message = Str::replaceArray('%d', [(string) $availableIn], $annotation->response); + + throw new RateLimitException( + $message, + $annotation->responseCode, + $availableIn + ); + } } return $proceedingJoinPoint->process(); diff --git a/tests/RateLimit/MultipleRateLimitExample.php b/tests/RateLimit/MultipleRateLimitExample.php new file mode 100644 index 000000000..3e18189bd --- /dev/null +++ b/tests/RateLimit/MultipleRateLimitExample.php @@ -0,0 +1,129 @@ + 'Success', + 'data' => [], + ]; + } + + /** + * Login endpoint with strict rate limiting: + * 1. IP-based limit: 5 attempts per 15 minutes (brute force protection) + * 2. Global limit: 100 attempts per minute across all IPs + */ + #[RateLimit( + key: 'login:ip:{ip}', + maxAttempts: 5, + decay: 900, + response: 'Too many login attempts from your IP. Please wait %d seconds.', + responseCode: 429 + )] + #[RateLimit( + key: 'login:global', + maxAttempts: 100, + decay: 60, + response: 'Login service temporarily unavailable. Please try again in %d seconds.', + responseCode: 503 + )] + public function login(string $username, string $password): array + { + // Login logic here + return [ + 'message' => 'Login successful', + 'token' => 'example-token', + ]; + } + + /** + * Admin endpoint with different rate limits for admin users: + * 1. Admin user limit: 5000 requests per hour + * 2. Regular user limit: 100 requests per hour (if accessing as regular user) + */ + #[RateLimit( + key: 'admin:data', + maxAttempts: 5000, + decay: 3600, + pool: 'admin_redis', + response: 'Admin API rate limit exceeded' + )] + #[RateLimit( + key: 'user:basic', + maxAttempts: 100, + decay: 3600, + pool: 'default', + response: 'Basic user rate limit exceeded' + )] + public function adminData(): array + { + return [ + 'admin_data' => [ + 'sensitive' => 'information', + ], + ]; + } + + /** + * Single RateLimit (backward compatibility - still works!) + * This demonstrates that the original single-annotation usage still works. + */ + #[RateLimit( + key: 'simple:endpoint', + maxAttempts: 60, + decay: 60, + response: 'Rate limit exceeded. Please wait %d seconds.' + )] + public function simpleEndpoint(): string + { + return 'This endpoint has a single rate limit rule.'; + } +} diff --git a/tests/RateLimit/RepeatableAnnotationTest.php b/tests/RateLimit/RepeatableAnnotationTest.php new file mode 100644 index 000000000..1a4ae57ee --- /dev/null +++ b/tests/RateLimit/RepeatableAnnotationTest.php @@ -0,0 +1,105 @@ +getAttributes(Attribute::class); + + expect($attributes)->not->toBeEmpty(); + + $attribute = $attributes[0]->newInstance(); + $targetFlag = $attribute->flags; + + // Check if IS_REPEATABLE flag is set + expect($targetFlag & Attribute::IS_REPEATABLE)->toBe(Attribute::IS_REPEATABLE); + expect($targetFlag & Attribute::TARGET_METHOD)->toBe(Attribute::TARGET_METHOD); +}); + +test('RateLimit aspect can handle multiple annotations', function () { + $metadata = new class { + public array $method = []; + }; + + // Simulate multiple annotations + $metadata->method[RateLimit::class] = [ + new RateLimit(key: 'test:1', maxAttempts: 10, decay: 60), + new RateLimit(key: 'test:2', maxAttempts: 20, decay: 120), + ]; + + expect($metadata->method[RateLimit::class])->toBeArray(); + expect($metadata->method[RateLimit::class])->toHaveCount(2); +}); + +test('RateLimit aspect can handle single annotation (backward compatibility)', function () { + $metadata = new class { + public array $method = []; + }; + + // Simulate single annotation (backward compatibility) + $metadata->method[RateLimit::class] = new RateLimit(key: 'test:single', maxAttempts: 10); + + $annotation = $metadata->method[RateLimit::class]; + + // Should remain as single instance for backward compatibility + expect($annotation)->toBeInstanceOf(RateLimit::class); + expect($annotation->key)->toBe('test:single'); + expect($annotation->maxAttempts)->toBe(10); +}); + +test('multiple rate limit configurations have different properties', function () { + $rateLimit1 = new RateLimit( + key: 'ip:{ip}', + maxAttempts: 100, + decay: 60, + algorithm: Algorithm::FIXED_WINDOW, + response: 'IP limit exceeded' + ); + + $rateLimit2 = new RateLimit( + key: 'user:{user_id}', + maxAttempts: 1000, + decay: 3600, + algorithm: Algorithm::SLIDING_WINDOW, + response: 'User limit exceeded' + ); + + $rateLimit3 = new RateLimit( + key: 'global:api', + maxAttempts: 10000, + decay: 86400, + algorithm: Algorithm::TOKEN_BUCKET, + response: 'Global limit exceeded' + ); + + $annotations = [$rateLimit1, $rateLimit2, $rateLimit3]; + + expect($annotations)->toHaveCount(3); + + // Verify each annotation has distinct properties + expect($annotations[0]->key)->toBe('ip:{ip}'); + expect($annotations[0]->maxAttempts)->toBe(100); + expect($annotations[0]->decay)->toBe(60); + + expect($annotations[1]->key)->toBe('user:{user_id}'); + expect($annotations[1]->maxAttempts)->toBe(1000); + expect($annotations[1]->decay)->toBe(3600); + + expect($annotations[2]->key)->toBe('global:api'); + expect($annotations[2]->maxAttempts)->toBe(10000); + expect($annotations[2]->decay)->toBe(86400); + + // Verify different algorithms + expect($annotations[0]->algorithm)->toBe(Algorithm::FIXED_WINDOW); + expect($annotations[1]->algorithm)->toBe(Algorithm::SLIDING_WINDOW); + expect($annotations[2]->algorithm)->toBe(Algorithm::TOKEN_BUCKET); +}); From 357205dd987c48e39bb41352e1dd870f83552e17 Mon Sep 17 00:00:00 2001 From: Deeka Wong <8337659+huangdijia@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:50:04 +0800 Subject: [PATCH 2/7] refactor: simplify constructor syntax in RateLimitAspect --- src/rate-limit/src/Aspect/RateLimitAspect.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/rate-limit/src/Aspect/RateLimitAspect.php b/src/rate-limit/src/Aspect/RateLimitAspect.php index 3a3a45fa4..ede5a9de9 100644 --- a/src/rate-limit/src/Aspect/RateLimitAspect.php +++ b/src/rate-limit/src/Aspect/RateLimitAspect.php @@ -26,9 +26,8 @@ class RateLimitAspect extends AbstractAspect RateLimit::class, ]; - public function __construct( - protected RateLimiterFactory $factory, - ) { + public function __construct(protected RateLimiterFactory $factory) + { } public function process(ProceedingJoinPoint $proceedingJoinPoint) From fe585c87fc711fa2dd9fbee3aaf2f293357fda8d Mon Sep 17 00:00:00 2001 From: Deeka Wong <8337659+huangdijia@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:09:58 +0800 Subject: [PATCH 3/7] fix: update RateLimit and RateLimitAspect to use AbstractMultipleAnnotation for better handling of repeatable annotations --- src/rate-limit/src/Annotation/RateLimit.php | 4 ++-- src/rate-limit/src/Aspect/RateLimitAspect.php | 16 ++++------------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/rate-limit/src/Annotation/RateLimit.php b/src/rate-limit/src/Annotation/RateLimit.php index 04974d2e0..d9cdceb66 100644 --- a/src/rate-limit/src/Annotation/RateLimit.php +++ b/src/rate-limit/src/Annotation/RateLimit.php @@ -13,10 +13,10 @@ use Attribute; use FriendsOfHyperf\RateLimit\Algorithm; -use Hyperf\Di\Annotation\AbstractAnnotation; +use Hyperf\Di\Annotation\AbstractMultipleAnnotation; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class RateLimit extends AbstractAnnotation +class RateLimit extends AbstractMultipleAnnotation { /** * @param string|array $key Rate limit key, supports placeholders like {user_id} diff --git a/src/rate-limit/src/Aspect/RateLimitAspect.php b/src/rate-limit/src/Aspect/RateLimitAspect.php index ede5a9de9..7a2d344c6 100644 --- a/src/rate-limit/src/Aspect/RateLimitAspect.php +++ b/src/rate-limit/src/Aspect/RateLimitAspect.php @@ -14,6 +14,7 @@ use FriendsOfHyperf\RateLimit\Annotation\RateLimit; use FriendsOfHyperf\RateLimit\Exception\RateLimitException; use FriendsOfHyperf\RateLimit\RateLimiterFactory; +use Hyperf\Di\Annotation\MultipleAnnotation; use Hyperf\Di\Aop\AbstractAspect; use Hyperf\Di\Aop\ProceedingJoinPoint; use Hyperf\Stringable\Str; @@ -34,20 +35,11 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint) { $metadata = $proceedingJoinPoint->getAnnotationMetadata(); - /** @var null|RateLimit|RateLimit[] $annotations */ + /** @var null|MultipleAnnotation $annotations */ $annotations = $metadata->method[RateLimit::class] ?? null; - if (! $annotations) { - return $proceedingJoinPoint->process(); - } - - // Ensure annotations is an array for consistent handling - if (! is_array($annotations)) { - $annotations = [$annotations]; - } - - // Process all rate limit rules: any rule triggered will reject the request - foreach ($annotations as $annotation) { + foreach ($annotations?->toAnnotations() ?? [] as $annotation) { + /** @var RateLimit $annotation */ $key = $this->resolveKey($annotation->key, $proceedingJoinPoint); $limiter = $this->factory->make($annotation->algorithm, $annotation->pool); From ce110f379f98a2f38b135a412c3acba8cde6622f Mon Sep 17 00:00:00 2001 From: Deeka Wong <8337659+huangdijia@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:14:59 +0800 Subject: [PATCH 4/7] test: enhance RepeatableAnnotationTest for IS_REPEATABLE support in RateLimit --- tests/RateLimit/MultipleRateLimitExample.php | 129 ------------------- tests/RateLimit/RepeatableAnnotationTest.php | 54 ++++---- 2 files changed, 32 insertions(+), 151 deletions(-) delete mode 100644 tests/RateLimit/MultipleRateLimitExample.php diff --git a/tests/RateLimit/MultipleRateLimitExample.php b/tests/RateLimit/MultipleRateLimitExample.php deleted file mode 100644 index 3e18189bd..000000000 --- a/tests/RateLimit/MultipleRateLimitExample.php +++ /dev/null @@ -1,129 +0,0 @@ - 'Success', - 'data' => [], - ]; - } - - /** - * Login endpoint with strict rate limiting: - * 1. IP-based limit: 5 attempts per 15 minutes (brute force protection) - * 2. Global limit: 100 attempts per minute across all IPs - */ - #[RateLimit( - key: 'login:ip:{ip}', - maxAttempts: 5, - decay: 900, - response: 'Too many login attempts from your IP. Please wait %d seconds.', - responseCode: 429 - )] - #[RateLimit( - key: 'login:global', - maxAttempts: 100, - decay: 60, - response: 'Login service temporarily unavailable. Please try again in %d seconds.', - responseCode: 503 - )] - public function login(string $username, string $password): array - { - // Login logic here - return [ - 'message' => 'Login successful', - 'token' => 'example-token', - ]; - } - - /** - * Admin endpoint with different rate limits for admin users: - * 1. Admin user limit: 5000 requests per hour - * 2. Regular user limit: 100 requests per hour (if accessing as regular user) - */ - #[RateLimit( - key: 'admin:data', - maxAttempts: 5000, - decay: 3600, - pool: 'admin_redis', - response: 'Admin API rate limit exceeded' - )] - #[RateLimit( - key: 'user:basic', - maxAttempts: 100, - decay: 3600, - pool: 'default', - response: 'Basic user rate limit exceeded' - )] - public function adminData(): array - { - return [ - 'admin_data' => [ - 'sensitive' => 'information', - ], - ]; - } - - /** - * Single RateLimit (backward compatibility - still works!) - * This demonstrates that the original single-annotation usage still works. - */ - #[RateLimit( - key: 'simple:endpoint', - maxAttempts: 60, - decay: 60, - response: 'Rate limit exceeded. Please wait %d seconds.' - )] - public function simpleEndpoint(): string - { - return 'This endpoint has a single rate limit rule.'; - } -} diff --git a/tests/RateLimit/RepeatableAnnotationTest.php b/tests/RateLimit/RepeatableAnnotationTest.php index 1a4ae57ee..43e3620e8 100644 --- a/tests/RateLimit/RepeatableAnnotationTest.php +++ b/tests/RateLimit/RepeatableAnnotationTest.php @@ -8,8 +8,11 @@ * @document https://github.com/friendsofhyperf/components/blob/main/README.md * @contact huangdijia@gmail.com */ + use FriendsOfHyperf\RateLimit\Algorithm; use FriendsOfHyperf\RateLimit\Annotation\RateLimit; +use Hyperf\Di\Annotation\MultipleAnnotation; + test('RateLimit annotation supports IS_REPEATABLE flag', function () { $reflection = new ReflectionClass(RateLimit::class); @@ -26,34 +29,41 @@ }); test('RateLimit aspect can handle multiple annotations', function () { - $metadata = new class { - public array $method = []; - }; - - // Simulate multiple annotations - $metadata->method[RateLimit::class] = [ - new RateLimit(key: 'test:1', maxAttempts: 10, decay: 60), - new RateLimit(key: 'test:2', maxAttempts: 20, decay: 120), - ]; - - expect($metadata->method[RateLimit::class])->toBeArray(); - expect($metadata->method[RateLimit::class])->toHaveCount(2); + $annotation1 = new RateLimit(key: 'test:1', maxAttempts: 10, decay: 60); + $annotation2 = new RateLimit(key: 'test:2', maxAttempts: 20, decay: 120); + + // MultipleAnnotation only takes one annotation in constructor + $multipleAnnotation = new MultipleAnnotation($annotation1); + // Insert the second annotation + $multipleAnnotation->insert($annotation2); + + expect($multipleAnnotation->toAnnotations())->toBeArray(); + expect($multipleAnnotation->toAnnotations())->toHaveCount(2); + + // Verify the annotations are properly converted + $converted = $multipleAnnotation->toAnnotations(); + expect($converted[0]->key)->toBe('test:1'); + expect($converted[0]->maxAttempts)->toBe(10); + expect($converted[0]->decay)->toBe(60); + + expect($converted[1]->key)->toBe('test:2'); + expect($converted[1]->maxAttempts)->toBe(20); + expect($converted[1]->decay)->toBe(120); }); test('RateLimit aspect can handle single annotation (backward compatibility)', function () { - $metadata = new class { - public array $method = []; - }; + $singleAnnotation = new RateLimit(key: 'test:single', maxAttempts: 10); - // Simulate single annotation (backward compatibility) - $metadata->method[RateLimit::class] = new RateLimit(key: 'test:single', maxAttempts: 10); + $multipleAnnotation = new MultipleAnnotation($singleAnnotation); - $annotation = $metadata->method[RateLimit::class]; + expect($multipleAnnotation)->toBeInstanceOf(MultipleAnnotation::class); - // Should remain as single instance for backward compatibility - expect($annotation)->toBeInstanceOf(RateLimit::class); - expect($annotation->key)->toBe('test:single'); - expect($annotation->maxAttempts)->toBe(10); + $converted = $multipleAnnotation->toAnnotations(); + expect($converted)->toBeArray(); + expect($converted)->toHaveCount(1); + expect($converted[0])->toBeInstanceOf(RateLimit::class); + expect($converted[0]->key)->toBe('test:single'); + expect($converted[0]->maxAttempts)->toBe(10); }); test('multiple rate limit configurations have different properties', function () { From fe995a99a6aacfac07dfe4662644a884c24692da Mon Sep 17 00:00:00 2001 From: Deeka Wong <8337659+huangdijia@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:16:11 +0800 Subject: [PATCH 5/7] test: add MultipleAnnotationTest for RateLimit functionality --- .../{RepeatableAnnotationTest.php => MultipleAnnotationTest.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/RateLimit/{RepeatableAnnotationTest.php => MultipleAnnotationTest.php} (100%) diff --git a/tests/RateLimit/RepeatableAnnotationTest.php b/tests/RateLimit/MultipleAnnotationTest.php similarity index 100% rename from tests/RateLimit/RepeatableAnnotationTest.php rename to tests/RateLimit/MultipleAnnotationTest.php From fd23e2cc6093b0a16b0dfd4c0599574b8499a398 Mon Sep 17 00:00:00 2001 From: Deeka Wong <8337659+huangdijia@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:16:55 +0800 Subject: [PATCH 6/7] chore: remove unnecessary blank lines in MultipleAnnotationTest --- tests/RateLimit/MultipleAnnotationTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/RateLimit/MultipleAnnotationTest.php b/tests/RateLimit/MultipleAnnotationTest.php index 43e3620e8..ab6b8b572 100644 --- a/tests/RateLimit/MultipleAnnotationTest.php +++ b/tests/RateLimit/MultipleAnnotationTest.php @@ -8,12 +8,10 @@ * @document https://github.com/friendsofhyperf/components/blob/main/README.md * @contact huangdijia@gmail.com */ - use FriendsOfHyperf\RateLimit\Algorithm; use FriendsOfHyperf\RateLimit\Annotation\RateLimit; use Hyperf\Di\Annotation\MultipleAnnotation; - test('RateLimit annotation supports IS_REPEATABLE flag', function () { $reflection = new ReflectionClass(RateLimit::class); $attributes = $reflection->getAttributes(Attribute::class); From 2f5c91f5d928d145ad6628337c3ee3c2fedf0940 Mon Sep 17 00:00:00 2001 From: Deeka Wong <8337659+huangdijia@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:40:51 +0800 Subject: [PATCH 7/7] fix: improve key resolution logic in RateLimitAspect for better default handling --- src/rate-limit/src/Aspect/RateLimitAspect.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/rate-limit/src/Aspect/RateLimitAspect.php b/src/rate-limit/src/Aspect/RateLimitAspect.php index 7a2d344c6..98f9fec21 100644 --- a/src/rate-limit/src/Aspect/RateLimitAspect.php +++ b/src/rate-limit/src/Aspect/RateLimitAspect.php @@ -60,8 +60,7 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint) protected function resolveKey(string|array $key, ProceedingJoinPoint $proceedingJoinPoint): string { - if (empty($key)) { - // Use method signature as default key + if (empty($key)) { // Use method signature as default key $className = $proceedingJoinPoint->className; $methodName = $proceedingJoinPoint->methodName; return "{$className}:{$methodName}";