From 6a72432f68a8cbf22402a70479c9ba4878173a1c Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:28:28 +0545 Subject: [PATCH 01/22] :sparkles: added `core` --- lib/src/open_feature/src/core/enums.dart | 3 + .../src/core/enums/error_code.dart | 11 ++ .../src/core/enums/flag_value_type.dart | 6 + .../open_feature/src/core/enums/reason.dart | 8 ++ .../open_feature/src/core/feature_client.dart | 5 + lib/src/open_feature/src/core/features.dart | 104 ++++++++++++++++++ .../src/core/flag_evaluation_lifecycle.dart | 9 ++ .../src/core/resolution_details.dart | 15 +++ 8 files changed, 161 insertions(+) create mode 100644 lib/src/open_feature/src/core/enums.dart create mode 100644 lib/src/open_feature/src/core/enums/error_code.dart create mode 100644 lib/src/open_feature/src/core/enums/flag_value_type.dart create mode 100644 lib/src/open_feature/src/core/enums/reason.dart create mode 100644 lib/src/open_feature/src/core/feature_client.dart create mode 100644 lib/src/open_feature/src/core/features.dart create mode 100644 lib/src/open_feature/src/core/flag_evaluation_lifecycle.dart create mode 100644 lib/src/open_feature/src/core/resolution_details.dart diff --git a/lib/src/open_feature/src/core/enums.dart b/lib/src/open_feature/src/core/enums.dart new file mode 100644 index 00000000..ba263e74 --- /dev/null +++ b/lib/src/open_feature/src/core/enums.dart @@ -0,0 +1,3 @@ +export 'enums/error_code.dart'; +export 'enums/flag_value_type.dart'; +export 'enums/reason.dart'; diff --git a/lib/src/open_feature/src/core/enums/error_code.dart b/lib/src/open_feature/src/core/enums/error_code.dart new file mode 100644 index 00000000..02b71a7d --- /dev/null +++ b/lib/src/open_feature/src/core/enums/error_code.dart @@ -0,0 +1,11 @@ +enum ErrorCode { + providerNotReady('PROVIDER_NOT_READY'), + flagNotFound('FLAG_NOT_FOUND'), + parseError('PARSE_ERROR'), + typeMismatch('TYPE_MISMATCH'), + general('GENERAL'); + + const ErrorCode(this.rawCode); + + final String rawCode; +} diff --git a/lib/src/open_feature/src/core/enums/flag_value_type.dart b/lib/src/open_feature/src/core/enums/flag_value_type.dart new file mode 100644 index 00000000..3a87ee5c --- /dev/null +++ b/lib/src/open_feature/src/core/enums/flag_value_type.dart @@ -0,0 +1,6 @@ +enum FlagValueType { + boolean, + string, + number, + object, +} diff --git a/lib/src/open_feature/src/core/enums/reason.dart b/lib/src/open_feature/src/core/enums/reason.dart new file mode 100644 index 00000000..a7e7daf8 --- /dev/null +++ b/lib/src/open_feature/src/core/enums/reason.dart @@ -0,0 +1,8 @@ +enum Reason { + disabled, + split, + targetingMatch, + unknown, + error, + noop, +} diff --git a/lib/src/open_feature/src/core/feature_client.dart b/lib/src/open_feature/src/core/feature_client.dart new file mode 100644 index 00000000..a8c77507 --- /dev/null +++ b/lib/src/open_feature/src/core/feature_client.dart @@ -0,0 +1,5 @@ +import 'flag_evaluation_lifecycle.dart'; +import 'features.dart'; + +abstract class FeatureClient extends Features + implements FlagEvaluationLifecycle {} diff --git a/lib/src/open_feature/src/core/features.dart b/lib/src/open_feature/src/core/features.dart new file mode 100644 index 00000000..824f01e0 --- /dev/null +++ b/lib/src/open_feature/src/core/features.dart @@ -0,0 +1,104 @@ +import '../evaluation_context/evaluation_context.dart'; +import '../flag_evaluation/flag_evaluation_details.dart'; +import '../flag_evaluation/flag_evaluation_options.dart'; + +abstract class Features { + Future> getBooleanDetails({ + required String key, + required bool defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }); + + Future> getStringDetails({ + required String key, + required String defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }); + + Future> getNumberDetails({ + required String key, + required num defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }); + + Future> getDetails({ + required String key, + required T defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }); + + Future getBooleanValue({ + required String key, + required bool defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return _evaluatedValue( + getBooleanDetails( + key: key, + defaultValue: defaultValue, + context: context, + options: options, + ), + ); + } + + Future getStringValue({ + required String key, + required String defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return _evaluatedValue( + getStringDetails( + key: key, + defaultValue: defaultValue, + context: context, + options: options, + ), + ); + } + + Future getNumberValue({ + required String key, + required num defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return _evaluatedValue( + getNumberDetails( + key: key, + defaultValue: defaultValue, + context: context, + options: options, + ), + ); + } + + Future getValue({ + required String key, + required T defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return _evaluatedValue( + getDetails( + key: key, + defaultValue: defaultValue, + context: context, + options: options, + ), + ); + } + + Future _evaluatedValue( + Future> detailsFuture, + ) async { + final details = await detailsFuture; + return details.value; + } +} diff --git a/lib/src/open_feature/src/core/flag_evaluation_lifecycle.dart b/lib/src/open_feature/src/core/flag_evaluation_lifecycle.dart new file mode 100644 index 00000000..43dc73ad --- /dev/null +++ b/lib/src/open_feature/src/core/flag_evaluation_lifecycle.dart @@ -0,0 +1,9 @@ +import '../hook/hook.dart'; + +abstract class FlagEvaluationLifecycle { + List get hooks; + + void addHooks(List hooks); + + void clearHooks(); +} diff --git a/lib/src/open_feature/src/core/resolution_details.dart b/lib/src/open_feature/src/core/resolution_details.dart new file mode 100644 index 00000000..5851d47f --- /dev/null +++ b/lib/src/open_feature/src/core/resolution_details.dart @@ -0,0 +1,15 @@ +import 'enums.dart'; + +class ResolutionDetails { + ResolutionDetails({ + required this.value, + this.errorCode, + this.reason, + this.variant, + }); + + final T value; + final ErrorCode? errorCode; + final Reason? reason; + final String? variant; +} From 2a9bde43640b2ba2a44727512668bc305423025e Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:28:42 +0545 Subject: [PATCH 02/22] :sparkles: added `evaluation_context` --- .../context_transformation_mixin.dart | 8 ++++ .../evaluation_context.dart | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 lib/src/open_feature/src/evaluation_context/context_transformation_mixin.dart create mode 100644 lib/src/open_feature/src/evaluation_context/evaluation_context.dart diff --git a/lib/src/open_feature/src/evaluation_context/context_transformation_mixin.dart b/lib/src/open_feature/src/evaluation_context/context_transformation_mixin.dart new file mode 100644 index 00000000..a1ba3a71 --- /dev/null +++ b/lib/src/open_feature/src/evaluation_context/context_transformation_mixin.dart @@ -0,0 +1,8 @@ +import 'dart:async'; + +import '../provider/feature_provider.dart'; +import 'evaluation_context.dart'; + +mixin ContextTransformationMixin on FeatureProvider { + FutureOr transformContext(EvaluationContext context); +} diff --git a/lib/src/open_feature/src/evaluation_context/evaluation_context.dart b/lib/src/open_feature/src/evaluation_context/evaluation_context.dart new file mode 100644 index 00000000..f9920f57 --- /dev/null +++ b/lib/src/open_feature/src/evaluation_context/evaluation_context.dart @@ -0,0 +1,41 @@ +import 'dart:collection'; + +class EvaluationContext extends MapMixin { + EvaluationContext( + Map map, { + this.targetingKey, + }) : _map = map; + + factory EvaluationContext.empty({String? targetingKey}) { + return EvaluationContext(targetingKey: targetingKey, {}); + } + + final String? targetingKey; + final Map _map; + + @override + Object? operator [](Object? key) => _map[key]; + + @override + void operator []=(String key, Object value) { + _map[key] = value; + } + + @override + void clear() => _map.clear(); + + @override + Iterable get keys => _map.keys; + + @override + Object? remove(Object? key) => _map.remove(key); + + EvaluationContext merge(EvaluationContext? other) { + if (other == null) return this; + + return EvaluationContext( + targetingKey: targetingKey ?? other.targetingKey, + {..._map, ...other._map}, + ); + } +} From 0a6d3fd3b06002dbde7da289e2f043e9ad98c39e Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:28:54 +0545 Subject: [PATCH 03/22] :sparkles: added `exceptions` --- .../src/exceptions/open_feature_exception.dart | 13 +++++++++++++ .../exceptions/src/flag_not_found_exception.dart | 5 +++++ .../src/exceptions/src/parse_exception.dart | 5 +++++ .../src/exceptions/src/type_mismatch_exception.dart | 5 +++++ 4 files changed, 28 insertions(+) create mode 100644 lib/src/open_feature/src/exceptions/open_feature_exception.dart create mode 100644 lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart create mode 100644 lib/src/open_feature/src/exceptions/src/parse_exception.dart create mode 100644 lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart diff --git a/lib/src/open_feature/src/exceptions/open_feature_exception.dart b/lib/src/open_feature/src/exceptions/open_feature_exception.dart new file mode 100644 index 00000000..31d364f5 --- /dev/null +++ b/lib/src/open_feature/src/exceptions/open_feature_exception.dart @@ -0,0 +1,13 @@ +import '../core/enums/error_code.dart'; + +export '../core/enums/error_code.dart'; +export 'src/flag_not_found_exception.dart'; +export 'src/parse_exception.dart'; +export 'src/type_mismatch_exception.dart'; + +abstract class OpenFeatureException { + OpenFeatureException(this.message, {required this.code}); + + final String message; + final ErrorCode code; +} diff --git a/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart b/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart new file mode 100644 index 00000000..0659afaf --- /dev/null +++ b/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart @@ -0,0 +1,5 @@ +import '../open_feature_exception.dart'; + +class FlagNotFoundException extends OpenFeatureException { + FlagNotFoundException(super.message) : super(code: ErrorCode.flagNotFound); +} diff --git a/lib/src/open_feature/src/exceptions/src/parse_exception.dart b/lib/src/open_feature/src/exceptions/src/parse_exception.dart new file mode 100644 index 00000000..d1e22cbe --- /dev/null +++ b/lib/src/open_feature/src/exceptions/src/parse_exception.dart @@ -0,0 +1,5 @@ +import '../open_feature_exception.dart'; + +class ParseException extends OpenFeatureException { + ParseException(super.message) : super(code: ErrorCode.flagNotFound); +} diff --git a/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart b/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart new file mode 100644 index 00000000..8a703787 --- /dev/null +++ b/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart @@ -0,0 +1,5 @@ +import '../open_feature_exception.dart'; + +class TypeMismatchException extends OpenFeatureException { + TypeMismatchException(super.message) : super(code: ErrorCode.typeMismatch); +} From b2cf09295261456414684b9feca949b4db5a94a1 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:29:13 +0545 Subject: [PATCH 04/22] :sparkles: added `flag_evaluation` --- .../flag_evaluation_details.dart | 17 +++++++++++++++++ .../flag_evaluation_options.dart | 11 +++++++++++ 2 files changed, 28 insertions(+) create mode 100644 lib/src/open_feature/src/flag_evaluation/flag_evaluation_details.dart create mode 100644 lib/src/open_feature/src/flag_evaluation/flag_evaluation_options.dart diff --git a/lib/src/open_feature/src/flag_evaluation/flag_evaluation_details.dart b/lib/src/open_feature/src/flag_evaluation/flag_evaluation_details.dart new file mode 100644 index 00000000..76039bfb --- /dev/null +++ b/lib/src/open_feature/src/flag_evaluation/flag_evaluation_details.dart @@ -0,0 +1,17 @@ +import '../core/enums.dart'; + +class FlagEvaluationDetails { + FlagEvaluationDetails({ + required this.key, + required this.value, + this.errorCode, + this.reason, + this.variant, + }); + + final String key; + final T value; + final ErrorCode? errorCode; + final Reason? reason; + final String? variant; +} diff --git a/lib/src/open_feature/src/flag_evaluation/flag_evaluation_options.dart b/lib/src/open_feature/src/flag_evaluation/flag_evaluation_options.dart new file mode 100644 index 00000000..c869f97d --- /dev/null +++ b/lib/src/open_feature/src/flag_evaluation/flag_evaluation_options.dart @@ -0,0 +1,11 @@ +import '../hook/hook.dart'; + +class FlagEvaluationOptions { + const FlagEvaluationOptions({ + this.hooks = const [], + this.hookHints, + }); + + final List hooks; + final HookHints? hookHints; +} From 7010638f125ead249e5e405cb1d010f03b1189e1 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:29:22 +0545 Subject: [PATCH 05/22] :sparkles: added `hook` --- lib/src/open_feature/src/hook/hook.dart | 35 +++++++++++++++++++ .../open_feature/src/hook/hook_context.dart | 34 ++++++++++++++++++ lib/src/open_feature/src/hook/hook_hints.dart | 13 +++++++ 3 files changed, 82 insertions(+) create mode 100644 lib/src/open_feature/src/hook/hook.dart create mode 100644 lib/src/open_feature/src/hook/hook_context.dart create mode 100644 lib/src/open_feature/src/hook/hook_hints.dart diff --git a/lib/src/open_feature/src/hook/hook.dart b/lib/src/open_feature/src/hook/hook.dart new file mode 100644 index 00000000..dc7b8895 --- /dev/null +++ b/lib/src/open_feature/src/hook/hook.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import '../evaluation_context/evaluation_context.dart'; +import '../flag_evaluation/flag_evaluation_details.dart'; +import 'hook_context.dart'; +import 'hook_hints.dart'; + +export 'hook_context.dart'; +export 'hook_hints.dart'; + +abstract class Hook { + FutureOr before({ + required HookContext context, + HookHints? hints, + }) async { + return null; + } + + FutureOr after({ + required HookContext context, + required FlagEvaluationDetails details, + HookHints? hints, + }) {} + + FutureOr error({ + required HookContext context, + required Object error, + HookHints? hints, + }) {} + + FutureOr finallyAfter({ + required HookContext context, + HookHints? hints, + }) {} +} diff --git a/lib/src/open_feature/src/hook/hook_context.dart b/lib/src/open_feature/src/hook/hook_context.dart new file mode 100644 index 00000000..dd4831b9 --- /dev/null +++ b/lib/src/open_feature/src/hook/hook_context.dart @@ -0,0 +1,34 @@ +import '../core/enums/flag_value_type.dart'; +import '../core/feature_client.dart'; +import '../evaluation_context/evaluation_context.dart'; +import '../provider/feature_provider.dart'; + +class HookContext { + HookContext({ + required this.flagKey, + required this.flagType, + required this.context, + required this.defaultValue, + required this.client, + required this.provider, + }); + + final String flagKey; + final FlagValueType flagType; + final EvaluationContext context; + final T defaultValue; + + final FeatureClient client; + final FeatureProvider provider; + + HookContext apply({required EvaluationContext context}) { + return HookContext( + flagKey: flagKey, + flagType: flagType, + context: context, + defaultValue: defaultValue, + client: client, + provider: provider, + ); + } +} diff --git a/lib/src/open_feature/src/hook/hook_hints.dart b/lib/src/open_feature/src/hook/hook_hints.dart new file mode 100644 index 00000000..e0a3486d --- /dev/null +++ b/lib/src/open_feature/src/hook/hook_hints.dart @@ -0,0 +1,13 @@ +import 'dart:collection'; + +class HookHints extends UnmodifiableMapBase { + HookHints(this._map); + + final Map _map; + + @override + Iterable get keys => _map.keys; + + @override + T? operator [](Object? key) => _map[key]; +} From 4f106ef4c0f255459c61d0ec2c2c4c0514cd7fe9 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:29:43 +0545 Subject: [PATCH 06/22] :sparkles: added `provider` --- .../src/provider/feature_provider.dart | 35 +++++++++++ .../src/provider/no_op_feature_provider.dart | 59 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 lib/src/open_feature/src/provider/feature_provider.dart create mode 100644 lib/src/open_feature/src/provider/no_op_feature_provider.dart diff --git a/lib/src/open_feature/src/provider/feature_provider.dart b/lib/src/open_feature/src/provider/feature_provider.dart new file mode 100644 index 00000000..00822493 --- /dev/null +++ b/lib/src/open_feature/src/provider/feature_provider.dart @@ -0,0 +1,35 @@ +import '../core/resolution_details.dart'; +import '../evaluation_context/evaluation_context.dart'; +import '../flag_evaluation/flag_evaluation_options.dart'; + +abstract class FeatureProvider { + String get name; + + Future> resolveBooleanValue({ + required String flagKey, + required bool defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }); + + Future> resolveStringValue({ + required String flagKey, + required String defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }); + + Future> resolveNumberValue({ + required String flagKey, + required num defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }); + + Future> resolveValue({ + required String flagKey, + required T defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }); +} diff --git a/lib/src/open_feature/src/provider/no_op_feature_provider.dart b/lib/src/open_feature/src/provider/no_op_feature_provider.dart new file mode 100644 index 00000000..df11cac3 --- /dev/null +++ b/lib/src/open_feature/src/provider/no_op_feature_provider.dart @@ -0,0 +1,59 @@ +import '../core/enums/reason.dart'; +import '../core/resolution_details.dart'; +import '../evaluation_context/evaluation_context.dart'; +import '../flag_evaluation/flag_evaluation_options.dart'; +import 'feature_provider.dart'; + +class NoOpFeatureProvider implements FeatureProvider { + const NoOpFeatureProvider(); + + @override + String get name => 'No-op Provider'; + + @override + Future> resolveBooleanValue({ + required String flagKey, + required bool defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) { + return _resolve(defaultValue); + } + + @override + Future> resolveNumberValue({ + required String flagKey, + required num defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) { + return _resolve(defaultValue); + } + + @override + Future> resolveStringValue({ + required String flagKey, + required String defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) { + return _resolve(defaultValue); + } + + @override + Future> resolveValue({ + required String flagKey, + required T defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) { + return _resolve(defaultValue); + } + + Future> _resolve(T value) async { + return ResolutionDetails( + value: value, + reason: Reason.noop, + ); + } +} From 014816b1ea69c12d1973095bdcc316613dd8e7b5 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:30:02 +0545 Subject: [PATCH 07/22] :sparkles: added `OpenFeatureClient` --- .../open_feature/src/open_feature_client.dart | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 lib/src/open_feature/src/open_feature_client.dart diff --git a/lib/src/open_feature/src/open_feature_client.dart b/lib/src/open_feature/src/open_feature_client.dart new file mode 100644 index 00000000..e66ed7cc --- /dev/null +++ b/lib/src/open_feature/src/open_feature_client.dart @@ -0,0 +1,249 @@ +import '../open_feature.dart'; +import 'flag_evaluation/flag_evaluation_details.dart'; + +typedef FeatureProviderResolver = Future> + Function({ + required String flagKey, + required T defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, +}); + +class OpenFeatureClientOptions { + OpenFeatureClientOptions({ + this.name, + this.version, + }); + + final String? name; + final String? version; +} + +class OpenFeatureClient extends FeatureClient { + OpenFeatureClient({ + required OpenFeatureClientOptions options, + }) : name = options.name, + version = options.version; + + final String? name; + final String? version; + + final List _hooks = []; + + @override + List> get hooks => List.unmodifiable(_hooks); + + @override + void addHooks(List> hooks) { + _hooks.addAll(hooks); + } + + @override + void clearHooks() => _hooks.clear(); + + @override + Future> getBooleanDetails({ + required String key, + required bool defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return evaluate( + flagKey: key, + flagType: FlagValueType.boolean, + resolver: _provider.resolveBooleanValue, + defaultValue: defaultValue, + context: context, + options: options, + ); + } + + @override + Future> getStringDetails({ + required String key, + required String defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return evaluate( + flagKey: key, + flagType: FlagValueType.string, + resolver: _provider.resolveStringValue, + defaultValue: defaultValue, + context: context, + options: options, + ); + } + + @override + Future> getNumberDetails({ + required String key, + required num defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return evaluate( + flagKey: key, + flagType: FlagValueType.number, + resolver: _provider.resolveNumberValue, + defaultValue: defaultValue, + context: context, + options: options, + ); + } + + @override + Future> getDetails({ + required String key, + required T defaultValue, + EvaluationContext? context, + FlagEvaluationOptions? options, + }) { + return evaluate( + flagKey: key, + flagType: FlagValueType.object, + resolver: _provider.resolveValue, + defaultValue: defaultValue, + context: context, + options: options, + ); + } + + Future> evaluate({ + required String flagKey, + required FlagValueType flagType, + required FeatureProviderResolver resolver, + required T defaultValue, + required EvaluationContext? context, + required FlagEvaluationOptions? options, + }) async { + final mergedHooks = [ + ...OpenFeature.instance.hooks, + ...hooks, + if (options != null) ...options.hooks, + ]; + final mergedHooksReversed = mergedHooks.reversed.toList(growable: false); + + final hookContext = HookContext( + flagKey: flagKey, + flagType: flagType, + context: OpenFeature.instance.context.merge(context), + defaultValue: defaultValue, + client: this, + provider: _provider, + ); + + try { + var mergedContext = await _beforeHooks( + mergedHooks, + hookContext, + options, + ); + mergedContext = mergedContext.merge(context); + + final provider = _provider; + final transformedContext = provider is ContextTransformationMixin + ? await provider.transformContext(mergedContext) + : mergedContext; + + final resolution = await resolver( + flagKey: flagKey, + defaultValue: defaultValue, + context: transformedContext, + options: options ?? const FlagEvaluationOptions(), + ); + + final evaluationDetails = FlagEvaluationDetails( + key: flagKey, + value: resolution.value, + reason: resolution.reason, + errorCode: resolution.errorCode, + variant: resolution.variant, + ); + + await _afterHooks( + mergedHooksReversed, + hookContext, + evaluationDetails, + options, + ); + + return evaluationDetails; + } on OpenFeatureException catch (e) { + await _errorHooks(mergedHooksReversed, hookContext, e, options); + + return FlagEvaluationDetails( + key: flagKey, + value: defaultValue, + reason: Reason.error, + errorCode: e.code, + ); + } finally { + await _finallyHooks(mergedHooksReversed, hookContext, options); + } + } + + Future _beforeHooks( + List hooks, + HookContext hookContext, + FlagEvaluationOptions? options, + ) async { + var context = hookContext.context; + + for (final hook in hooks) { + final evalContext = await hook.before( + context: hookContext, + hints: options?.hookHints, + ); + + context = context.merge(evalContext); + } + + return context; + } + + Future _afterHooks( + List hooks, + HookContext hookContext, + FlagEvaluationDetails evaluationDetails, + FlagEvaluationOptions? options, + ) async { + for (final hook in hooks) { + await hook.after( + context: hookContext, + hints: options?.hookHints, + details: evaluationDetails, + ); + } + } + + Future _errorHooks( + List hooks, + HookContext hookContext, + Object error, + FlagEvaluationOptions? options, + ) async { + for (final hook in hooks) { + await hook.error( + context: hookContext, + hints: options?.hookHints, + error: error, + ); + } + } + + Future _finallyHooks( + List hooks, + HookContext hookContext, + FlagEvaluationOptions? options, + ) async { + for (final hook in hooks) { + await hook.finallyAfter( + context: hookContext, + hints: options?.hookHints, + ); + } + } + + FeatureProvider get _provider => OpenFeature.instance.provider; +} From b63b71a4215d5d66cf6de86af4fe57f8bd195964 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:30:19 +0545 Subject: [PATCH 08/22] :sparkles: added `OpenFeature` class --- lib/src/open_feature/open_feature.dart | 68 ++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 lib/src/open_feature/open_feature.dart diff --git a/lib/src/open_feature/open_feature.dart b/lib/src/open_feature/open_feature.dart new file mode 100644 index 00000000..798738b2 --- /dev/null +++ b/lib/src/open_feature/open_feature.dart @@ -0,0 +1,68 @@ +library open_feature; + +import 'src/core/feature_client.dart'; +import 'src/evaluation_context/evaluation_context.dart'; +import 'src/hook/hook.dart'; +import 'src/open_feature_client.dart'; +import 'src/provider/feature_provider.dart'; +import 'src/provider/no_op_feature_provider.dart'; + +export 'src/core/enums.dart'; +export 'src/core/feature_client.dart'; +export 'src/core/resolution_details.dart'; +export 'src/evaluation_context/context_transformation_mixin.dart'; +export 'src/evaluation_context/evaluation_context.dart'; +export 'src/exceptions/open_feature_exception.dart'; +export 'src/flag_evaluation/flag_evaluation_options.dart'; +export 'src/hook/hook.dart'; +export 'src/provider/feature_provider.dart'; + +class OpenFeature { + OpenFeature._({ + this.provider = const NoOpFeatureProvider(), + EvaluationContext? context, + }) : context = context ?? EvaluationContext.empty(); + + final FeatureProvider provider; + final List _hooks = []; + final EvaluationContext context; + + static OpenFeature? _instance; + + static OpenFeature get instance { + return _instance ??= OpenFeature._(); + } + + FeatureClient getClient({ + String? name, + String? version, + }) { + return OpenFeatureClient( + options: OpenFeatureClientOptions(name: name, version: version), + ); + } + + void addHooks(List hooks) => _hooks.addAll(hooks); + + void clearHooks() => _hooks.clear(); + + List get hooks => List.unmodifiable(_hooks); + + set provider(FeatureProvider provider) { + _instance = _instance?._copyWith(provider: provider); + } + + set context(EvaluationContext context) { + _instance = _instance?._copyWith(context: context); + } + + OpenFeature _copyWith({ + FeatureProvider? provider, + EvaluationContext? context, + }) { + return OpenFeature._( + provider: provider ?? this.provider, + context: context ?? this.context, + ); + } +} From 98b7018494f07aabe148ef7f8bf4cac91ea59ab2 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:52:45 +0545 Subject: [PATCH 09/22] :sparkles: added `JsonFeatureProvider` --- .../engine/json_evaluation_engine.dart | 70 +++++++++++ .../engine/open_feature_flags.dart | 113 ++++++++++++++++++ .../json_feature_provider.dart | 86 +++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart create mode 100644 lib/src/defaults/feature_provider/engine/open_feature_flags.dart create mode 100644 lib/src/defaults/feature_provider/json_feature_provider.dart diff --git a/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart b/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart new file mode 100644 index 00000000..88455434 --- /dev/null +++ b/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart @@ -0,0 +1,70 @@ +import 'package:clean_framework/src/open_feature/open_feature.dart'; + +import 'open_feature_flags.dart'; + +class JsonEvaluationEngine { + ResolutionDetails evaluate({ + required OpenFeatureFlags flags, + required String flagKey, + required FlagValueType returnType, + required EvaluationContext context, + }) { + final flag = flags[flagKey]; + + if (flag == null || flag.state != FlagState.enabled) { + throw FlagNotFoundException('$flagKey not found.'); + } else if (flag.returnType != returnType) { + throw TypeMismatchException( + 'Flag value $flagKey had unexpected type ${flag.returnType?.name}, expected $returnType', + ); + } + + try { + final matchedRule = flag.rules.firstWhere( + (rule) { + for (final condition in rule.conditions) { + final value = context[condition.context]; + final conditionValue = condition.value; + + if (condition.op == 'equals') { + return value == conditionValue; + } + + if (value is String && conditionValue is String) { + if (condition.op == 'starts_with') { + return value.startsWith(conditionValue); + } else if (condition.op == 'ends_with') { + return value.endsWith(conditionValue); + } + } + + if (value is String && conditionValue is Iterable) { + if (condition.op == 'ends_with') { + for (final checkValue in conditionValue) { + if (value.contains(checkValue)) return true; + } + return false; + } + } + } + + return false; + }, + ); + + final variant = matchedRule.action.variant; + + return ResolutionDetails( + value: flag.variants[variant], + variant: variant, + reason: Reason.targetingMatch, + ); + } on StateError { + return ResolutionDetails( + value: flag.variants[flag.defaultVariant], + variant: flag.defaultVariant, + reason: Reason.noop, + ); + } + } +} diff --git a/lib/src/defaults/feature_provider/engine/open_feature_flags.dart b/lib/src/defaults/feature_provider/engine/open_feature_flags.dart new file mode 100644 index 00000000..6d801e82 --- /dev/null +++ b/lib/src/defaults/feature_provider/engine/open_feature_flags.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; + +import 'package:clean_framework/src/open_feature/open_feature.dart'; + +enum FlagState { enabled, disabled } + +class OpenFeatureFlags { + OpenFeatureFlags._(this._flags); + + final Map _flags; + + factory OpenFeatureFlags.fromJson(String json) { + return OpenFeatureFlags.fromMap(jsonDecode(json)); + } + + factory OpenFeatureFlags.fromMap(Map map) { + return OpenFeatureFlags._( + map.map((k, v) => MapEntry(k, FeatureFlag.fromMap(v))), + ); + } + + FeatureFlag? operator [](String key) => _flags[key]; +} + +class FeatureFlag { + FeatureFlag({ + required this.state, + this.name, + this.description, + this.returnType, + this.variants = const {}, + this.defaultVariant, + this.rules = const [], + }); + + final FlagState state; + final String? name; + final String? description; + final FlagValueType? returnType; + final Map variants; + final String? defaultVariant; + final List rules; + + factory FeatureFlag.fromMap(Map map) { + final returnType = map['returnType']; + + return FeatureFlag( + state: FlagState.values.byName(map['state']), + name: map['name'], + description: map['description'], + returnType: + returnType == null ? null : FlagValueType.values.byName(returnType), + variants: map['variants'] ?? {}, + defaultVariant: map['defaultVariant'], + rules: List.from(map['rules'] ?? []) + .map((rule) => FlagRule.fromMap(rule)) + .toList(growable: false), + ); + } +} + +class FlagRule { + FlagRule({ + required this.action, + required this.conditions, + }); + + final RuleAction action; + final List conditions; + + factory FlagRule.fromMap(Map map) { + return FlagRule( + action: RuleAction.fromMap(map['action']), + conditions: List.from(map['conditions']) + .map((c) => RuleCondition.fromMap(c)) + .toList(growable: false), + ); + } +} + +class RuleAction { + RuleAction({ + required this.variant, + }); + + final String variant; + + factory RuleAction.fromMap(Map map) { + return RuleAction( + variant: map['variant'], + ); + } +} + +class RuleCondition { + RuleCondition({ + required this.context, + required this.op, + required this.value, + }); + + final String context; + final String op; + final Object value; + + factory RuleCondition.fromMap(Map map) { + return RuleCondition( + context: map['context'], + op: map['op'], + value: map['value'], + ); + } +} diff --git a/lib/src/defaults/feature_provider/json_feature_provider.dart b/lib/src/defaults/feature_provider/json_feature_provider.dart new file mode 100644 index 00000000..54a417d9 --- /dev/null +++ b/lib/src/defaults/feature_provider/json_feature_provider.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:clean_framework/src/defaults/feature_provider/engine/json_evaluation_engine.dart'; +import 'package:clean_framework/src/open_feature/open_feature.dart'; + +import 'engine/open_feature_flags.dart'; + +class JsonFeatureProvider implements FeatureProvider { + final JsonEvaluationEngine _engine = JsonEvaluationEngine(); + final Completer _flagsCompleter = Completer(); + + @override + String get name => 'json'; + + @override + Future> resolveBooleanValue({ + required String flagKey, + required bool defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) async { + final flags = await _flagsCompleter.future; + + return _engine.evaluate( + flags: flags, + flagKey: flagKey, + returnType: FlagValueType.boolean, + context: context, + ); + } + + @override + Future> resolveNumberValue({ + required String flagKey, + required num defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) async { + final flags = await _flagsCompleter.future; + + return _engine.evaluate( + flags: flags, + flagKey: flagKey, + returnType: FlagValueType.number, + context: context, + ); + } + + @override + Future> resolveStringValue({ + required String flagKey, + required String defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) async { + final flags = await _flagsCompleter.future; + + return _engine.evaluate( + flags: flags, + flagKey: flagKey, + returnType: FlagValueType.string, + context: context, + ); + } + + @override + Future> resolveValue({ + required String flagKey, + required T defaultValue, + required EvaluationContext context, + required FlagEvaluationOptions options, + }) async { + final flags = await _flagsCompleter.future; + + return _engine.evaluate( + flags: flags, + flagKey: flagKey, + returnType: FlagValueType.object, + context: context, + ); + } + + void feed(OpenFeatureFlags flags) { + _flagsCompleter.complete(flags); + } +} From 86785e9ed8f2512c10635d64fa4b2b7c236e59d3 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 16:54:41 +0545 Subject: [PATCH 10/22] :sparkles: exported open-feature --- lib/clean_framework.dart | 1 + lib/clean_framework_defaults.dart | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/clean_framework.dart b/lib/clean_framework.dart index 550cdc2e..fdc3cc9c 100644 --- a/lib/clean_framework.dart +++ b/lib/clean_framework.dart @@ -8,5 +8,6 @@ export 'package:clean_framework/src/feature_state/feature_mapper.dart'; export 'package:clean_framework/src/feature_state/feature_state_provider.dart'; export 'package:clean_framework/src/feature_state/feature_widget.dart'; export 'package:clean_framework/src/logger.dart'; +export 'package:clean_framework/src/open_feature/open_feature.dart'; export 'package:clean_framework/src/routing/app_router.dart'; export 'package:either_dart/either.dart'; diff --git a/lib/clean_framework_defaults.dart b/lib/clean_framework_defaults.dart index 0a37cd55..7fbbf8f3 100644 --- a/lib/clean_framework_defaults.dart +++ b/lib/clean_framework_defaults.dart @@ -1,17 +1,16 @@ /// Clean Framework Defaults library clean_framework_defaults; +export 'package:clean_framework/src/defaults/feature_provider/json_feature_provider.dart'; export 'package:clean_framework/src/defaults/feature_state/feature_state.dart'; - +export 'package:clean_framework/src/defaults/network_service.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_client.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_external_interface.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_gateway.dart'; -export 'package:clean_framework/src/defaults/providers/firebase/firebase_watcher_gateway.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_requests.dart'; export 'package:clean_framework/src/defaults/providers/firebase/firebase_responses.dart'; +export 'package:clean_framework/src/defaults/providers/firebase/firebase_watcher_gateway.dart'; export 'package:clean_framework/src/defaults/providers/graphql/graphql.dart'; export 'package:clean_framework/src/defaults/providers/graphql/src/graphql_service.dart'; export 'package:clean_framework/src/defaults/providers/rest/rest.dart'; export 'package:clean_framework/src/defaults/providers/rest/src/rest_service.dart'; - -export 'package:clean_framework/src/defaults/network_service.dart'; From f7f9ad2332f666958d6cae38e451e730d475d2b4 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 17:17:47 +0545 Subject: [PATCH 11/22] :sparkles: added `FeatureBuilder` --- lib/clean_framework.dart | 1 + lib/src/widgets/src/feature_builder.dart | 42 ++++++++++++++++++++++++ lib/src/widgets/widgets.dart | 1 + 3 files changed, 44 insertions(+) create mode 100644 lib/src/widgets/src/feature_builder.dart create mode 100644 lib/src/widgets/widgets.dart diff --git a/lib/clean_framework.dart b/lib/clean_framework.dart index fdc3cc9c..cc82d456 100644 --- a/lib/clean_framework.dart +++ b/lib/clean_framework.dart @@ -10,4 +10,5 @@ export 'package:clean_framework/src/feature_state/feature_widget.dart'; export 'package:clean_framework/src/logger.dart'; export 'package:clean_framework/src/open_feature/open_feature.dart'; export 'package:clean_framework/src/routing/app_router.dart'; +export 'package:clean_framework/src/widgets/widgets.dart'; export 'package:either_dart/either.dart'; diff --git a/lib/src/widgets/src/feature_builder.dart b/lib/src/widgets/src/feature_builder.dart new file mode 100644 index 00000000..7f7f63b8 --- /dev/null +++ b/lib/src/widgets/src/feature_builder.dart @@ -0,0 +1,42 @@ +import 'package:clean_framework/src/open_feature/open_feature.dart'; +import 'package:flutter/material.dart'; + +class FeatureBuilder extends StatefulWidget { + const FeatureBuilder({ + super.key, + required this.flagKey, + required this.defaultValue, + required this.builder, + }); + + final String flagKey; + final T defaultValue; + final Widget Function(BuildContext, T) builder; + + @override + State> createState() => _FeatureBuilderState(); +} + +class _FeatureBuilderState extends State> { + late final FeatureClient _client; + + @override + void initState() { + super.initState(); + _client = OpenFeature.instance.getClient(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + initialData: widget.defaultValue, + future: _client.getValue( + key: widget.flagKey, + defaultValue: widget.defaultValue, + ), + builder: (context, snapshot) { + return widget.builder(context, snapshot.data!); + }, + ); + } +} diff --git a/lib/src/widgets/widgets.dart b/lib/src/widgets/widgets.dart new file mode 100644 index 00000000..cf58a485 --- /dev/null +++ b/lib/src/widgets/widgets.dart @@ -0,0 +1 @@ +export 'src/feature_builder.dart'; From 48da7ad5506d9c72d5f644c4b2d009898cc1a5ef Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 17:36:17 +0545 Subject: [PATCH 12/22] :sparkles: added `EvaluationEngine` --- .../feature_provider/engine/evaluation_engine.dart | 12 ++++++++++++ .../engine/json_evaluation_engine.dart | 6 +++++- .../feature_provider/json_feature_provider.dart | 9 ++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 lib/src/defaults/feature_provider/engine/evaluation_engine.dart diff --git a/lib/src/defaults/feature_provider/engine/evaluation_engine.dart b/lib/src/defaults/feature_provider/engine/evaluation_engine.dart new file mode 100644 index 00000000..164ca500 --- /dev/null +++ b/lib/src/defaults/feature_provider/engine/evaluation_engine.dart @@ -0,0 +1,12 @@ +import 'package:clean_framework/src/open_feature/open_feature.dart'; + +import 'open_feature_flags.dart'; + +abstract class EvaluationEngine { + ResolutionDetails evaluate({ + required OpenFeatureFlags flags, + required String flagKey, + required FlagValueType returnType, + required EvaluationContext context, + }); +} diff --git a/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart b/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart index 88455434..e7cf9eda 100644 --- a/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart +++ b/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart @@ -1,8 +1,12 @@ +import 'package:clean_framework/src/defaults/feature_provider/engine/evaluation_engine.dart'; import 'package:clean_framework/src/open_feature/open_feature.dart'; import 'open_feature_flags.dart'; -class JsonEvaluationEngine { +class JsonEvaluationEngine implements EvaluationEngine { + const JsonEvaluationEngine(); + + @override ResolutionDetails evaluate({ required OpenFeatureFlags flags, required String flagKey, diff --git a/lib/src/defaults/feature_provider/json_feature_provider.dart b/lib/src/defaults/feature_provider/json_feature_provider.dart index 54a417d9..f3f8ce5b 100644 --- a/lib/src/defaults/feature_provider/json_feature_provider.dart +++ b/lib/src/defaults/feature_provider/json_feature_provider.dart @@ -1,12 +1,19 @@ import 'dart:async'; +import 'package:clean_framework/src/defaults/feature_provider/engine/evaluation_engine.dart'; import 'package:clean_framework/src/defaults/feature_provider/engine/json_evaluation_engine.dart'; import 'package:clean_framework/src/open_feature/open_feature.dart'; import 'engine/open_feature_flags.dart'; +export 'engine/evaluation_engine.dart'; + class JsonFeatureProvider implements FeatureProvider { - final JsonEvaluationEngine _engine = JsonEvaluationEngine(); + JsonFeatureProvider({ + EvaluationEngine engine = const JsonEvaluationEngine(), + }) : _engine = engine; + + final EvaluationEngine _engine; final Completer _flagsCompleter = Completer(); @override From 22be6ace3f4e873cd692408df28397236e297bbc Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 17:39:59 +0545 Subject: [PATCH 13/22] :bento: added `flags.json` --- example/assets/flags.json | 53 +++++++++++++++++++++++++++++++++++++++ example/pubspec.yaml | 3 +++ 2 files changed, 56 insertions(+) create mode 100644 example/assets/flags.json diff --git a/example/assets/flags.json b/example/assets/flags.json new file mode 100644 index 00000000..08ea9034 --- /dev/null +++ b/example/assets/flags.json @@ -0,0 +1,53 @@ +{ + "newTitle": { + "state": "disabled" + }, + "color": { + "returnType": "number", + "variants": { + "red": 4294901760, + "green": 4278255360, + "blue": 4278190335, + "purple": 4285140397 + }, + "defaultVariant": "red", + "state": "enabled" + }, + "exampleFeatures": { + "returnType": "string", + "variants": { + "query": "firebase,graphql", + "restful": "graphql,rest", + "traditional": "rest", + "all": "firebase,graphql,rest" + }, + "defaultVariant": "query", + "state": "enabled", + "rules": [ + { + "action": { + "variant": "restful" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "ios" + } + ] + }, + { + "action": { + "variant": "all" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "android" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1c82d6ce..d4032404 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -25,3 +25,6 @@ dev_dependencies: flutter: uses-material-design: true + + assets: + - assets/flags.json From 1509efb556ee8d42327bc7ce5f8644ff30109475 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 19:09:41 +0545 Subject: [PATCH 14/22] :sparkles: added `FeatureBuilder` --- example/assets/flags.json | 2 +- example/lib/asset_feature_provider.dart | 10 +++ example/lib/home_page.dart | 90 ++++++++++++++----- example/lib/main.dart | 45 ++++++---- example/pubspec.yaml | 4 +- .../json_feature_provider.dart | 1 + lib/src/widgets/src/feature_builder.dart | 77 +++++++++++----- lib/src/widgets/src/feature_scope.dart | 51 +++++++++++ lib/src/widgets/widgets.dart | 1 + 9 files changed, 217 insertions(+), 64 deletions(-) create mode 100644 example/lib/asset_feature_provider.dart create mode 100644 lib/src/widgets/src/feature_scope.dart diff --git a/example/assets/flags.json b/example/assets/flags.json index 08ea9034..ceb1190d 100644 --- a/example/assets/flags.json +++ b/example/assets/flags.json @@ -32,7 +32,7 @@ { "context": "platform", "op": "equals", - "value": "ios" + "value": "iOS" } ] }, diff --git a/example/lib/asset_feature_provider.dart b/example/lib/asset_feature_provider.dart new file mode 100644 index 00000000..e707b1a2 --- /dev/null +++ b/example/lib/asset_feature_provider.dart @@ -0,0 +1,10 @@ +import 'package:clean_framework/clean_framework_defaults.dart'; +import 'package:flutter/services.dart'; + +class AssetFeatureProvider extends JsonFeatureProvider { + Future load(String key) async { + final rawFlags = await rootBundle.loadString(key); + + feed(OpenFeatureFlags.fromJson(rawFlags)); + } +} diff --git a/example/lib/home_page.dart b/example/lib/home_page.dart index a2b7292d..8c501969 100644 --- a/example/lib/home_page.dart +++ b/example/lib/home_page.dart @@ -1,4 +1,6 @@ +import 'package:clean_framework/clean_framework.dart'; import 'package:clean_framework_example/routes.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class HomePage extends StatelessWidget { @@ -10,28 +12,74 @@ class HomePage extends StatelessWidget { appBar: AppBar( title: Text('Example Features'), ), - body: ListView( - padding: EdgeInsets.symmetric(vertical: 16), - children: [ - ListTile( - title: Text('Firebase'), - leading: Icon(Icons.local_fire_department_sharp), - onTap: () => router.to(Routes.lastLogin), - ), - const Divider(), - ListTile( - title: Text('GraphQL'), - leading: Icon(Icons.graphic_eq), - onTap: () => router.to(Routes.countries), - ), - const Divider(), - ListTile( - title: Text('Rest API'), - leading: Icon(Icons.sync_alt), - onTap: () => router.to(Routes.randomCat), - ), - ], + body: FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'all', + evaluationContext: EvaluationContext( + {'platform': defaultTargetPlatform.name}, + ), + builder: (context, value) { + final enabledFeatures = value.split(','); + + return ListView( + padding: EdgeInsets.symmetric(vertical: 16), + children: [ + _List( + enabled: enabledFeatures.contains('firebase'), + title: 'Firebase', + iconData: Icons.local_fire_department_sharp, + route: Routes.lastLogin, + showDivider: false, + ), + _List( + enabled: enabledFeatures.contains('graphql'), + title: 'GraphQL', + iconData: Icons.graphic_eq, + route: Routes.countries, + ), + _List( + enabled: enabledFeatures.contains('rest'), + title: 'Rest API', + iconData: Icons.sync_alt, + route: Routes.randomCat, + ), + ], + ); + }, ), ); } } + +class _List extends StatelessWidget { + const _List({ + required this.enabled, + required this.title, + required this.iconData, + required this.route, + this.showDivider = true, + }); + + final bool enabled; + final String title; + final IconData iconData; + final Routes route; + final bool showDivider; + + @override + Widget build(BuildContext context) { + if (!enabled) return const SizedBox.shrink(); + + return Column( + children: [ + ListTile( + title: Text(title), + leading: Icon(iconData), + onTap: () => router.to(route), + ), + if (showDivider) Divider(), + ], + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index ef5a5fb8..f4cb219c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,33 +1,42 @@ import 'package:clean_framework/clean_framework.dart'; +import 'package:clean_framework_example/asset_feature_provider.dart'; import 'package:clean_framework_example/providers.dart'; import 'package:clean_framework_example/routes.dart'; import 'package:flutter/material.dart'; -void main() { +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); loadProviders(); + + final featureProvider = AssetFeatureProvider(); + OpenFeature.instance.provider = featureProvider; + await featureProvider.load('assets/flags.json'); + runApp(ExampleApp()); } class ExampleApp extends StatelessWidget { @override Widget build(BuildContext context) { - return AppProvidersContainer( - providersContext: providersContext, - onBuild: (context, _) { - providersContext().read(featureStatesProvider.featuresMap).load({ - 'features': [ - {'name': 'last_login', 'state': 'ACTIVE'}, - ] - }); - }, - child: MaterialApp.router( - routeInformationParser: router.informationParser, - routerDelegate: router.delegate, - theme: ThemeData( - pageTransitionsTheme: PageTransitionsTheme( - builders: { - TargetPlatform.android: ZoomPageTransitionsBuilder(), - }, + return FeatureScope( + child: AppProvidersContainer( + providersContext: providersContext, + onBuild: (context, _) { + providersContext().read(featureStatesProvider.featuresMap).load({ + 'features': [ + {'name': 'last_login', 'state': 'ACTIVE'}, + ] + }); + }, + child: MaterialApp.router( + routeInformationParser: router.informationParser, + routerDelegate: router.delegate, + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), ), ), ), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index d4032404..b7888d6c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -4,8 +4,8 @@ version: 1.0.0 publish_to: 'none' environment: - sdk: '>=2.16.0 <3.0.0' - flutter: '>=1.17.0' + sdk: '>=2.17.0 <3.0.0' + flutter: '>=3.0.0' dependencies: flutter: diff --git a/lib/src/defaults/feature_provider/json_feature_provider.dart b/lib/src/defaults/feature_provider/json_feature_provider.dart index f3f8ce5b..0cd75f0c 100644 --- a/lib/src/defaults/feature_provider/json_feature_provider.dart +++ b/lib/src/defaults/feature_provider/json_feature_provider.dart @@ -7,6 +7,7 @@ import 'package:clean_framework/src/open_feature/open_feature.dart'; import 'engine/open_feature_flags.dart'; export 'engine/evaluation_engine.dart'; +export 'engine/open_feature_flags.dart'; class JsonFeatureProvider implements FeatureProvider { JsonFeatureProvider({ diff --git a/lib/src/widgets/src/feature_builder.dart b/lib/src/widgets/src/feature_builder.dart index 7f7f63b8..6a43fbfe 100644 --- a/lib/src/widgets/src/feature_builder.dart +++ b/lib/src/widgets/src/feature_builder.dart @@ -1,42 +1,75 @@ +import 'package:clean_framework/clean_framework.dart'; import 'package:clean_framework/src/open_feature/open_feature.dart'; +import 'package:clean_framework/src/widgets/src/feature_scope.dart'; import 'package:flutter/material.dart'; -class FeatureBuilder extends StatefulWidget { - const FeatureBuilder({ +typedef FeatureBuilderCallback = Widget Function( + BuildContext, + T, +); + +class FeatureBuilder extends StatelessWidget { + FeatureBuilder({ super.key, required this.flagKey, + required this.valueType, required this.defaultValue, required this.builder, + this.evaluationContext, }); final String flagKey; + final FlagValueType valueType; final T defaultValue; - final Widget Function(BuildContext, T) builder; - - @override - State> createState() => _FeatureBuilderState(); -} - -class _FeatureBuilderState extends State> { - late final FeatureClient _client; - - @override - void initState() { - super.initState(); - _client = OpenFeature.instance.getClient(); - } + final FeatureBuilderCallback builder; + final EvaluationContext? evaluationContext; @override Widget build(BuildContext context) { + final client = FeatureScope.of(context).client; + return FutureBuilder( - initialData: widget.defaultValue, - future: _client.getValue( - key: widget.flagKey, - defaultValue: widget.defaultValue, - ), + initialData: defaultValue, + future: _resolver(client), builder: (context, snapshot) { - return widget.builder(context, snapshot.data!); + return builder(context, snapshot.data!); }, ); } + + Future _resolver(FeatureClient client) async { + Future _future; + switch (valueType) { + case FlagValueType.boolean: + _future = client.getBooleanValue( + key: flagKey, + defaultValue: defaultValue as bool, + context: evaluationContext, + ); + break; + case FlagValueType.string: + _future = client.getStringValue( + key: flagKey, + defaultValue: defaultValue as String, + context: evaluationContext, + ); + break; + case FlagValueType.number: + _future = client.getNumberValue( + key: flagKey, + defaultValue: defaultValue as num, + context: evaluationContext, + ); + break; + case FlagValueType.object: + _future = client.getValue( + key: flagKey, + defaultValue: defaultValue, + context: evaluationContext, + ); + break; + } + + return (await _future) as T; + } } diff --git a/lib/src/widgets/src/feature_scope.dart b/lib/src/widgets/src/feature_scope.dart new file mode 100644 index 00000000..2bfb1e5e --- /dev/null +++ b/lib/src/widgets/src/feature_scope.dart @@ -0,0 +1,51 @@ +import 'package:clean_framework/clean_framework.dart'; +import 'package:flutter/material.dart'; + +class FeatureScope extends StatefulWidget { + const FeatureScope({ + super.key, + required this.child, + }); + + final Widget child; + + static _InheritedFeatureScope of(BuildContext context) { + final _InheritedFeatureScope? result = + context.dependOnInheritedWidgetOfExactType<_InheritedFeatureScope>(); + assert(result != null, 'No _InheritedFeatureScope found in context'); + return result!; + } + + @override + State createState() => _FeatureScopeState(); +} + +class _FeatureScopeState extends State { + late final FeatureClient _client; + + @override + void initState() { + super.initState(); + _client = OpenFeature.instance.getClient(); + } + + @override + Widget build(BuildContext context) { + return _InheritedFeatureScope( + client: _client, + child: widget.child, + ); + } +} + +class _InheritedFeatureScope extends InheritedWidget { + const _InheritedFeatureScope({ + required Widget child, + required this.client, + }) : super(child: child); + + final FeatureClient client; + + @override + bool updateShouldNotify(_InheritedFeatureScope old) => false; +} diff --git a/lib/src/widgets/widgets.dart b/lib/src/widgets/widgets.dart index cf58a485..f75581fa 100644 --- a/lib/src/widgets/widgets.dart +++ b/lib/src/widgets/widgets.dart @@ -1 +1,2 @@ export 'src/feature_builder.dart'; +export 'src/feature_scope.dart'; From 3dc98c7877f7ea89a5d93dedebdeb0f4b17164e2 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 20:32:05 +0545 Subject: [PATCH 15/22] :sparkles: added `FeatureScope` --- example/lib/home_page.dart | 30 +++++++++--------- example/lib/main.dart | 24 +++++++-------- lib/src/widgets/src/feature_builder.dart | 39 +++++++++++++----------- lib/src/widgets/src/feature_scope.dart | 27 ++++++++++++++-- 4 files changed, 72 insertions(+), 48 deletions(-) diff --git a/example/lib/home_page.dart b/example/lib/home_page.dart index 8c501969..ac64cbc0 100644 --- a/example/lib/home_page.dart +++ b/example/lib/home_page.dart @@ -15,7 +15,7 @@ class HomePage extends StatelessWidget { body: FeatureBuilder( flagKey: 'exampleFeatures', valueType: FlagValueType.string, - defaultValue: 'all', + defaultValue: 'rest,firebase,graphql', evaluationContext: EvaluationContext( {'platform': defaultTargetPlatform.name}, ), @@ -30,7 +30,6 @@ class HomePage extends StatelessWidget { title: 'Firebase', iconData: Icons.local_fire_department_sharp, route: Routes.lastLogin, - showDivider: false, ), _List( enabled: enabledFeatures.contains('graphql'), @@ -58,28 +57,29 @@ class _List extends StatelessWidget { required this.title, required this.iconData, required this.route, - this.showDivider = true, }); final bool enabled; final String title; final IconData iconData; final Routes route; - final bool showDivider; @override Widget build(BuildContext context) { - if (!enabled) return const SizedBox.shrink(); - - return Column( - children: [ - ListTile( - title: Text(title), - leading: Icon(iconData), - onTap: () => router.to(route), - ), - if (showDivider) Divider(), - ], + return AnimatedSize( + duration: const Duration(seconds: 2), + child: enabled + ? Column( + children: [ + ListTile( + title: Text(title), + leading: Icon(iconData), + onTap: () => router.to(route), + ), + Divider(), + ], + ) + : const SizedBox.shrink(), ); } } diff --git a/example/lib/main.dart b/example/lib/main.dart index f4cb219c..47ff8e1f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:clean_framework/clean_framework.dart'; import 'package:clean_framework_example/asset_feature_provider.dart'; import 'package:clean_framework_example/providers.dart'; @@ -8,26 +10,24 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); loadProviders(); - final featureProvider = AssetFeatureProvider(); - OpenFeature.instance.provider = featureProvider; - await featureProvider.load('assets/flags.json'); - runApp(ExampleApp()); } class ExampleApp extends StatelessWidget { @override Widget build(BuildContext context) { - return FeatureScope( + return FeatureScope( + register: () => AssetFeatureProvider(), + loader: (featureProvider) async { + await Future.delayed(Duration(seconds: 5)); + await featureProvider.load('assets/flags.json'); + }, + onLoaded: () { + log('Feature Flags activated.'); + }, child: AppProvidersContainer( providersContext: providersContext, - onBuild: (context, _) { - providersContext().read(featureStatesProvider.featuresMap).load({ - 'features': [ - {'name': 'last_login', 'state': 'ACTIVE'}, - ] - }); - }, + onBuild: (context, _) {}, child: MaterialApp.router( routeInformationParser: router.informationParser, routerDelegate: router.delegate, diff --git a/lib/src/widgets/src/feature_builder.dart b/lib/src/widgets/src/feature_builder.dart index 6a43fbfe..234c9002 100644 --- a/lib/src/widgets/src/feature_builder.dart +++ b/lib/src/widgets/src/feature_builder.dart @@ -1,6 +1,4 @@ import 'package:clean_framework/clean_framework.dart'; -import 'package:clean_framework/src/open_feature/open_feature.dart'; -import 'package:clean_framework/src/widgets/src/feature_scope.dart'; import 'package:flutter/material.dart'; typedef FeatureBuilderCallback = Widget Function( @@ -8,7 +6,7 @@ typedef FeatureBuilderCallback = Widget Function( T, ); -class FeatureBuilder extends StatelessWidget { +class FeatureBuilder extends StatefulWidget { FeatureBuilder({ super.key, required this.flagKey, @@ -24,48 +22,53 @@ class FeatureBuilder extends StatelessWidget { final FeatureBuilderCallback builder; final EvaluationContext? evaluationContext; + @override + State> createState() => _FeatureBuilderState(); +} + +class _FeatureBuilderState extends State> { @override Widget build(BuildContext context) { final client = FeatureScope.of(context).client; return FutureBuilder( - initialData: defaultValue, + initialData: widget.defaultValue, future: _resolver(client), builder: (context, snapshot) { - return builder(context, snapshot.data!); + return widget.builder(context, snapshot.data!); }, ); } Future _resolver(FeatureClient client) async { Future _future; - switch (valueType) { + switch (widget.valueType) { case FlagValueType.boolean: _future = client.getBooleanValue( - key: flagKey, - defaultValue: defaultValue as bool, - context: evaluationContext, + key: widget.flagKey, + defaultValue: widget.defaultValue as bool, + context: widget.evaluationContext, ); break; case FlagValueType.string: _future = client.getStringValue( - key: flagKey, - defaultValue: defaultValue as String, - context: evaluationContext, + key: widget.flagKey, + defaultValue: widget.defaultValue as String, + context: widget.evaluationContext, ); break; case FlagValueType.number: _future = client.getNumberValue( - key: flagKey, - defaultValue: defaultValue as num, - context: evaluationContext, + key: widget.flagKey, + defaultValue: widget.defaultValue as num, + context: widget.evaluationContext, ); break; case FlagValueType.object: _future = client.getValue( - key: flagKey, - defaultValue: defaultValue, - context: evaluationContext, + key: widget.flagKey, + defaultValue: widget.defaultValue, + context: widget.evaluationContext, ); break; } diff --git a/lib/src/widgets/src/feature_scope.dart b/lib/src/widgets/src/feature_scope.dart index 2bfb1e5e..dca76e99 100644 --- a/lib/src/widgets/src/feature_scope.dart +++ b/lib/src/widgets/src/feature_scope.dart @@ -1,13 +1,21 @@ +import 'dart:async'; + import 'package:clean_framework/clean_framework.dart'; import 'package:flutter/material.dart'; -class FeatureScope extends StatefulWidget { +class FeatureScope extends StatefulWidget { const FeatureScope({ super.key, + required this.register, required this.child, + this.loader, + this.onLoaded, }); + final T Function() register; final Widget child; + final Future Function(T)? loader; + final VoidCallback? onLoaded; static _InheritedFeatureScope of(BuildContext context) { final _InheritedFeatureScope? result = @@ -17,16 +25,29 @@ class FeatureScope extends StatefulWidget { } @override - State createState() => _FeatureScopeState(); + State> createState() => _FeatureScopeState(); } -class _FeatureScopeState extends State { +class _FeatureScopeState + extends State> { late final FeatureClient _client; @override void initState() { super.initState(); + final featureProvider = widget.register(); + + OpenFeature.instance.provider = featureProvider; _client = OpenFeature.instance.getClient(); + + _load(featureProvider); + } + + Future _load(T featureProvider) async { + if (widget.loader != null) { + await widget.loader!.call(featureProvider); + widget.onLoaded?.call(); + } } @override From 09338d067d4319083b3bffd113089731f340af52 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Mon, 6 Jun 2022 20:55:21 +0545 Subject: [PATCH 16/22] :sparkles: update app bar color based on feature flag --- example/lib/home_page.dart | 95 +++++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 38 deletions(-) diff --git a/example/lib/home_page.dart b/example/lib/home_page.dart index ac64cbc0..1b2d9ae8 100644 --- a/example/lib/home_page.dart +++ b/example/lib/home_page.dart @@ -8,45 +8,64 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text('Example Features'), - ), - body: FeatureBuilder( - flagKey: 'exampleFeatures', - valueType: FlagValueType.string, - defaultValue: 'rest,firebase,graphql', - evaluationContext: EvaluationContext( - {'platform': defaultTargetPlatform.name}, - ), - builder: (context, value) { - final enabledFeatures = value.split(','); + return FeatureBuilder( + flagKey: 'color', + valueType: FlagValueType.number, + defaultValue: 0xFF0000FF, + builder: (context, colorValue) { + return Scaffold( + appBar: AppBar( + title: Text('Example Features'), + backgroundColor: Color(colorValue), + ), + body: FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest,firebase,graphql', + evaluationContext: EvaluationContext( + {'platform': defaultTargetPlatform.name}, + ), + builder: (context, value) { + final enabledFeatures = value.split(','); + + return _ExampleFeature(enabledFeatures: enabledFeatures); + }, + ), + ); + }, + ); + } +} + +class _ExampleFeature extends StatelessWidget { + const _ExampleFeature({required this.enabledFeatures}); - return ListView( - padding: EdgeInsets.symmetric(vertical: 16), - children: [ - _List( - enabled: enabledFeatures.contains('firebase'), - title: 'Firebase', - iconData: Icons.local_fire_department_sharp, - route: Routes.lastLogin, - ), - _List( - enabled: enabledFeatures.contains('graphql'), - title: 'GraphQL', - iconData: Icons.graphic_eq, - route: Routes.countries, - ), - _List( - enabled: enabledFeatures.contains('rest'), - title: 'Rest API', - iconData: Icons.sync_alt, - route: Routes.randomCat, - ), - ], - ); - }, - ), + final List enabledFeatures; + + @override + Widget build(BuildContext context) { + return ListView( + padding: EdgeInsets.symmetric(vertical: 16), + children: [ + _List( + enabled: enabledFeatures.contains('firebase'), + title: 'Firebase', + iconData: Icons.local_fire_department_sharp, + route: Routes.lastLogin, + ), + _List( + enabled: enabledFeatures.contains('graphql'), + title: 'GraphQL', + iconData: Icons.graphic_eq, + route: Routes.countries, + ), + _List( + enabled: enabledFeatures.contains('rest'), + title: 'Rest API', + iconData: Icons.sync_alt, + route: Routes.randomCat, + ), + ], ); } } From f74443c27770d7f778f4fc54076a5a54012229d9 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Tue, 7 Jun 2022 20:10:13 +0545 Subject: [PATCH 17/22] :bug: handled erronous cases for flag resolution --- lib/src/widgets/src/feature_builder.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/src/widgets/src/feature_builder.dart b/lib/src/widgets/src/feature_builder.dart index 234c9002..fafc1826 100644 --- a/lib/src/widgets/src/feature_builder.dart +++ b/lib/src/widgets/src/feature_builder.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:clean_framework/clean_framework.dart'; import 'package:flutter/material.dart'; @@ -35,6 +37,17 @@ class _FeatureBuilderState extends State> { initialData: widget.defaultValue, future: _resolver(client), builder: (context, snapshot) { + if (snapshot.hasError) { + log( + 'Resolution Error', + name: 'Feature Flag', + error: snapshot.error, + stackTrace: snapshot.stackTrace, + ); + + return widget.builder(context, widget.defaultValue); + } + return widget.builder(context, snapshot.data!); }, ); From 721e4ed57624b7024aa2637437218bd08751beb1 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Tue, 7 Jun 2022 21:37:15 +0545 Subject: [PATCH 18/22] :white_check_mark: fixed failing tests --- example/lib/main.dart | 3 +- .../country/presentation/country_ui_test.dart | 6 ++ example/test/home_page_test.dart | 74 +++++++++++++++++-- example/test/main_test.dart | 4 +- lib/src/tests/test_helpers.dart | 6 +- 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 47ff8e1f..0c7602e6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -19,7 +19,8 @@ class ExampleApp extends StatelessWidget { return FeatureScope( register: () => AssetFeatureProvider(), loader: (featureProvider) async { - await Future.delayed(Duration(seconds: 5)); + // To demonstrate the lazy update triggered by change in feature flags. + await Future.delayed(Duration(seconds: 2)); await featureProvider.load('assets/flags.json'); }, onLoaded: () { diff --git a/example/test/features/country/presentation/country_ui_test.dart b/example/test/features/country/presentation/country_ui_test.dart index cdbf0aaf..f7dc0319 100644 --- a/example/test/features/country/presentation/country_ui_test.dart +++ b/example/test/features/country/presentation/country_ui_test.dart @@ -7,6 +7,8 @@ import 'package:clean_framework_example/routes.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../home_page_test.dart'; + void main() { setupUITest(context: providersContext, router: router); @@ -89,6 +91,10 @@ void main() { uiTest( 'tapping on country tile should navigate to detail page', + parentBuilder: (child) => FeatureScope( + register: () => FakeJsonFeatureProvider(), + child: child, + ), verify: (tester) async { router.to(Routes.countries); await tester.pumpAndSettle(); diff --git a/example/test/home_page_test.dart b/example/test/home_page_test.dart index cef7d45c..9d7cb110 100644 --- a/example/test/home_page_test.dart +++ b/example/test/home_page_test.dart @@ -1,4 +1,5 @@ import 'package:clean_framework/clean_framework.dart'; +import 'package:clean_framework/clean_framework_defaults.dart'; import 'package:clean_framework_example/features/country/presentation/country_ui.dart'; import 'package:clean_framework_example/features/last_login/presentation/last_login_ui.dart'; import 'package:clean_framework_example/features/random_cat/presentation/random_cat_ui.dart'; @@ -107,12 +108,73 @@ void main() { } Widget buildWidget(Widget widget) { - return AppProvidersContainer( - providersContext: providersContext, - onBuild: (_, __) {}, - child: MaterialApp.router( - routeInformationParser: router.informationParser, - routerDelegate: router.delegate, + return FeatureScope( + register: () => FakeJsonFeatureProvider(), + child: AppProvidersContainer( + providersContext: providersContext, + onBuild: (_, __) {}, + child: MaterialApp.router( + routeInformationParser: router.informationParser, + routerDelegate: router.delegate, + ), ), ); } + +class FakeJsonFeatureProvider extends JsonFeatureProvider { + FakeJsonFeatureProvider() { + feed(OpenFeatureFlags.fromJson('''{ + "newTitle": { + "state": "disabled" + }, + "color": { + "returnType": "number", + "variants": { + "red": 4294901760, + "green": 4278255360, + "blue": 4278190335, + "purple": 4285140397 + }, + "defaultVariant": "red", + "state": "enabled" + }, + "exampleFeatures": { + "returnType": "string", + "variants": { + "query": "firebase,graphql", + "restful": "graphql,rest", + "traditional": "rest", + "all": "firebase,graphql,rest" + }, + "defaultVariant": "query", + "state": "enabled", + "rules": [ + { + "action": { + "variant": "restful" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "iOS" + } + ] + }, + { + "action": { + "variant": "all" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "android" + } + ] + } + ] + } +}''')); + } +} diff --git a/example/test/main_test.dart b/example/test/main_test.dart index 08441cfc..6ea41eca 100644 --- a/example/test/main_test.dart +++ b/example/test/main_test.dart @@ -18,7 +18,7 @@ void main() { await tester.pumpWidget( app.ExampleApp(), ); - await tester.pump(); + await tester.pump(const Duration(seconds: 2)); await tester.pumpAndSettle(); // Uncomment this to see the widget tree on the console @@ -30,6 +30,6 @@ void main() { providersContext().read(featureStatesProvider.featuresMap); expect(featuresMap.defaultState, isA()); - expect(featuresMap.getStateFor(lastLoginFeature), FeatureState.active); + expect(featuresMap.getStateFor(lastLoginFeature), FeatureState.hidden); }); } diff --git a/lib/src/tests/test_helpers.dart b/lib/src/tests/test_helpers.dart index 6d7eca41..740f836d 100644 --- a/lib/src/tests/test_helpers.dart +++ b/lib/src/tests/test_helpers.dart @@ -65,6 +65,7 @@ void uiTest( dynamic tags, Size? screenSize, Iterable? localizationDelegates, + Widget Function(Widget)? parentBuilder, }) { assert( () { @@ -130,7 +131,10 @@ void uiTest( child = _scopedChild(builder!()); } - await tester.pumpWidget(child, pumpDuration); + await tester.pumpWidget( + parentBuilder == null ? child : parentBuilder(child), + pumpDuration, + ); if (postFrame == null) { await tester.pumpAndSettle(); From 5f0c80d1d5c277a567a6b1b0f69e27d936d7223d Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Tue, 7 Jun 2022 21:53:46 +0545 Subject: [PATCH 19/22] :bookmark: bumped version & updated changelog --- CHANGELOG.md | 4 ++++ pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8e333f..57032e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# [1.3.1] - Jun 7, 2022 +- Added Feature Flag Management based on OpenFeature specs. +- Added `FeatureBuilder` and `FeatureScope`. + # [1.3.0] - May 12, 2022 - Added `BridgeGatewayProvider` - Fixed issue with content-type being appended with charset. diff --git a/pubspec.yaml b/pubspec.yaml index 128133ae..3a4dc7c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: clean_framework description: Clean Architecture components library, inspired on the guidelines created by Uncle Bob. -version: 1.3.0 +version: 1.3.1 homepage: https://acmesoftware.com/ repository: https://github.com/MattHamburger/clean_framework From 74b4763a8125fabc674b0af272fe32f1f35dee73 Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Tue, 7 Jun 2022 22:06:22 +0545 Subject: [PATCH 20/22] :white_check_mark: added tests for feature builder --- test/widgets/feature_builder_test.dart | 171 +++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 test/widgets/feature_builder_test.dart diff --git a/test/widgets/feature_builder_test.dart b/test/widgets/feature_builder_test.dart new file mode 100644 index 00000000..9b47df8f --- /dev/null +++ b/test/widgets/feature_builder_test.dart @@ -0,0 +1,171 @@ +import 'package:clean_framework/clean_framework.dart'; +import 'package:clean_framework/clean_framework_defaults.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FeatureBuilder tests ||', () { + testWidgets('default value', (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + onLoaded: () {}, + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest', + builder: (context, value) { + return Text(value); + }, + ); + }, + ), + ), + ); + + expect(find.text('rest'), findsOneWidget); + }); + + testWidgets('default variant', (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + onLoaded: () {}, + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest', + builder: (context, value) { + return Text(value); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('firebase,graphql'), findsOneWidget); + }); + + testWidgets('alternative variant for iOS', (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + onLoaded: () {}, + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest', + evaluationContext: EvaluationContext( + {'platform': 'iOS'}, + ), + builder: (context, value) { + return Text(value); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('graphql,rest'), findsOneWidget); + }); + + testWidgets('alternative variant for Android', (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + onLoaded: () {}, + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'exampleFeatures', + valueType: FlagValueType.string, + defaultValue: 'rest', + evaluationContext: EvaluationContext( + {'platform': 'android'}, + ), + builder: (context, value) { + return Text(value); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('firebase,graphql,rest'), findsOneWidget); + }); + }); +} + +class FakeJsonFeatureProvider extends JsonFeatureProvider { + void load() { + feed(OpenFeatureFlags.fromJson('''{ + "newTitle": { + "state": "disabled" + }, + "color": { + "returnType": "number", + "variants": { + "red": 4294901760, + "green": 4278255360, + "blue": 4278190335, + "purple": 4285140397 + }, + "defaultVariant": "red", + "state": "enabled" + }, + "exampleFeatures": { + "returnType": "string", + "variants": { + "query": "firebase,graphql", + "restful": "graphql,rest", + "traditional": "rest", + "all": "firebase,graphql,rest" + }, + "defaultVariant": "query", + "state": "enabled", + "rules": [ + { + "action": { + "variant": "restful" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "iOS" + } + ] + }, + { + "action": { + "variant": "all" + }, + "conditions": [ + { + "context": "platform", + "op": "equals", + "value": "android" + } + ] + } + ] + } +}''')); + } +} From 4d979c08fedf2fe50f45d024adf2b6fc36d0351d Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Tue, 7 Jun 2022 22:19:21 +0545 Subject: [PATCH 21/22] :white_check_mark: added more test cases --- test/widgets/feature_builder_test.dart | 155 +++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/test/widgets/feature_builder_test.dart b/test/widgets/feature_builder_test.dart index 9b47df8f..b0087c62 100644 --- a/test/widgets/feature_builder_test.dart +++ b/test/widgets/feature_builder_test.dart @@ -110,6 +110,143 @@ void main() { expect(find.text('firebase,graphql,rest'), findsOneWidget); }); }); + + group('FeatureBuilder tests || different value type ||', () { + testWidgets( + 'boolean', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'boolean', + valueType: FlagValueType.boolean, + defaultValue: false, + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('true'), findsOneWidget); + }, + ); + + testWidgets( + 'boolean', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'boolean', + valueType: FlagValueType.boolean, + defaultValue: false, + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('true'), findsOneWidget); + }, + ); + + testWidgets( + 'number', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'color', + valueType: FlagValueType.number, + defaultValue: 4285140397, + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('4294901760'), findsOneWidget); + }, + ); + + testWidgets( + 'object', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'object', + valueType: FlagValueType.object, + defaultValue: [0, 0], + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('[1, 2]'), findsOneWidget); + }, + ); + + testWidgets( + 'shows default value on error', + (tester) async { + await tester.pumpWidget( + FeatureScope( + register: () => FakeJsonFeatureProvider(), + loader: (featureProvider) async => featureProvider.load(), + child: MaterialApp( + builder: (context, child) { + return FeatureBuilder( + flagKey: 'object', + valueType: FlagValueType.number, + defaultValue: [0, 0], + builder: (context, value) { + return Text(value.toString()); + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('[0, 0]'), findsOneWidget); + }, + ); + }); } class FakeJsonFeatureProvider extends JsonFeatureProvider { @@ -118,6 +255,24 @@ class FakeJsonFeatureProvider extends JsonFeatureProvider { "newTitle": { "state": "disabled" }, + "object": { + "returnType": "object", + "variants": { + "a": [1, 2], + "b": [2, 3] + }, + "defaultVariant": "a", + "state": "enabled" + }, + "boolean": { + "returnType": "boolean", + "variants": { + "a": true, + "b": false + }, + "defaultVariant": "a", + "state": "enabled" + }, "color": { "returnType": "number", "variants": { From 9a746d5b5ae9614e9ad764dd8a08ae3e9f950c1c Mon Sep 17 00:00:00 2001 From: sarbagyastha Date: Tue, 7 Jun 2022 22:30:21 +0545 Subject: [PATCH 22/22] :heavy_minus_sign: ignored coverage for open feature files --- .../feature_provider/engine/json_evaluation_engine.dart | 2 ++ lib/src/defaults/feature_provider/json_feature_provider.dart | 2 ++ lib/src/open_feature/open_feature.dart | 1 + lib/src/open_feature/src/core/enums/error_code.dart | 2 ++ .../open_feature/src/evaluation_context/evaluation_context.dart | 2 ++ lib/src/open_feature/src/exceptions/open_feature_exception.dart | 2 ++ .../src/exceptions/src/flag_not_found_exception.dart | 2 ++ lib/src/open_feature/src/exceptions/src/parse_exception.dart | 2 ++ .../src/exceptions/src/type_mismatch_exception.dart | 2 ++ lib/src/open_feature/src/hook/hook.dart | 2 ++ lib/src/open_feature/src/hook/hook_context.dart | 2 ++ lib/src/open_feature/src/hook/hook_hints.dart | 2 ++ lib/src/open_feature/src/open_feature_client.dart | 2 ++ lib/src/open_feature/src/provider/no_op_feature_provider.dart | 2 ++ lib/src/widgets/src/feature_scope.dart | 2 ++ test/providers/ui_test.dart | 1 + 16 files changed, 30 insertions(+) diff --git a/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart b/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart index e7cf9eda..3f6a3af3 100644 --- a/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart +++ b/lib/src/defaults/feature_provider/engine/json_evaluation_engine.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'package:clean_framework/src/defaults/feature_provider/engine/evaluation_engine.dart'; import 'package:clean_framework/src/open_feature/open_feature.dart'; diff --git a/lib/src/defaults/feature_provider/json_feature_provider.dart b/lib/src/defaults/feature_provider/json_feature_provider.dart index 0cd75f0c..76fa0e45 100644 --- a/lib/src/defaults/feature_provider/json_feature_provider.dart +++ b/lib/src/defaults/feature_provider/json_feature_provider.dart @@ -17,8 +17,10 @@ class JsonFeatureProvider implements FeatureProvider { final EvaluationEngine _engine; final Completer _flagsCompleter = Completer(); + // coverage:ignore-start @override String get name => 'json'; + // coverage:ignore-end @override Future> resolveBooleanValue({ diff --git a/lib/src/open_feature/open_feature.dart b/lib/src/open_feature/open_feature.dart index 798738b2..f5147107 100644 --- a/lib/src/open_feature/open_feature.dart +++ b/lib/src/open_feature/open_feature.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file library open_feature; import 'src/core/feature_client.dart'; diff --git a/lib/src/open_feature/src/core/enums/error_code.dart b/lib/src/open_feature/src/core/enums/error_code.dart index 02b71a7d..1ccdcf82 100644 --- a/lib/src/open_feature/src/core/enums/error_code.dart +++ b/lib/src/open_feature/src/core/enums/error_code.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + enum ErrorCode { providerNotReady('PROVIDER_NOT_READY'), flagNotFound('FLAG_NOT_FOUND'), diff --git a/lib/src/open_feature/src/evaluation_context/evaluation_context.dart b/lib/src/open_feature/src/evaluation_context/evaluation_context.dart index f9920f57..28e041f3 100644 --- a/lib/src/open_feature/src/evaluation_context/evaluation_context.dart +++ b/lib/src/open_feature/src/evaluation_context/evaluation_context.dart @@ -13,6 +13,7 @@ class EvaluationContext extends MapMixin { final String? targetingKey; final Map _map; + // coverage:ignore-start @override Object? operator [](Object? key) => _map[key]; @@ -29,6 +30,7 @@ class EvaluationContext extends MapMixin { @override Object? remove(Object? key) => _map.remove(key); + // coverage:ignore-end EvaluationContext merge(EvaluationContext? other) { if (other == null) return this; diff --git a/lib/src/open_feature/src/exceptions/open_feature_exception.dart b/lib/src/open_feature/src/exceptions/open_feature_exception.dart index 31d364f5..52cf94f5 100644 --- a/lib/src/open_feature/src/exceptions/open_feature_exception.dart +++ b/lib/src/open_feature/src/exceptions/open_feature_exception.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import '../core/enums/error_code.dart'; export '../core/enums/error_code.dart'; diff --git a/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart b/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart index 0659afaf..5d80fecd 100644 --- a/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart +++ b/lib/src/open_feature/src/exceptions/src/flag_not_found_exception.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import '../open_feature_exception.dart'; class FlagNotFoundException extends OpenFeatureException { diff --git a/lib/src/open_feature/src/exceptions/src/parse_exception.dart b/lib/src/open_feature/src/exceptions/src/parse_exception.dart index d1e22cbe..9d59e6a4 100644 --- a/lib/src/open_feature/src/exceptions/src/parse_exception.dart +++ b/lib/src/open_feature/src/exceptions/src/parse_exception.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import '../open_feature_exception.dart'; class ParseException extends OpenFeatureException { diff --git a/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart b/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart index 8a703787..cfe0b8b6 100644 --- a/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart +++ b/lib/src/open_feature/src/exceptions/src/type_mismatch_exception.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import '../open_feature_exception.dart'; class TypeMismatchException extends OpenFeatureException { diff --git a/lib/src/open_feature/src/hook/hook.dart b/lib/src/open_feature/src/hook/hook.dart index dc7b8895..fb1d3233 100644 --- a/lib/src/open_feature/src/hook/hook.dart +++ b/lib/src/open_feature/src/hook/hook.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'dart:async'; import '../evaluation_context/evaluation_context.dart'; diff --git a/lib/src/open_feature/src/hook/hook_context.dart b/lib/src/open_feature/src/hook/hook_context.dart index dd4831b9..e91810c8 100644 --- a/lib/src/open_feature/src/hook/hook_context.dart +++ b/lib/src/open_feature/src/hook/hook_context.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import '../core/enums/flag_value_type.dart'; import '../core/feature_client.dart'; import '../evaluation_context/evaluation_context.dart'; diff --git a/lib/src/open_feature/src/hook/hook_hints.dart b/lib/src/open_feature/src/hook/hook_hints.dart index e0a3486d..f70f0540 100644 --- a/lib/src/open_feature/src/hook/hook_hints.dart +++ b/lib/src/open_feature/src/hook/hook_hints.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'dart:collection'; class HookHints extends UnmodifiableMapBase { diff --git a/lib/src/open_feature/src/open_feature_client.dart b/lib/src/open_feature/src/open_feature_client.dart index e66ed7cc..25efef39 100644 --- a/lib/src/open_feature/src/open_feature_client.dart +++ b/lib/src/open_feature/src/open_feature_client.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import '../open_feature.dart'; import 'flag_evaluation/flag_evaluation_details.dart'; diff --git a/lib/src/open_feature/src/provider/no_op_feature_provider.dart b/lib/src/open_feature/src/provider/no_op_feature_provider.dart index df11cac3..545116fa 100644 --- a/lib/src/open_feature/src/provider/no_op_feature_provider.dart +++ b/lib/src/open_feature/src/provider/no_op_feature_provider.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import '../core/enums/reason.dart'; import '../core/resolution_details.dart'; import '../evaluation_context/evaluation_context.dart'; diff --git a/lib/src/widgets/src/feature_scope.dart b/lib/src/widgets/src/feature_scope.dart index dca76e99..bf784a54 100644 --- a/lib/src/widgets/src/feature_scope.dart +++ b/lib/src/widgets/src/feature_scope.dart @@ -67,6 +67,8 @@ class _InheritedFeatureScope extends InheritedWidget { final FeatureClient client; + // coverage:ignore-start @override bool updateShouldNotify(_InheritedFeatureScope old) => false; + // coverage:ignore-end } diff --git a/test/providers/ui_test.dart b/test/providers/ui_test.dart index 599bb880..eaae552f 100644 --- a/test/providers/ui_test.dart +++ b/test/providers/ui_test.dart @@ -20,6 +20,7 @@ void main() { 'LastLogin without setup', builder: () => TestUI(), context: ProvidersContext(), + parentBuilder: (child) => Container(child: child), verify: (tester) async { expect(find.byType(type()), findsOneWidget); expect(find.text('bar'), findsOneWidget);