diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 27693b5a..0ada1262 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -18,11 +18,9 @@ jobs: changes: runs-on: ubuntu-latest - permissions: - pull-requests: read - outputs: needs_dart_build: ${{ steps.needs_dart_build.outputs.changes }} + needs_redis_build: ${{ steps.needs_redis_build.outputs.changes }} needs_verify: ${{ steps.needs_verify.outputs.changes }} name: 👀 Detect Changes @@ -48,10 +46,6 @@ jobs: - ./.github/workflows/main.yaml - ./.github/actions/dart_package/action.yaml - packages/discord_gcp_alerts/** - redis_client: - - ./.github/workflows/main.yaml - - ./.github/actions/dart_package/action.yaml - - packages/redis_client/** shorebird_cli: - ./.github/workflows/main.yaml - ./.github/actions/dart_package/action.yaml @@ -76,6 +70,16 @@ jobs: - ./.github/actions/dart_package/action.yaml - packages/scoped/** + - uses: dorny/paths-filter@v2 + name: Redis Detection + id: needs_redis_build + with: + filters: | + redis_client: + - ./.github/workflows/main.yaml + - ./.github/actions/dart_package/action.yaml + - packages/redis_client/** + - uses: dorny/paths-filter@v2 name: Verify Detection id: needs_verify @@ -113,6 +117,35 @@ jobs: working_directory: packages/${{ matrix.package }} min_coverage: ${{ matrix.package == 'cutler' && '10' || '100' }} + build_redis: + needs: changes + if: ${{ needs.changes.outputs.needs_redis_build != '[]' }} + + permissions: write-all + + strategy: + matrix: + package: ${{ fromJSON(needs.changes.outputs.needs_redis_build) }} + + runs-on: ubuntu-latest + + name: 🎯 Build ${{ matrix.package }} + + steps: + - name: 📚 Git Checkout + uses: actions/checkout@v3 + + - name: 🐳 Run Redis + run: | + docker pull redis:latest + docker run --name test_redis -d -p 6379:6379 redis redis-server --requirepass "password" + + - name: 🎯 Build ${{ matrix.package }} + uses: ./.github/actions/dart_package + with: + codecov_token: ${{ secrets.CODECOV_TOKEN }} + working_directory: packages/${{ matrix.package }} + verify_packages: needs: changes if: ${{ needs.changes.outputs.needs_verify != '[]' }} @@ -135,7 +168,8 @@ jobs: working_directory: packages/${{ matrix.package }} ci: - needs: [semantic_pull_request, build_dart_packages, verify_packages] + needs: + [semantic_pull_request, build_dart_packages, build_redis, verify_packages] if: ${{ always() }} runs-on: ubuntu-latest diff --git a/packages/redis_client/README.md b/packages/redis_client/README.md index 0e4d1a51..e55e4b28 100644 --- a/packages/redis_client/README.md +++ b/packages/redis_client/README.md @@ -7,6 +7,34 @@ A Dart library for interacting with [Redis](https://redis.io). [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg [license_link]: https://opensource.org/licenses/MIT +## Quick Start + +```dart +import 'dart:async'; + +import 'package:redis_client/redis_client.dart'; + +Future main() async { + // Create an instance of a RedisClient. + final client = RedisClient(); + + // Connect to the Redis server. + await client.connect(); + + // Set the value of a key. + await client.set(key: 'HELLO', value: 'WORLD'); + + // Get the value of a key. + final value = await client.get(key: 'HELLO'); // WORLD + + // Delete the key. + await client.delete(key: 'HELLO'); + + // Close the connection to the Redis server. + await client.close(); +} +``` + ## License Shorebird packages are licensed for use under either Apache License, Version 2.0 diff --git a/packages/redis_client/analysis_options.yaml b/packages/redis_client/analysis_options.yaml index b388541f..799268d3 100644 --- a/packages/redis_client/analysis_options.yaml +++ b/packages/redis_client/analysis_options.yaml @@ -1 +1 @@ -include: package:very_good_analysis/analysis_options.5.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/packages/redis_client/example/main.dart b/packages/redis_client/example/main.dart new file mode 100644 index 00000000..d5c3d288 --- /dev/null +++ b/packages/redis_client/example/main.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:redis_client/redis_client.dart'; + +Future main() async { + // Create an instance of a RedisClient. + final client = RedisClient(); + + // Connect to the Redis server. + await client.connect(); + + const key = 'HELLO'; + + final initialValue = await client.get(key: key); // null + assert(initialValue == null, 'Key should not exist.'); + + // Set the value of a key. + await client.set(key: key, value: 'WORLD'); + + // Get the value of a key. + final value = await client.get(key: key); // WORLD + assert(value == 'WORLD', 'Value should be "WORLD".'); + + // Delete the key. + await client.delete(key: key); + + final finalValue = await client.get(key: key); // null + assert(finalValue == null, 'Key should not exist.'); + + // Close the connection to the Redis server. + await client.close(); +} diff --git a/packages/redis_client/lib/redis_client.dart b/packages/redis_client/lib/redis_client.dart index 4ff97bd2..ab7cd4e0 100644 --- a/packages/redis_client/lib/redis_client.dart +++ b/packages/redis_client/lib/redis_client.dart @@ -1 +1,2 @@ -export 'src/redis_client.dart' show RedisClient; +export 'src/redis_client.dart' + show RedisClient, RedisCommandOptions, RedisSocketOptions; diff --git a/packages/redis_client/lib/src/redis_client.dart b/packages/redis_client/lib/src/redis_client.dart index b54dedd0..87505425 100644 --- a/packages/redis_client/lib/src/redis_client.dart +++ b/packages/redis_client/lib/src/redis_client.dart @@ -1,7 +1,245 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:resp_client/resp_client.dart'; +import 'package:resp_client/resp_commands.dart'; +import 'package:resp_client/resp_server.dart'; + +/// {@template redis_socket_options} +/// Options for connecting to a Redis server. +/// {@endtemplate} +class RedisSocketOptions { + /// {@macro redis_socket_options} + const RedisSocketOptions({ + this.host = 'localhost', + this.port = 6379, + this.timeout = const Duration(seconds: 30), + }); + + /// The host of the Redis server. + /// Defaults to localhost. + final String host; + + /// The port of the Redis server. + /// Defaults to 6379. + final int port; + + /// The timeout for connecting to the Redis server. + /// Defaults to 30 seconds. + final Duration timeout; +} + +/// {@template redis_command_options} +/// Options for sending commands to a Redis server. +/// {@endtemplate} +class RedisCommandOptions { + /// {@macro redis_command_options} + const RedisCommandOptions({ + this.timeout = const Duration(seconds: 10), + }); + + /// The timeout for sending commands to the Redis server. + /// Defaults to 10 seconds. + final Duration timeout; +} + /// {@template redis_client} /// A client for interacting with a Redis server. /// {@endtemplate} class RedisClient { /// {@macro redis_client} - const RedisClient(); + RedisClient({ + RedisSocketOptions socket = const RedisSocketOptions(), + RedisCommandOptions command = const RedisCommandOptions(), + }) : _socketOptions = socket, + _commandOptions = command; + + /// The socket options for the Redis server. + final RedisSocketOptions _socketOptions; + + /// The command options for the Redis client. + final RedisCommandOptions _commandOptions; + + /// The underlying connection to the Redis server. + RespServerConnection? _connection; + + /// The underlying client for interacting with the Redis server. + RespClient? _client; + + /// Whether the client has been closed. + var _closed = false; + + /// A completer which completes when the client establishes a connection. + var _connected = Completer(); + + /// A completer which completes when the client disconnects. + /// Begins in a completed state since the client is initially disconnected. + var _disconnected = Completer()..complete(); + + /// A future which completes when the client establishes a connection. + Future get connected => _connected.future; + + /// A future which completes when the client disconnects. + Future get disconnected => _disconnected.future; + + /// Authenticate to the Redis server. + /// Returns true if successful, otherwise false. + /// Equivalent to the `AUTH` command. + /// https://redis.io/commands/auth + Future auth({ + required String password, + String username = 'default', + }) async { + final result = await _exec( + () => sendCommand(['AUTH', username, password]), + ); + if (result is RespSimpleString) return result.payload == 'OK'; + return false; + } + + /// Set the value of a key. + /// Equivalent to the `SET` command. + /// https://redis.io/commands/set + Future set({required String key, required String value}) { + return _exec(() => RespCommandsTier2(_client!).set(key, value)); + } + + /// Gets the value of a key. + /// Returns null if the key does not exist. + /// Equivalent to the `GET` command. + /// https://redis.io/commands/get + Future get({required String key}) { + return _exec(() => RespCommandsTier2(_client!).get(key)); + } + + /// Deletes the specified key. + /// Equivalent to the `DEL` command. + /// https://redis.io/commands/del + Future delete({required String key}) { + return _exec(() => RespCommandsTier2(_client!).del([key])); + } + + /// Send a command to the Redis server. + Future> sendCommand(List command) async { + return _exec(() => RespCommandsTier0(_client!).execute(command)); + } + + /// Establish a connection to the Redis server. + /// The delay between connection attempts. + Future connect({ + Duration connectionRetryDelay = const Duration(milliseconds: 100), + int maxConnectionAttempts = 100, + }) async { + if (_closed) throw StateError('RedisClient has been closed.'); + + unawaited( + _reconnect( + connectionRetryDelay: connectionRetryDelay, + remainingConnectionAttempts: maxConnectionAttempts, + ), + ); + + return connected; + } + + /// Terminate the connection to the Redis server. + Future disconnect() { + _connection?.close(); + _reset(); + return disconnected; + } + + /// Terminate the connection to the Redis server and close the client. + /// After this method is called, the client instance is no longer usable. + /// Call this method when you are done using the client and/or wish to + /// prevent reconnection attempts. + Future close() { + _closed = true; + return disconnect(); + } + + Future _reconnect({ + required Duration connectionRetryDelay, + required int remainingConnectionAttempts, + Object? error, + StackTrace? stackTrace, + }) async { + if (remainingConnectionAttempts <= 0) { + _connected.completeError( + error ?? const SocketException('Connection retry limit exceeded'), + stackTrace, + ); + return; + } + + void onConnectionOpened(RespServerConnection connection) { + _disconnected = Completer(); + _connection = connection; + _client = RespClient(connection); + _connected.complete(); + } + + void onConnectionClosed([Object? error, StackTrace? stackTrace]) { + _reset(); + _reconnect( + connectionRetryDelay: connectionRetryDelay, + remainingConnectionAttempts: remainingConnectionAttempts - 1, + error: error, + stackTrace: stackTrace, + ); + } + + try { + final uri = _socketOptions.connectionUri; + final connection = await connectSocket( + uri.host, + port: uri.port, + timeout: _socketOptions.timeout, + ); + + onConnectionOpened(connection); + + unawaited( + connection.outputSink.done + .then((_) => onConnectionClosed()) + .catchError(onConnectionClosed), + ); + } catch (error, stackTrace) { + Future.delayed( + connectionRetryDelay, + () => _reconnect( + connectionRetryDelay: connectionRetryDelay, + remainingConnectionAttempts: remainingConnectionAttempts - 1, + error: error, + stackTrace: stackTrace, + ), + ); + } + } + + void _reset() { + _connected = Completer(); + _connection = null; + _client = null; + if (!_disconnected.isCompleted) _disconnected.complete(); + } + + Future _exec(FutureOr Function() fn) async { + if (_closed) throw StateError('RedisClient has been closed.'); + await connected; + return Future.sync(() { + return fn(); + }).timeout( + _commandOptions.timeout, + onTimeout: () { + _connection?.close(); + throw const SocketException('Connection timed out'); + }, + ); + } +} + +extension on RedisSocketOptions { + /// The connection URI for the Redis server derived from the socket options. + Uri get connectionUri => Uri.parse('redis://$host:$port'); } diff --git a/packages/redis_client/pubspec.yaml b/packages/redis_client/pubspec.yaml index 767fe0ad..db724fed 100644 --- a/packages/redis_client/pubspec.yaml +++ b/packages/redis_client/pubspec.yaml @@ -6,6 +6,9 @@ publish_to: none environment: sdk: ">=3.0.0 <4.0.0" +dependencies: + resp_client: ^1.2.0 + dev_dependencies: test: ^1.19.2 - very_good_analysis: ^5.0.0 + very_good_analysis: ^5.1.0 diff --git a/packages/redis_client/test/src/redis_client_test.dart b/packages/redis_client/test/src/redis_client_test.dart index e2e009ad..235e0c3e 100644 --- a/packages/redis_client/test/src/redis_client_test.dart +++ b/packages/redis_client/test/src/redis_client_test.dart @@ -1,10 +1,159 @@ +import 'dart:io'; + import 'package:redis_client/redis_client.dart'; import 'package:test/test.dart'; void main() { group(RedisClient, () { - test('can be instantiated', () { - expect(const RedisClient(), isNotNull); + late RedisClient client; + + setUp(() async { + client = RedisClient(); + await client.connect(); + }); + + tearDown(() async { + try { + await client.sendCommand(['RESET']); + await client.sendCommand(['FLUSHALL']); + await client.close(); + } catch (_) { + // ignore + } + }); + + group('connect', () { + test('throws SocketException when connection times out', () async { + final client = RedisClient( + socket: const RedisSocketOptions(timeout: Duration(microseconds: 1)), + ); + await expectLater( + () => client.connect(maxConnectionAttempts: 1), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Connection timed out'), + ), + ), + ); + }); + + test('throws SocketException after max connection attempts', () async { + final client = RedisClient( + socket: const RedisSocketOptions(port: 1234), + ); + await expectLater( + () => client.connect(maxConnectionAttempts: 1), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Connection refused'), + ), + ), + ); + }); + + test('throws SocketException after disconnect', () async { + final client = RedisClient( + socket: const RedisSocketOptions(port: 1234), + ); + await expectLater( + () => client.connect(maxConnectionAttempts: 0), + throwsA( + isA().having( + (e) => '$e', + 'message', + contains('Connection retry limit exceeded'), + ), + ), + ); + }); + + test('throws StateError when closed', () async { + await client.close(); + await expectLater( + client.connect(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'RedisClient has been closed.', + ), + ), + ); + }); + }); + + group('AUTH', () { + test('is required', () async { + await expectLater( + client.get(key: 'foo'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('-NOAUTH Authentication required.'), + ), + ), + ); + }); + + test('fails when username is incorrect', () async { + await expectLater( + client.auth(username: 'shorebird', password: 'password'), + completion(isFalse), + ); + }); + + test('fails when password is incorrect', () async { + await expectLater( + client.auth(password: 'oops'), + completion(isFalse), + ); + }); + + test('succeeds when username/password are correct', () async { + await expectLater( + client.auth(password: 'password'), + completion(isTrue), + ); + }); + }); + + group('GET/SET/DEL', () { + test('completes', () async { + const key = 'key'; + const value = 'value'; + await client.auth(password: 'password'); + await expectLater(client.get(key: key), completion(isNull)); + await expectLater(client.set(key: key, value: value), completes); + await expectLater(client.get(key: key), completion(equals(value))); + await expectLater(client.delete(key: key), completes); + await expectLater(client.get(key: key), completion(isNull)); + }); + + test( + 'throws SocketException ' + 'when command timeout is exceeded', () async { + final client = RedisClient( + command: const RedisCommandOptions( + timeout: Duration.zero, + ), + ); + await client.connect(); + await expectLater( + client.get(key: 'foo'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Connection timed out'), + ), + ), + ); + }); }); }); }