diff --git a/src/rate-limit/src/Annotation/RateLimit.php b/src/rate-limit/src/Annotation/RateLimit.php index 263c927ca..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)] -class RateLimit extends AbstractAnnotation +#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +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 8d66bac6b..98f9fec21 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; @@ -26,34 +27,32 @@ class RateLimitAspect extends AbstractAspect RateLimit::class, ]; - public function __construct( - protected RateLimiterFactory $factory, - ) { + public function __construct(protected RateLimiterFactory $factory) + { } public function process(ProceedingJoinPoint $proceedingJoinPoint) { $metadata = $proceedingJoinPoint->getAnnotationMetadata(); - /** @var null|RateLimit $annotation */ - $annotation = $metadata->method[RateLimit::class] ?? null; - - if (! $annotation) { - return $proceedingJoinPoint->process(); - } + /** @var null|MultipleAnnotation $annotations */ + $annotations = $metadata->method[RateLimit::class] ?? null; - $key = $this->resolveKey($annotation->key, $proceedingJoinPoint); - $limiter = $this->factory->make($annotation->algorithm, $annotation->pool); + foreach ($annotations?->toAnnotations() ?? [] as $annotation) { + /** @var RateLimit $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); + 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 - ); + throw new RateLimitException( + $message, + $annotation->responseCode, + $availableIn + ); + } } return $proceedingJoinPoint->process(); @@ -61,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}"; diff --git a/tests/RateLimit/MultipleAnnotationTest.php b/tests/RateLimit/MultipleAnnotationTest.php new file mode 100644 index 000000000..ab6b8b572 --- /dev/null +++ b/tests/RateLimit/MultipleAnnotationTest.php @@ -0,0 +1,113 @@ +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 () { + $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 () { + $singleAnnotation = new RateLimit(key: 'test:single', maxAttempts: 10); + + $multipleAnnotation = new MultipleAnnotation($singleAnnotation); + + expect($multipleAnnotation)->toBeInstanceOf(MultipleAnnotation::class); + + $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 () { + $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); +});