diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1e0a14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Files and directories created by pub. +.dart_tool/ +.packages + +# Conventional directory for build outputs. +build/ + +# Omit committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock +/.idea/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b55e73 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ + + +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. + +```dart +const like = 'sample'; +``` + +## Additional information + +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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/example/upstash_redis_example.dart b/example/upstash_redis_example.dart new file mode 100644 index 0000000..008153a --- /dev/null +++ b/example/upstash_redis_example.dart @@ -0,0 +1,30 @@ +import 'package:upstash_redis/src/commands/zadd.dart'; +import 'package:upstash_redis/upstash_redis.dart'; + +Future main() async { + final redis = Redis.fromEnv(); + + print(await redis.set('name', 'rebaz', ex: 60)); + print(await redis.set( + 'obj', + { + 'v': { + 'a': [1, 2], + 'b': [3, 4] + } + }, + ex: 60)); + print(await redis.get('name')); + print(await redis.get>>>('obj')); + print(await redis.set('name', 'raouf', ex: 60, nx: true)); + print(await redis.zadd('z', score: 1, member: 'rebaz')); + print(await redis.zadd( + 'z2', + scores: [ + ScoreMember(score: 2, member: 'Mike'), + ScoreMember(score: 3, member: 'Ali'), + ScoreMember(score: 4, member: 'Jack'), + ], + )); + print(await redis.zrem('z2', ['Jack', 'Ali'])); +} diff --git a/lib/src/commands/command.dart b/lib/src/commands/command.dart new file mode 100644 index 0000000..b5211ac --- /dev/null +++ b/lib/src/commands/command.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:upstash_redis/src/http.dart'; +import 'package:upstash_redis/src/upstash_error.dart'; +import 'package:upstash_redis/src/utils.dart'; + +typedef Serialize = String Function(dynamic data); +typedef Deserialize = TData Function(TResult result); + +String defaultSerializer(dynamic data) { + if (data is String) return data; + return json.encode(data); +} + +TData _castedDeserializer(TResult result) { + return (result as dynamic) as TData; +} + +class CommandOption { + CommandOption({this.deserialize, this.automaticDeserialization = true}); + + /// Custom deserialize + final Deserialize? deserialize; + + /// Automatically try to deserialize the returned data from upstash using `json.decode` + /// + /// default is true + final bool automaticDeserialization; +} + +/// Command offers default (de)serialization and the exec method to all commands. +/// +/// TData represents what the user will enter or receive, +/// TResult is the raw data returned from upstash, which may need to be transformed or parsed. +abstract class Command { + Command( + /*String|unknown*/ + List command, [ + CommandOption? opts, + this.serialize = defaultSerializer, + ]) : deserialize = (opts == null || opts.automaticDeserialization == true) + ? opts?.deserialize ?? parseResponse + : _castedDeserializer, + command = command.map(serialize).toList(); + + final List command; + final Serialize serialize; + final Deserialize deserialize; + + Future exec(Requester client) async { + final response = await client.request(body: command); + final result = checkUpstashResponse(response); + return deserialize(result as TResult); + } +} + +@pragma('vm:prefer-inline') +@pragma('dart2js:tryInline') +TResult? checkUpstashResponse(UpstashResponse response) { + final error = response.error; + final result = response.result; + + if (error != null) { + throw UpstashError(error); + } + + if (result == undefined) { + throw Exception('Request did not return a result'); + } + + return result; +} diff --git a/lib/src/commands/del.dart b/lib/src/commands/del.dart new file mode 100644 index 0000000..1c777cd --- /dev/null +++ b/lib/src/commands/del.dart @@ -0,0 +1,12 @@ +import 'package:upstash_redis/src/commands/command.dart'; + +class DelCommand extends Command { + DelCommand._(super.command, super.opts); + + factory DelCommand( + List command, [ + CommandOption? opts, + ]) { + return DelCommand._(['del', ...command], opts); + } +} diff --git a/lib/src/commands/get.dart b/lib/src/commands/get.dart new file mode 100644 index 0000000..986700b --- /dev/null +++ b/lib/src/commands/get.dart @@ -0,0 +1,12 @@ +import 'package:upstash_redis/src/commands/command.dart'; + +class GetCommand extends Command { + GetCommand._(super.command, super.opts); + + factory GetCommand( + List command, [ + CommandOption? opts, + ]) { + return GetCommand._(['get', ...command], opts); + } +} diff --git a/lib/src/commands/mod.dart b/lib/src/commands/mod.dart new file mode 100644 index 0000000..a18d208 --- /dev/null +++ b/lib/src/commands/mod.dart @@ -0,0 +1,7 @@ +export 'command.dart'; +export 'get.dart'; +export 'set.dart'; +export 'zrem.dart'; +export 'del.dart'; +export 'zadd.dart'; +export 'zscore.dart'; \ No newline at end of file diff --git a/lib/src/commands/set.dart b/lib/src/commands/set.dart new file mode 100644 index 0000000..ff4c786 --- /dev/null +++ b/lib/src/commands/set.dart @@ -0,0 +1,40 @@ +import 'package:upstash_redis/src/commands/command.dart'; +import 'package:upstash_redis/src/commands/mod.dart'; + +class SetCommand extends Command { + SetCommand._(super.command, super.opts); + + factory SetCommand( + String key, + TData value, { + int? ex, + int? px, + bool? nx, + bool? xx, + CommandOption? cmdOpts, + }) { + final command = ["set", key, value]; + + if (ex != null && px != null) { + throw StateError('should only provide "ex" or "px"'); + } + + if (nx != null && xx != null) { + throw StateError('should only provide "nx" or "xx"'); + } + + if (ex is int) { + command.addAll(['ex', ex]); + } else if (px is int) { + command.addAll(['px', px]); + } + + if (nx == true) { + command.add('nx'); + } else if (xx == true) { + command.add('xx'); + } + + return SetCommand._(command, cmdOpts); + } +} diff --git a/lib/src/commands/zadd.dart b/lib/src/commands/zadd.dart new file mode 100644 index 0000000..7ec9c1d --- /dev/null +++ b/lib/src/commands/zadd.dart @@ -0,0 +1,89 @@ +import 'package:upstash_redis/src/commands/command.dart'; +import 'package:collection/collection.dart'; +import 'package:upstash_redis/src/http.dart'; + +class ScoreMember { + const ScoreMember({ + required this.score, + required this.member, + }); + + final num score; + final TData member; +} + +class ZAddCommand extends Command { + ZAddCommand._(super.command, super.opts); + + factory ZAddCommand.single( + String key, { + num? score, + TData? member, + bool? ch, + bool? incr, + bool? nx, + bool? xx, + CommandOption? cmdOpts, + }) { + ScoreMember? scoreMember; + if (score != null && member != null) { + scoreMember = ScoreMember(score: score, member: member); + } + return ZAddCommand( + key, + [if (scoreMember != null) scoreMember], + ch: ch, + incr: incr, + nx: nx, + xx: xx, + cmdOpts: cmdOpts, + ); + } + + factory ZAddCommand( + String key, + List> scoreMembers, { + bool? ch, + bool? incr, + bool? nx, + bool? xx, + CommandOption? cmdOpts, + }) { + if (nx != null && xx != null) { + throw StateError('should only provide "nx" or "xx"'); + } + + final command = ['zadd', key]; + + if (nx == true) { + command.add('nx'); + } else if (xx == true) { + command.add('xx'); + } + + if (ch == true) { + command.add('ch'); + } + if (incr == true) { + command.add('incr'); + } + + final flatScoreMap = scoreMembers.map((e) => [e.score, e.member]).flattened; + command.addAll(flatScoreMap); + + return ZAddCommand._(command, cmdOpts); + } + + @override + Future exec(Requester client) async { + final response = await client.request(body: command); + final result = checkUpstashResponse(response); + + if (result is String) { + return num?.tryParse(result); + } else if (result is num) { + return result; + } + return deserialize(result); + } +} diff --git a/lib/src/commands/zrem.dart b/lib/src/commands/zrem.dart new file mode 100644 index 0000000..774b2b7 --- /dev/null +++ b/lib/src/commands/zrem.dart @@ -0,0 +1,13 @@ +import 'package:upstash_redis/src/commands/command.dart'; + +class ZRemCommand extends Command { + ZRemCommand._(super.command, super.opts); + + factory ZRemCommand( + String key, + List members, [ + CommandOption? opts, + ]) { + return ZRemCommand._(['zrem', key, ...members], opts); + } +} diff --git a/lib/src/commands/zscore.dart b/lib/src/commands/zscore.dart new file mode 100644 index 0000000..d40dfc6 --- /dev/null +++ b/lib/src/commands/zscore.dart @@ -0,0 +1,13 @@ +import 'package:upstash_redis/src/commands/command.dart'; + +class ZScoreCommand extends Command { + ZScoreCommand._(super.command, super.opts); + + factory ZScoreCommand( + String key, + TData member, [ + CommandOption? opts, + ]) { + return ZScoreCommand._(['zscore', key, member], opts); + } +} diff --git a/lib/src/converters.dart b/lib/src/converters.dart new file mode 100644 index 0000000..027689f --- /dev/null +++ b/lib/src/converters.dart @@ -0,0 +1,264 @@ +/// this [mapConversions] and [listConversions] used to easily convert value to common type +/// and not care about custom serializer. +/// +/// in upstash js library this will work out of the box, but in dart need to explicitly cast the value, +/// you can also add new convert if you want using [addNewConverter] +/// +final mapConversions = { + // -------------------- MAP CONVERSION ----------------------- + // raw + (Map).toString(): (Map value) => Map.from(value), + (Map).toString(): (Map value) => Map.from(value), + (Map).toString(): (Map value) => Map.from(value), + (Map).toString(): (Map value) => Map.from(value), + (Map).toString(): (Map value) => Map.from(value), + // list raw value + (Map>).toString(): (Map value) { + return Map>.fromEntries( + value.entries.map((e) => MapEntry(e.key as String, List.from(e.value))), + ); + }, + (Map>).toString(): (Map value) { + return Map>.fromEntries( + value.entries.map((e) => MapEntry(e.key as String, List.from(e.value))), + ); + }, + (Map>).toString(): (Map value) { + return Map>.fromEntries( + value.entries.map((e) => MapEntry(e.key as String, List.from(e.value))), + ); + }, + (Map>).toString(): (Map value) { + return Map>.fromEntries( + value.entries.map((e) => MapEntry(e.key as String, List.from(e.value))), + ); + }, + (Map>).toString(): (Map value) => Map>.from(value), + // map raw value inside the map + (Map>).toString(): (Map value) { + return Map>.fromEntries( + value.entries.map((e) => MapEntry(e.key as String, Map.from(e.value))), + ); + }, + (Map>).toString(): (Map value) { + return Map>.fromEntries( + value.entries.map((e) => MapEntry(e.key as String, Map.from(e.value))), + ); + }, + (Map>).toString(): (Map value) { + return Map>.fromEntries( + value.entries.map((e) => MapEntry(e.key as String, Map.from(e.value))), + ); + }, + (Map>).toString(): (Map value) { + return Map>.fromEntries( + value.entries.map((e) => MapEntry(e.key as String, Map.from(e.value))), + ); + }, + (Map>).toString(): (Map value) { + return Map>.fromEntries( + value.entries.map((e) => MapEntry(e.key as String, Map.from(e.value))), + ); + }, + // list raw value inside the map + (Map>>).toString(): (Map value) { + return Map>>.fromEntries( + value.entries.map( + (e) => MapEntry( + e.key as String, + Map>.fromEntries( + (e.value as Map).entries.map( + (e) => MapEntry(e.key as String, List.from(e.value)), + ), + ), + ), + ), + ); + }, + (Map>>).toString(): (Map value) { + return Map>>.fromEntries( + value.entries.map( + (e) => MapEntry( + e.key as String, + Map>.fromEntries( + (e.value as Map).entries.map((e) => MapEntry(e.key as String, List.from(e.value))), + ), + ), + ), + ); + }, + (Map>>).toString(): (Map value) { + return Map>>.fromEntries( + value.entries.map( + (e) => MapEntry( + e.key as String, + Map>.fromEntries( + (e.value as Map) + .entries + .map((e) => MapEntry(e.key as String, List.from(e.value))), + ), + ), + ), + ); + }, + (Map>>).toString(): (Map value) { + return Map>>.fromEntries( + value.entries.map( + (e) => MapEntry( + e.key as String, + Map>.fromEntries( + (e.value as Map) + .entries + .map((e) => MapEntry(e.key as String, List.from(e.value))), + ), + ), + ), + ); + }, + (Map>>).toString(): (Map value) { + return Map>>.fromEntries( + value.entries.map( + (e) => MapEntry( + e.key as String, + Map>.fromEntries( + (e.value as Map) + .entries + .map((e) => MapEntry(e.key as String, List.from(e.value))), + ), + ), + ), + ); + }, +}; + +final listConversions = { + // -------------------- LIST CONVERSION ----------------------- + // raw + (List).toString(): (List value) => List.from(value), + (List).toString(): (List value) => List.from(value), + (List).toString(): (List value) => List.from(value), + (List).toString(): (List value) => List.from(value), + (List).toString(): (List value) => List.from(value), + // list with map: raw value + (List>).toString(): (List value) { + return List>.from(value.map((e) => Map.from(e as Map))); + }, + (List>).toString(): (List value) { + return List>.from(value.map((e) => Map.from(e as Map))); + }, + (List>).toString(): (List value) { + return List>.from(value.map((e) => Map.from(e as Map))); + }, + (List>).toString(): (List value) { + return List>.from(value.map((e) => Map.from(e as Map))); + }, + (List>).toString(): (List value) { + return List>.from(value.map((e) => Map.from(e as Map))); + }, + // list inside map + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, List.from(e.value as List)))), + ), + ); + }, + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, List.from(e.value as List)))), + ), + ); + }, + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, List.from(e.value as List)))), + ), + ); + }, + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, List.from(e.value as List)))), + ), + ); + }, + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, List.from(e.value as List)))), + ), + ); + }, + // map inside map + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, Map.from(e.value as Map)))), + ), + ); + }, + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, Map.from(e.value as Map)))), + ), + ); + }, + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, Map.from(e.value as Map)))), + ), + ); + }, + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, Map.from(e.value as Map)))), + ), + ); + }, + (List>>).toString(): (List value) { + return List>>.from( + value.map( + (e) => Map>.fromEntries((e as Map) + .entries + .map((e) => MapEntry(e.key as String, Map.from(e.value as Map)))), + ), + ); + }, +}; + +void addNewConverter( + String castType, { + Map Function(Map value)? mapType, + List Function(List value)? listType, +}) { + assert(mapType == null || listType == null); + + if (mapType != null) { + mapConversions[castType] = mapType; + } else if (listType != null) { + listConversions[castType] = listType; + } +} diff --git a/lib/src/http.dart b/lib/src/http.dart new file mode 100644 index 0000000..13c17c3 --- /dev/null +++ b/lib/src/http.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'package:http/http.dart' as http; +import 'package:upstash_redis/src/upstash_error.dart'; + +class UpstashResponse { + const UpstashResponse({ + this.result, + this.error, + }); + + factory UpstashResponse.fromJson(Map json) { + final result = json['result']; + return UpstashResponse( + result: json.containsKey('result') ? result : undefined, + error: json['error'] as String?, + ); + } + + final TResult? result; + final String? error; +} + +abstract class Requester { + Future> request({ + List? path, + Object? body, + }); +} + +class RetryConfig { + const RetryConfig({ + this.retries = 5, + this.backoff = _defaultBackoff, + }); + + /// The number of retries to attempt before giving up. + /// + /// @default 5 + final int retries; + + /// A backoff function receives the current retry count and returns a number in milliseconds to wait before retrying. + /// + /// @default + /// math.exp(retryCount) * 50 + final double Function(int retryCount) backoff; +} + +double _defaultBackoff(int retryCount) { + return math.exp(retryCount) * 50; +} + +class Retry { + const Retry({ + required this.attempts, + this.backoff = _defaultBackoff, + }); + + /// The number of retries to attempt before giving up. + final int attempts; + + /// A backoff function receives the current retry count and returns a number in milliseconds to wait before retrying. + final double Function(int retryCount) backoff; +} + +class Options { + Options({ + this.backend, + }); + + final String? backend; +} + +class HttpClientConfig { + HttpClientConfig({ + this.headers, + required this.baseUrl, + this.options, + this.retry, + }); + + final Map? headers; + final String baseUrl; + final Options? options; + final RetryConfig? retry; +} + +class UpstashHttpClient implements Requester { + UpstashHttpClient(HttpClientConfig config) + : baseUrl = config.baseUrl.replaceAll(RegExp(r'/$'), ''), + headers = {"Content-Type": "application/json", ...?config.headers}, + options = Options(backend: config.options?.backend), + retry = config.retry == null + ? Retry(attempts: 5) + : Retry( + attempts: config.retry!.retries, + backoff: config.retry!.backoff, + ); + + final String baseUrl; + final Map headers; + final Options? options; + final Retry retry; + + @override + Future> request({List? path, Object? body}) async { + final uri = Uri.parse([baseUrl, ...(path ?? [])].join('/')); + final encodedBody = body != null ? json.encode(body) : null; + + http.Response? result; + dynamic error; + + for (int i = 0; i <= retry.attempts; i++) { + try { + result = await http.post( + uri, + headers: headers, + body: encodedBody, + ); + break; + } catch (e) { + error = e; + await Future.delayed(Duration(milliseconds: retry.backoff(i).toInt())); + } + } + + if (result == null) { + if (error != null) { + throw error; + } + + throw Exception('Exhausted all retries'); + } + + final jsonData = Map.from(json.decode(result.body)); + final bodyResult = UpstashResponse.fromJson(jsonData); + + if (result.statusCode < 200 || result.statusCode >= 300) { + throw UpstashError(bodyResult.error ?? 'unknown error'); + } + + return bodyResult; + } +} diff --git a/lib/src/redis.dart b/lib/src/redis.dart new file mode 100644 index 0000000..dc0fb7e --- /dev/null +++ b/lib/src/redis.dart @@ -0,0 +1,123 @@ +import 'dart:io'; + +import 'package:upstash_redis/src/commands/mod.dart'; +import 'package:upstash_redis/src/http.dart'; + +class RedisOptions { + const RedisOptions({ + this.automaticDeserialization = true, + }); + + /// Automatically try to deserialize the returned data from upstash using `json.decode` + final bool automaticDeserialization; +} + +/// Serverless redis client for upstash. +class Redis { + Redis._(this._client, this.opts); + + /// Create a new redis client + factory Redis({ + required String url, + required String token, + RetryConfig? retryConfig, + RedisOptions opts = const RedisOptions(), + }) { + if (url.startsWith(' ') || url.endsWith(' ') || url.contains(RegExp(r'[\r\n]'))) { + print('The redis url contains whitespace or newline, which can cause errors!'); + } + if (token.startsWith(' ') || token.endsWith(' ') || token.contains(RegExp(r'[\r\n]'))) { + print('The redis token contains whitespace or newline, which can cause errors!'); + } + + return Redis._( + UpstashHttpClient( + HttpClientConfig( + baseUrl: url, + headers: {'authorization': 'Bearer $token'}, + retry: retryConfig, + ), + ), + CommandOption(automaticDeserialization: opts.automaticDeserialization), + ); + } + + factory Redis.fromEnv({ + RetryConfig? retryConfig, + RedisOptions opts = const RedisOptions(), + }) { + final url = Platform.environment['UPSTASH_REDIS_REST_URL'] ?? ''; + final token = Platform.environment['UPSTASH_REDIS_REST_TOKEN'] ?? ''; + + if (url.isEmpty) { + throw Exception('Unable to find environment variable: `UPSTASH_REDIS_REST_URL`.'); + } + + if (token.isEmpty) { + throw Exception('Unable to find environment variable: `UPSTASH_REDIS_REST_TOKEN`.'); + } + + return Redis( + url: url, + token: token, + retryConfig: retryConfig, + opts: opts, + ); + } + + final Requester _client; + final CommandOption? opts; + + /// @see https://redis.io/commands/del + Future del(List keys, [CommandOption? opts]) { + return DelCommand(keys, opts).exec(_client); + } + + /// @see https://redis.io/commands/get + Future get(String key) { + return GetCommand([key]).exec(_client); + } + + /// @see https://redis.io/commands/set + Future set( + String key, + TData value, { + int? ex, + int? px, + bool? nx, + bool? xx, + }) { + return SetCommand(key, value, ex: ex, px: px, nx: nx, xx: xx).exec(_client); + } + + /// @see https://redis.io/commands/zadd + Future zadd( + String key, { + num? score, + TData? member, + List> scores = const [], + bool? ch, + bool? incr, + bool? nx, + bool? xx, + CommandOption? cmdOpts, + }) { + final allScores = [...scores]; + if (score != null && member != null) { + allScores.insert(0, ScoreMember(score: score, member: member)); + } + + return ZAddCommand(key, allScores, ch: ch, incr: incr, nx: nx, xx: xx, cmdOpts: cmdOpts) + .exec(_client); + } + + /// @see https://redis.io/commands/zrem + Future zrem(String key, List members) { + return ZRemCommand(key, members).exec(_client); + } + + /// @see https://redis.io/commands/zscore + Future zscore(String key, TData member) { + return ZScoreCommand(key, member).exec(_client); + } +} diff --git a/lib/src/test_utils.dart b/lib/src/test_utils.dart new file mode 100644 index 0000000..fd1581b --- /dev/null +++ b/lib/src/test_utils.dart @@ -0,0 +1,49 @@ +import 'dart:io'; +import 'dart:math' as math; + +import 'package:upstash_redis/src/commands/del.dart'; +import 'package:upstash_redis/src/http.dart'; + +final _random = math.Random(); + +String randomID() { + return _random.nextInt(10000000).toString(); +} + +UpstashHttpClient newHttpClient() { + final url = Platform.environment['UPSTASH_REDIS_REST_URL']; + final token = Platform.environment['UPSTASH_REDIS_REST_TOKEN']; + + if (url == null) { + throw StateError('Could not find url'); + } + + if (token == null) { + throw StateError('Could not find token'); + } + + return UpstashHttpClient( + HttpClientConfig( + baseUrl: url, + headers: { + 'authorization': 'Bearer $token', + }, + ), + ); +} + +class Keygen { + final List keys = []; + + String newKey() { + final key = randomID(); + keys.add(key); + return key; + } + + Future cleanup() async { + if (keys.isNotEmpty) { + await DelCommand(keys).exec(newHttpClient()); + } + } +} diff --git a/lib/src/upstash_error.dart b/lib/src/upstash_error.dart new file mode 100644 index 0000000..2fcd45c --- /dev/null +++ b/lib/src/upstash_error.dart @@ -0,0 +1,12 @@ +class UpstashError implements Exception { + UpstashError(this.message); + + final String message; + + @override + String toString() { + return 'UpstashError($message)'; + } +} + +const undefined = Object(); \ No newline at end of file diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..ff04b7f --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; + +import 'package:upstash_redis/src/converters.dart'; + +TResult parseResponse(dynamic result) { + try { + final resultType = TResult.toString().replaceAll('?', ''); + final value = parseRecursive(result); + + if (value is Map) { + final converter = mapConversions[resultType]; + if (converter != null) { + return converter.call(value) as TResult; + } + } else if (value is List) { + final converter = listConversions[resultType]; + if (converter != null) { + return converter.call(value) as TResult; + } + } + + return value as TResult; + } catch (e) { + return result as TResult; + } +} + +dynamic parseRecursive(dynamic obj) { + final parsed = obj is List + ? obj.map((o) { + try { + return parseRecursive(o); + } catch (_) { + return o; + } + }).toList() + : json.decode(obj as String); + + // Parsing very large numbers can result in MAX_SAFE_INTEGER + // overflow. In that case we return the number as string instead. + if (parsed is num && parsed.toString() != obj) { + return obj; + } + + return parsed; +} diff --git a/lib/upstash_redis.dart b/lib/upstash_redis.dart new file mode 100644 index 0000000..48486fe --- /dev/null +++ b/lib/upstash_redis.dart @@ -0,0 +1,3 @@ +library upstash_redis; + +export 'src/redis.dart'; diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..5a212fa --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,15 @@ +name: upstash_redis +description: upstash/redis is an HTTP/REST based Redis client for dart, built on top of Upstash REST API. +version: 1.0.0 +homepage: https://github.com/rebaz94/upstash_redis + +environment: + sdk: '>=2.17.0 <3.0.0' + +dependencies: + http: ^0.13.4 + collection: ^1.16.0 + +dev_dependencies: + lints: ^2.0.0 + test: ^1.21.3 diff --git a/test/commands/del_test.dart b/test/commands/del_test.dart new file mode 100644 index 0000000..826977c --- /dev/null +++ b/test/commands/del_test.dart @@ -0,0 +1,32 @@ +import 'package:test/test.dart'; +import 'package:upstash_redis/src/commands/mod.dart'; +import 'package:upstash_redis/src/test_utils.dart'; + +void main() async { + final client = newHttpClient(); + final keygen = Keygen(); + final newKey = keygen.newKey; + + tearDownAll(() => keygen.cleanup()); + + test('when key does not exist, does nothing', () async { + final key = newKey(); + final res = await DelCommand([key]).exec(client); + expect(res, 0); + }); + + test('when key does exist, deletes the key', () async { + final key = newKey(); + await SetCommand(key, 'value').exec(client); + final res = await DelCommand([key]).exec(client); + expect(res, 1); + }); + + test('with multiple keys, when one does not exist, deletes all keys', () async { + final key1 = newKey(); + final key2 = newKey(); + await SetCommand(key1, 'value').exec(client); + final res = await DelCommand([key1, key2]).exec(client); + expect(res, 1); + }); +} diff --git a/test/commands/get_test.dart b/test/commands/get_test.dart new file mode 100644 index 0000000..7f3ddf5 --- /dev/null +++ b/test/commands/get_test.dart @@ -0,0 +1,38 @@ +import 'package:test/test.dart'; +import 'package:upstash_redis/src/commands/mod.dart'; +import 'package:upstash_redis/src/test_utils.dart'; + +void main() async { + final client = newHttpClient(); + final keygen = Keygen(); + final newKey = keygen.newKey; + + tearDownAll(() => keygen.cleanup()); + + test('gets an exiting value', () async { + final key = newKey(); + final value = randomID(); + await SetCommand(key, value).exec(client); + final res = await GetCommand([key]).exec(client); + expect(res, value); + }); + + test('gets a non-existing value', () async { + final key = newKey(); + final res = await GetCommand([key]).exec(client); + expect(res, null); + }); + + test('gets an object', () async { + final key = newKey(); + final value = {'v': randomID()}; + await SetCommand(key, value).exec(client); + try { + final res = await GetCommand>([key]).exec(client); + expect(res, value); + } catch (e, stack) { + print(e); + print(stack); + } + }); +} diff --git a/test/commands/set_test.dart b/test/commands/set_test.dart new file mode 100644 index 0000000..6d9ed99 --- /dev/null +++ b/test/commands/set_test.dart @@ -0,0 +1,92 @@ +import 'package:test/test.dart'; +import 'package:upstash_redis/src/commands/mod.dart'; +import 'package:upstash_redis/src/test_utils.dart'; + +void main() async { + final client = newHttpClient(); + final keygen = Keygen(); + final newKey = keygen.newKey; + + tearDownAll(() => keygen.cleanup()); + + test('without options, sets value', () async { + final key = newKey(); + final value = randomID(); + final res = await SetCommand(key, value).exec(client); + expect(res, 'OK'); + final res2 = await GetCommand([key]).exec(client); + expect(res2, value); + }); + + test('ex, sets value', () async { + final key = newKey(); + final value = randomID(); + + final res = await SetCommand(key, value, ex: 1).exec(client); + expect(res, 'OK'); + final res2 = await GetCommand([key]).exec(client); + expect(res2, value); + + await Future.delayed(const Duration(seconds: 2)); + final res3 = await GetCommand([key]).exec(client); + expect(res3, null); + }); + + test('px, sets value', () async { + final key = newKey(); + final value = randomID(); + + final res = await SetCommand(key, value, px: 1000).exec(client); + expect(res, 'OK'); + final res2 = await GetCommand([key]).exec(client); + expect(res2, value); + + await Future.delayed(const Duration(seconds: 2)); + final res3 = await GetCommand([key]).exec(client); + expect(res3, null); + }); + + test('nx, when key exists, does nothing', () async { + final key = newKey(); + final value = randomID(); + final newValue = randomID(); + + await SetCommand(key, value).exec(client); + final res = await SetCommand(key, newValue, nx: true).exec(client); + expect(res, null); + final res2 = await GetCommand([key]).exec(client); + expect(res2, value); + }); + + test('nx, when key does not exists, overwrites key', () async { + final key = newKey(); + final value = randomID(); + + final res = await SetCommand(key, value, nx: true).exec(client); + expect(res, 'OK'); + final res2 = await GetCommand([key]).exec(client); + expect(res2, value); + }); + + test('xx, when key exists, overwrites key', () async { + final key = newKey(); + final value = randomID(); + final newValue = randomID(); + + await SetCommand(key, value).exec(client); + final res = await SetCommand(key, newValue, xx: true).exec(client); + expect(res, 'OK'); + final res2 = await GetCommand([key]).exec(client); + expect(res2, newValue); + }); + + test('xx, when key does not exists, does nothing', () async { + final key = newKey(); + final value = randomID(); + + final res = await SetCommand(key, value, xx: true).exec(client); + expect(res, null); + final res2 = await GetCommand([key]).exec(client); + expect(res2, null); + }); +} diff --git a/test/commands/zadd_test.dart b/test/commands/zadd_test.dart new file mode 100644 index 0000000..179898e --- /dev/null +++ b/test/commands/zadd_test.dart @@ -0,0 +1,214 @@ +import 'package:test/test.dart'; +import 'package:upstash_redis/src/commands/zadd.dart'; +import 'package:upstash_redis/src/commands/zscore.dart'; +import 'package:upstash_redis/src/test_utils.dart'; +import 'dart:math' as math; + +void main() async { + final client = newHttpClient(); + final keygen = Keygen(); + final newKey = keygen.newKey; + + tearDownAll(() => keygen.cleanup()); + + group('command format', () { + test('without options, build the correct command', () async { + final command = ZAddCommand.single('key', score: 0, member: 'member').command; + + expect(command, ['zadd', 'key', '0', 'member']); + }); + + test('with nx, build the correct command', () async { + final command = ZAddCommand.single( + 'key', + score: 0, + member: 'member', + nx: true, + ).command; + + expect(command, ['zadd', 'key', 'nx', '0', 'member']); + }); + + test('with xx, build the correct command', () async { + final command = ZAddCommand.single( + 'key', + score: 0, + member: 'member', + xx: true, + ).command; + + expect(command, ['zadd', 'key', 'xx', '0', 'member']); + }); + + test('with ch, build the correct command', () async { + final command = ZAddCommand.single( + 'key', + score: 0, + member: 'member', + ch: true, + ).command; + + expect(command, ['zadd', 'key', 'ch', '0', 'member']); + }); + + test('with incr, build the correct command', () async { + final command = ZAddCommand.single( + 'key', + score: 0, + member: 'member', + incr: true, + ).command; + + expect(command, ['zadd', 'key', 'incr', '0', 'member']); + }); + + test('with nx and ch, build the correct command', () async { + final command = ZAddCommand.single( + 'key', + score: 0, + member: 'member', + nx: true, + ch: true, + ).command; + + expect(command, ['zadd', 'key', 'nx', 'ch', '0', 'member']); + }); + + test('with nx,ch and incr, build the correct command', () async { + final command = ZAddCommand.single( + 'key', + score: 0, + member: 'member', + nx: true, + ch: true, + incr: true, + ).command; + + expect(command, ['zadd', 'key', 'nx', 'ch', 'incr', '0', 'member']); + }); + + test('with nx and multiple members', () async { + final command = ZAddCommand( + 'key', + [ + const ScoreMember(score: 0, member: 'member'), + const ScoreMember(score: 1, member: 'member1'), + ], + nx: true, + ).command; + + expect(command, ['zadd', 'key', 'nx', '0', 'member', '1', 'member1']); + }); + }); + + test('without options, adds the member', () async { + final key = newKey(); + final member = randomID(); + final score = math.Random().nextInt(100); + final res = await ZAddCommand.single(key, score: score, member: member).exec(client); + + expect(res, 1); + }); + + group('xx', () { + test('when the element exists, updates the element', () async { + final key = newKey(); + final member = randomID(); + final score = math.Random().nextInt(100); + await ZAddCommand.single(key, score: score, member: member).exec(client); + final newScore = score + 1; + final res = await ZAddCommand.single( + key, + score: newScore, + member: member, + xx: true, + ).exec(client); + expect(res, 0); + + final res2 = await ZScoreCommand(key, member).exec(client); + expect(res2, newScore); + }); + + test('when the element does not exist, does nothing', () async { + final key = newKey(); + final member = randomID(); + final score = math.Random().nextInt(100); + await ZAddCommand.single(key, score: score, member: member).exec(client); + final newScore = score + 1; + final res = await ZAddCommand.single( + key, + score: newScore, + member: member, + xx: true, + ).exec(client); + expect(res, 0); + }); + }); + + group('nx', () { + test('when the element exists, does nothing', () async { + final key = newKey(); + final member = randomID(); + final score = math.Random().nextInt(100); + await ZAddCommand.single(key, score: score, member: member).exec(client); + final newScore = score + 1; + final res = await ZAddCommand.single( + key, + score: newScore, + member: member, + nx: true, + ).exec(client); + expect(res, 0); + + final res2 = await ZScoreCommand(key, member).exec(client); + expect(res2, score); + }); + + test('when the element does not exist, creates element', () async { + final key = newKey(); + final member = randomID(); + final score = math.Random().nextInt(100); + final res = await ZAddCommand.single( + key, + score: score, + member: member, + nx: true, + ).exec(client); + expect(res, 1); + }); + }); + + group('ch', () { + test('returns the number of changed elements', () async { + final key = newKey(); + final member = randomID(); + final score = math.Random().nextInt(100); + await ZAddCommand.single(key, score: score, member: member).exec(client); + final newScore = score + 1; + final res = await ZAddCommand.single( + key, + score: newScore, + member: member, + ch: true, + ).exec(client); + expect(res, 1); + }); + }); + + group('incr', () { + test('returns the number with added increment', () async { + final key = newKey(); + final member = randomID(); + final score = 10; + await ZAddCommand.single(key, score: score, member: member).exec(client); + final newScore = score + 1; + final res = await ZAddCommand.single( + key, + score: newScore, + member: member, + incr: true, + ).exec(client); + expect(res, score + newScore); + }); + }); +} diff --git a/test/commands/zrem_test.dart b/test/commands/zrem_test.dart new file mode 100644 index 0000000..73ba0a7 --- /dev/null +++ b/test/commands/zrem_test.dart @@ -0,0 +1,28 @@ +import 'package:test/test.dart'; +import 'package:upstash_redis/src/commands/mod.dart'; +import 'package:upstash_redis/src/test_utils.dart'; + +void main() async { + final client = newHttpClient(); + final keygen = Keygen(); + final newKey = keygen.newKey; + + tearDownAll(() => keygen.cleanup()); + + test('gets an exiting value', () async { + final key = newKey(); + final member1 = randomID(); + final member2 = randomID(); + + await ZAddCommand( + key, + [ + ScoreMember(score: 1, member: member1), + ScoreMember(score: 2, member: member2), + ], + ).exec(client); + + final res = await ZRemCommand(key, [member1, member2]).exec(client); + expect(res, 2); + }); +} diff --git a/test/commands/zscore_test.dart b/test/commands/zscore_test.dart new file mode 100644 index 0000000..9e158f0 --- /dev/null +++ b/test/commands/zscore_test.dart @@ -0,0 +1,22 @@ +import 'package:test/test.dart'; +import 'package:upstash_redis/src/commands/zadd.dart'; +import 'package:upstash_redis/src/commands/zscore.dart'; +import 'package:upstash_redis/src/test_utils.dart'; +import 'dart:math' as math; + +void main() async { + final client = newHttpClient(); + final keygen = Keygen(); + final newKey = keygen.newKey; + + tearDownAll(() => keygen.cleanup()); + + test('returns the score', () async { + final key = newKey(); + final member = randomID(); + final score = math.Random().nextInt(100); + await ZAddCommand.single(key, score: score, member: member).exec(client); + final res = await ZScoreCommand(key, member).exec(client); + expect(res, score); + }); +} diff --git a/test/http_test.dart b/test/http_test.dart new file mode 100644 index 0000000..b1e2352 --- /dev/null +++ b/test/http_test.dart @@ -0,0 +1,23 @@ +import 'package:test/test.dart'; +import 'package:upstash_redis/src/http.dart'; +import 'package:upstash_redis/src/test_utils.dart'; + +void main() { + test('remove trailing slash from urls', () { + final client = UpstashHttpClient( + HttpClientConfig(baseUrl: 'https://example.com/'), + ); + + expect(client.baseUrl, 'https://example.com'); + }); + + test('throw when the request is invalid', () { + final client = newHttpClient(); + expect(client.request(body: ['get', '1', '2']), throwsException); + }); + test('throw without authorization', () { + final client = newHttpClient(); + client.headers.clear(); + expect(client.request(body: ['get', '1', '2']), throwsException); + }); +} diff --git a/upstash_redis.iml b/upstash_redis.iml new file mode 100644 index 0000000..aac2738 --- /dev/null +++ b/upstash_redis.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file