Skip to content
6 changes: 3 additions & 3 deletions src/rate-limit/src/Annotation/RateLimit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
40 changes: 19 additions & 21 deletions src/rate-limit/src/Aspect/RateLimitAspect.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,43 +27,40 @@ 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();
}

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}";
Expand Down
113 changes: 113 additions & 0 deletions tests/RateLimit/MultipleAnnotationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);
/**
* This file is part of friendsofhyperf/components.
*
* @link https://github.com/friendsofhyperf/components
* @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);

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);
});