From 965af92092ff83e2faa94846715d8868b8e8a6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kl=C3=BCpfel?= Date: Sun, 28 Jul 2024 11:10:54 +0200 Subject: [PATCH] adds tests, readme and ci --- .github/FUNDING.yml | 2 + .github/workflows/flutter.yml | 30 ++++ README.md | 68 ++++---- analysis_options.yaml | 11 +- coverage/lcov.info | 161 ++++++++++-------- example/main.dart | 18 ++ lib/src/token_bucket.dart | 54 +++++- lib/src/token_bucket_storage.dart | 34 +++- pubspec.yaml | 44 +---- test/mocks.dart | 4 + test/token_bucket_state_test.dart | 52 ++++++ test/token_bucket_storage_test.dart | 20 +++ ...rithm_test.dart => token_bucket_test.dart} | 97 +++++------ 13 files changed, 388 insertions(+), 207 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/flutter.yml create mode 100644 example/main.dart create mode 100644 test/mocks.dart create mode 100644 test/token_bucket_state_test.dart create mode 100644 test/token_bucket_storage_test.dart rename test/{token_bucket_algorithm_test.dart => token_bucket_test.dart} (71%) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c0e45e2 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +ko_fi: splashbyte +custom: [ 'buymeacoffee.com/splashbyte' ] \ No newline at end of file diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml new file mode 100644 index 0000000..0385188 --- /dev/null +++ b/.github/workflows/flutter.yml @@ -0,0 +1,30 @@ +name: Flutter Analyze & Test + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + + - name: Install dependencies + run: flutter pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze project source + run: flutter analyze + + - name: Run tests + run: flutter test --coverage + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 02fe8ec..ea3b928 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,45 @@ - +This Dart package provides rate limiting by using an implementation of the token bucket algorithm. -TODO: Put a short description of the package here that helps potential users -know whether this package might be useful for them. - -## Features - -TODO: List what your package can do. Maybe include images, gifs, or videos. - -## Getting started - -TODO: List prerequisites and provide or point to information on how to -start using the package. - -## Usage - -TODO: Include short and useful examples for package users. Add longer examples -to `/example` folder. +## Simple usage ```dart -const like = 'sample'; +final bucket = TokenBucket( + size: 15, + refillInterval: const Duration(seconds: 1), + refillAmount: 10, + storage: MemoryTokenBucketStorage(), // optionally change the way the state of the bucket is stored +); + +if(bucket.consume()) { + // Consumed 1 token successfully +} + +if(bucket.consume(2)) { + // Consumed 2 tokens successfully +} ``` -## Additional information +If you want to store the tokens asynchronously in a custom storage, you can also use the `AsyncTokenBucket`. -TODO: Tell users more about the package: where to find more information, how to -contribute to the package, how to file issues, what response they can expect -from the package authors, and more. +```dart +final bucket = AsyncTokenBucket( + size: 15, + refillInterval: const Duration(seconds: 1), + refillAmount: 10, + storage: MyCustomAsyncTokenBucketStorage(), +); + +if(await bucket.consume()) { + // Consumed 1 token successfully +} +``` diff --git a/analysis_options.yaml b/analysis_options.yaml index a5744c1..055a687 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,11 @@ include: package:flutter_lints/flutter.yaml -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options +analyzer: + language: + strict-casts: true + strict-raw-types: true + +linter: + rules: + prefer_single_quotes: true + parameter_assignments: true diff --git a/coverage/lcov.info b/coverage/lcov.info index 3ca6d0c..c448554 100644 --- a/coverage/lcov.info +++ b/coverage/lcov.info @@ -1,81 +1,102 @@ -SF:lib/src/token_bucket_state.dart -DA:5,1 -DA:7,1 -DA:8,1 -DA:9,1 -DA:10,1 -DA:14,1 -DA:15,5 -DA:17,1 -DA:18,1 -DA:19,1 -DA:21,2 -DA:24,2 -DA:25,1 -DA:26,2 -DA:29,1 -DA:32,1 -DA:33,3 -DA:34,3 -DA:35,3 -DA:37,1 -DA:38,5 -LF:21 -LH:21 -end_of_record SF:lib/src/token_bucket.dart -DA:14,1 -DA:19,2 -DA:20,2 -DA:21,2 -DA:27,1 -DA:28,2 +DA:25,1 DA:31,2 -DA:32,1 -DA:35,4 -DA:36,2 -DA:38,6 -DA:40,1 -DA:42,4 -DA:50,1 +DA:32,2 +DA:33,2 +DA:34,3 +DA:35,1 +DA:46,1 +DA:47,2 +DA:50,2 +DA:51,1 +DA:54,4 DA:55,2 -DA:57,1 -DA:59,2 -DA:60,5 -DA:63,6 -DA:66,1 -DA:68,3 -DA:69,3 -DA:71,2 -DA:72,3 -DA:73,1 -DA:74,2 -DA:79,1 -DA:80,4 -DA:81,3 -DA:82,2 -DA:89,1 +DA:57,6 +DA:59,1 +DA:61,4 +DA:70,1 +DA:76,2 +DA:78,1 +DA:80,3 +DA:83,1 +DA:84,2 +DA:86,2 +DA:87,4 +DA:92,1 DA:94,2 -DA:96,1 -DA:98,5 -DA:99,3 -DA:102,1 +DA:95,4 +DA:98,4 +DA:101,1 +DA:103,3 DA:104,3 -DA:105,3 -DA:107,3 +DA:106,2 +DA:107,2 DA:108,1 DA:109,2 -LF:41 -LH:41 +DA:114,1 +DA:115,4 +DA:116,3 +DA:117,2 +DA:128,1 +DA:134,2 +DA:136,1 +DA:138,1 +DA:141,1 +DA:142,2 +DA:144,2 +DA:145,4 +DA:150,1 +DA:152,2 +DA:153,2 +DA:154,1 +DA:157,1 +DA:159,3 +DA:160,3 +DA:162,2 +DA:163,1 +DA:164,2 +LF:56 +LH:56 end_of_record -SF:lib/src/token_bucket_storage.dart -DA:7,1 -DA:17,1 -DA:22,1 +SF:lib/src/token_bucket_state.dart +DA:10,3 +DA:13,2 +DA:14,2 +DA:15,2 +DA:16,2 +DA:20,1 +DA:21,5 DA:23,1 +DA:24,1 DA:25,1 -DA:26,1 -DA:30,1 -LF:7 -LH:7 +DA:27,2 +DA:30,2 +DA:31,1 +DA:32,2 +DA:35,3 +DA:38,2 +DA:39,6 +DA:40,6 +DA:41,6 +DA:43,1 +DA:44,5 +LF:21 +LH:21 +end_of_record +SF:lib/src/token_bucket_storage.dart +DA:7,2 +DA:16,2 +DA:27,2 +DA:31,2 +DA:32,2 +DA:34,2 +DA:35,2 +DA:40,3 +DA:44,1 +DA:46,1 +DA:47,3 +DA:49,1 +DA:50,3 +LF:13 +LH:13 end_of_record diff --git a/example/main.dart b/example/main.dart new file mode 100644 index 0000000..bccf4ed --- /dev/null +++ b/example/main.dart @@ -0,0 +1,18 @@ +import 'package:token_bucket_algorithm/token_bucket_algorithm.dart'; + +void main() { + final bucket = TokenBucket( + size: 15, + refillInterval: const Duration(seconds: 1), + refillAmount: 10, + storage: MemoryTokenBucketStorage(), + ); + + if(bucket.consume()) { + // Consumed 1 token successfully + } + + if(bucket.consume(2)) { + // Consumed 2 tokens successfully + } +} diff --git a/lib/src/token_bucket.dart b/lib/src/token_bucket.dart index 5efcf97..9504ee2 100644 --- a/lib/src/token_bucket.dart +++ b/lib/src/token_bucket.dart @@ -19,14 +19,23 @@ abstract class _BaseTokenBucket { /// The storage for the internal [TokenBucketState]. final S storage; + /// The initial amount of tokens. + final int initialAmount; + _BaseTokenBucket({ required this.size, required this.refillInterval, required this.refillAmount, required this.storage, + this.initialAmount = 0, }) : assert(size > 0), assert(refillAmount > 0), - assert(refillInterval > Duration.zero); + assert(refillInterval > Duration.zero), + assert(initialAmount >= 0 && initialAmount <= size) { + _init(); + } + + FutureOr _init(); /// Returns the currently available tokens of this bucket. FutureOr get availableTokens; @@ -62,16 +71,31 @@ class AsyncTokenBucket extends _BaseTokenBucket { required super.size, required super.refillInterval, required super.refillAmount, + super.initialAmount, AsyncTokenBucketStorage? storage, }) : super(storage: storage ?? MemoryTokenBucketStorage()); + @override + FutureOr _init() { + return _queueFuture(() => _getSafeFromStorage()); + } + + Future _getSafeFromStorage() async { + var result = await storage.get(); + if (result == null) { + await storage.set(result = + TokenBucketState(tokens: initialAmount, lastRefillTime: clock.now())); + } + return result; + } + @override FutureOr get availableTokens async { await _queueFuture(() async { - await storage.set(_refillBucket(await storage.get())); + await storage.set(_refillBucket(await _getSafeFromStorage())); return false; }); - return Future.value(storage.get()).then((state) => state.tokens); + return _getSafeFromStorage().then((state) => state.tokens); } @override @@ -80,7 +104,7 @@ class AsyncTokenBucket extends _BaseTokenBucket { throw ArgumentError('cost must be <=$size and >=1'); } return _queueFuture(() async { - final state = _refillBucket(await storage.get()); + final state = _refillBucket(await _getSafeFromStorage()); final (result, newState) = state.consume(cost); await storage.set(newState); return result; @@ -105,13 +129,29 @@ class TokenBucket extends _BaseTokenBucket { required super.size, required super.refillInterval, required super.refillAmount, + super.initialAmount, TokenBucketStorage? storage, }) : super(storage: storage ?? MemoryTokenBucketStorage()); + @override + void _init() { + _getSafeFromStorage(); + } + + TokenBucketState _getSafeFromStorage() { + var result = storage.get(); + if (result == null) { + storage.set(result = + TokenBucketState(tokens: initialAmount, lastRefillTime: clock.now())); + } + return result; + } + @override int get availableTokens { - storage.set(_refillBucket(storage.get())); - return storage.get().tokens; + final result = _refillBucket(_getSafeFromStorage()); + storage.set(result); + return result.tokens; } @override @@ -119,7 +159,7 @@ class TokenBucket extends _BaseTokenBucket { if (cost < 1 || cost > size) { throw ArgumentError('cost must be <=$size and >=1'); } - final state = _refillBucket(storage.get()); + final state = _refillBucket(_getSafeFromStorage()); final (result, newState) = state.consume(cost); storage.set(newState); return result; diff --git a/lib/src/token_bucket_storage.dart b/lib/src/token_bucket_storage.dart index 8d93b86..4fca3c5 100644 --- a/lib/src/token_bucket_storage.dart +++ b/lib/src/token_bucket_storage.dart @@ -1,35 +1,51 @@ import 'dart:async'; -import 'package:clock/clock.dart'; import 'package:token_bucket_algorithm/token_bucket_algorithm.dart'; +/// The base class for storing a [TokenBucketState]. +abstract class AsyncTokenBucketStorage { + const AsyncTokenBucketStorage(); + + FutureOr get(); + + FutureOr set(TokenBucketState state); +} + +/// The base class for storing a [TokenBucketState] synchronously. abstract class TokenBucketStorage extends AsyncTokenBucketStorage { const TokenBucketStorage(); @override - TokenBucketState get(); + TokenBucketState? get(); @override void set(TokenBucketState state); } +/// This [TokenBucketStorage] stores a [TokenBucketState] as local variable in memory. class MemoryTokenBucketStorage extends TokenBucketStorage { MemoryTokenBucketStorage(); - TokenBucketState _state = - TokenBucketState(tokens: 0, lastRefillTime: clock.now()); + TokenBucketState? _state; @override - TokenBucketState get() => _state; + TokenBucketState? get() => _state; @override void set(TokenBucketState state) => _state = state; } -abstract class AsyncTokenBucketStorage { - const AsyncTokenBucketStorage(); +/// This [TokenBucketStorage] stores a [TokenBucketState] as static variable in memory. +class StaticMemoryTokenBucketStorage extends TokenBucketStorage { + static final Map _states = {}; - FutureOr get(); + final String key; - FutureOr set(TokenBucketState state); + StaticMemoryTokenBucketStorage({required this.key}); + + @override + TokenBucketState? get() => _states[key]; + + @override + void set(TokenBucketState state) => _states[key] = state; } diff --git a/pubspec.yaml b/pubspec.yaml index 8f68f87..a96c42a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,6 +4,12 @@ version: 0.1.0 repository: https://github.com/splashbyte/dart_token_bucket_algorithm issue_tracker: https://github.com/splashbyte/dart_token_bucket_algorithm/issues +topics: + - rate-limit + - token-bucket + - algorithm + - security + environment: sdk: '>=3.4.4 <4.0.0' flutter: ">=1.17.0" @@ -18,40 +24,4 @@ dev_dependencies: sdk: flutter flutter_lints: ^3.0.0 fake_async: ^1.3.1 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - -# To add assets to your package, add an assets section, like this: -# assets: -# - images/a_dot_burr.jpeg -# - images/a_dot_ham.jpeg -# -# For details regarding assets in packages, see -# https://flutter.dev/assets-and-images/#from-packages -# -# An image asset can refer to one or more resolution-specific "variants", see -# https://flutter.dev/assets-and-images/#resolution-aware - -# To add custom fonts to your package, add a fonts section here, -# in this "flutter" section. Each entry in this list should have a -# "family" key with the font family name, and a "fonts" key with a -# list giving the asset and other descriptors for the font. For -# example: -# fonts: -# - family: Schyler -# fonts: -# - asset: fonts/Schyler-Regular.ttf -# - asset: fonts/Schyler-Italic.ttf -# style: italic -# - family: Trajan Pro -# fonts: -# - asset: fonts/TrajanPro.ttf -# - asset: fonts/TrajanPro_Bold.ttf -# weight: 700 -# -# For details regarding fonts in packages, see -# https://flutter.dev/custom-fonts/#from-packages + mocktail: ^1.0.4 diff --git a/test/mocks.dart b/test/mocks.dart new file mode 100644 index 0000000..21d86cc --- /dev/null +++ b/test/mocks.dart @@ -0,0 +1,4 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:token_bucket_algorithm/src/token_bucket_storage.dart'; + +class MockTokenBucketStorage extends Mock implements TokenBucketStorage {} diff --git a/test/token_bucket_state_test.dart b/test/token_bucket_state_test.dart new file mode 100644 index 0000000..4cb3578 --- /dev/null +++ b/test/token_bucket_state_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:token_bucket_algorithm/token_bucket_algorithm.dart'; + +void main() { + test('fromJson and toJson', () async { + const tokens = 3; + final lastRefillTime = DateTime(2000); + final state = + TokenBucketState(tokens: tokens, lastRefillTime: lastRefillTime); + + expect( + state.toJson(), + allOf( + containsPair('tokens', tokens), + containsPair('lastRefillTime', lastRefillTime.microsecondsSinceEpoch), + hasLength(2), + ), + ); + + expect( + TokenBucketState.fromJson({ + 'tokens': tokens, + 'lastRefillTime': lastRefillTime.microsecondsSinceEpoch, + }), + state, + ); + }); + + test('hashCode and ==', () async { + const tokens = 3; + final lastRefillTime = DateTime(2000); + final state1 = + TokenBucketState(tokens: tokens, lastRefillTime: lastRefillTime); + final state2 = + TokenBucketState(tokens: tokens, lastRefillTime: lastRefillTime); + + expect(state1, state2); + expect(state1.hashCode, state2.hashCode); + }); + + test('copyWith', () async { + const tokens = 3; + final lastRefillTime = DateTime(2000); + final state = + TokenBucketState(tokens: tokens, lastRefillTime: lastRefillTime); + + expect(state.copyWith(tokens: 4), + TokenBucketState(tokens: 4, lastRefillTime: lastRefillTime)); + expect(state.copyWith(lastRefillTime: DateTime(2001)), + TokenBucketState(tokens: tokens, lastRefillTime: DateTime(2001))); + }); +} diff --git a/test/token_bucket_storage_test.dart b/test/token_bucket_storage_test.dart new file mode 100644 index 0000000..2f60a36 --- /dev/null +++ b/test/token_bucket_storage_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:token_bucket_algorithm/token_bucket_algorithm.dart'; + +void _testStorage(TokenBucketStorage storage) { + final value = TokenBucketState(tokens: 4, lastRefillTime: DateTime(2003)); + expect(storage.get(), null, reason: 'get() does not return null initially'); + storage.set(value); + expect(storage.get(), value, reason: 'get() does not return the set value'); + expect(storage.get(), value, reason: 'get() seems to change the value'); +} + +void main() { + test('MemoryTokenBucketStorage', () { + _testStorage(MemoryTokenBucketStorage()); + }); + + test('StaticMemoryTokenBucketStorage', () { + _testStorage(StaticMemoryTokenBucketStorage(key: 'test')); + }); +} diff --git a/test/token_bucket_algorithm_test.dart b/test/token_bucket_test.dart similarity index 71% rename from test/token_bucket_algorithm_test.dart rename to test/token_bucket_test.dart index 62243a1..1d86168 100644 --- a/test/token_bucket_algorithm_test.dart +++ b/test/token_bucket_test.dart @@ -1,13 +1,56 @@ import 'package:clock/clock.dart'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:token_bucket_algorithm/token_bucket_algorithm.dart'; +import 'mocks.dart'; + void main() { - test('Bucket is initialized with 0 tokens', () { - final bucket = TokenBucket( + test('Bucket is respects initialAmount', () { + final bucket1 = TokenBucket( size: 15, refillInterval: const Duration(seconds: 1), refillAmount: 10); - expect(bucket.availableTokens, 0); + expect(bucket1.availableTokens, 0); + + final bucket2 = TokenBucket( + size: 15, + refillInterval: const Duration(seconds: 1), + refillAmount: 10, + initialAmount: 3); + expect(bucket2.availableTokens, 3); + }); + + test('Bucket stores token initially', () { + fakeAsync((async) { + final storage = MockTokenBucketStorage(); + when(() => storage.get()).thenReturn(null); + TokenBucket( + size: 15, + refillInterval: const Duration(seconds: 1), + refillAmount: 10, + initialAmount: 3, + storage: storage, + ); + verify(() => storage.set( + TokenBucketState(tokens: 3, lastRefillTime: clock.now()))).called(1); + }); + }); + + test('Async bucket stores token initially', () { + fakeAsync((async) { + final storage = MockTokenBucketStorage(); + when(() => storage.get()).thenReturn(null); + AsyncTokenBucket( + size: 15, + refillInterval: const Duration(seconds: 1), + refillAmount: 10, + initialAmount: 3, + storage: storage, + ); + async.flushMicrotasks(); + verify(() => storage.set( + TokenBucketState(tokens: 3, lastRefillTime: clock.now()))).called(1); + }); }); test('Consuming invalid amounts', () { @@ -104,52 +147,4 @@ void main() { async.elapse(Duration.zero); }); }); - - test('fromJson and toJson', () async { - const tokens = 3; - final lastRefillTime = DateTime(2000); - final state = - TokenBucketState(tokens: tokens, lastRefillTime: lastRefillTime); - - expect( - state.toJson(), - allOf( - containsPair('tokens', tokens), - containsPair('lastRefillTime', lastRefillTime.microsecondsSinceEpoch), - hasLength(2), - ), - ); - - expect( - TokenBucketState.fromJson({ - 'tokens': tokens, - 'lastRefillTime': lastRefillTime.microsecondsSinceEpoch, - }), - state, - ); - }); - - test('hashCode and ==', () async { - const tokens = 3; - final lastRefillTime = DateTime(2000); - final state1 = - TokenBucketState(tokens: tokens, lastRefillTime: lastRefillTime); - final state2 = - TokenBucketState(tokens: tokens, lastRefillTime: lastRefillTime); - - expect(state1, state2); - expect(state1.hashCode, state2.hashCode); - }); - - test('copyWith', () async { - const tokens = 3; - final lastRefillTime = DateTime(2000); - final state = - TokenBucketState(tokens: tokens, lastRefillTime: lastRefillTime); - - expect(state.copyWith(tokens: 4), - TokenBucketState(tokens: 4, lastRefillTime: lastRefillTime)); - expect(state.copyWith(lastRefillTime: DateTime(2001)), - TokenBucketState(tokens: tokens, lastRefillTime: DateTime(2001))); - }); }