diff --git a/GitVersion.yml b/GitVersion.yml index cacc951..280505f 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,4 +1,4 @@ -next-version: 1.0.6 +next-version: 2.0.0 tag-prefix: '[vV]' mode: ContinuousDeployment branches: diff --git a/Ninja.FeatureOne.sln b/Ninja.FeatureOne.sln index 941ec11..570903b 100644 --- a/Ninja.FeatureOne.sln +++ b/Ninja.FeatureOne.sln @@ -25,6 +25,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeatureOne", "src\FeatureOn EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeatureOne.Tests", "test\FeatureOne.Tests\FeatureOne.Tests.csproj", "{F51B62E7-1A8F-41DC-B027-074932A01D33}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FeatureOne.SQL", "src\FeatureOne.SQL\FeatureOne.SQL.csproj", "{FED48C4A-1EEC-491C-8F5D-886763DF9388}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FeatureOne.SQL.Tests", "test\FeatureOne.SQL.Tests\FeatureOne.SQL.Tests.csproj", "{A6102616-CA8C-4C7F-AF16-C8F451E6D62D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +43,14 @@ Global {F51B62E7-1A8F-41DC-B027-074932A01D33}.Debug|Any CPU.Build.0 = Debug|Any CPU {F51B62E7-1A8F-41DC-B027-074932A01D33}.Release|Any CPU.ActiveCfg = Release|Any CPU {F51B62E7-1A8F-41DC-B027-074932A01D33}.Release|Any CPU.Build.0 = Release|Any CPU + {FED48C4A-1EEC-491C-8F5D-886763DF9388}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FED48C4A-1EEC-491C-8F5D-886763DF9388}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FED48C4A-1EEC-491C-8F5D-886763DF9388}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FED48C4A-1EEC-491C-8F5D-886763DF9388}.Release|Any CPU.Build.0 = Release|Any CPU + {A6102616-CA8C-4C7F-AF16-C8F451E6D62D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6102616-CA8C-4C7F-AF16-C8F451E6D62D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6102616-CA8C-4C7F-AF16-C8F451E6D62D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6102616-CA8C-4C7F-AF16-C8F451E6D62D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -47,6 +59,8 @@ Global {FB8FCDD0-A3D4-4776-85E0-C47ECFB3D310} = {9930EAFC-568F-4676-82AB-2B8F9D7535B3} {65BD1F94-CEF4-4CC1-8482-3FF72BBF620F} = {B5EFC87D-E5C5-488E-AA89-06D26027E4C7} {F51B62E7-1A8F-41DC-B027-074932A01D33} = {E864826B-B5D2-4DAD-B53A-2A3976226B53} + {FED48C4A-1EEC-491C-8F5D-886763DF9388} = {B5EFC87D-E5C5-488E-AA89-06D26027E4C7} + {A6102616-CA8C-4C7F-AF16-C8F451E6D62D} = {E864826B-B5D2-4DAD-B53A-2A3976226B53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {745CC402-145C-4E31-BAB7-DA93F8292512} diff --git a/README.md b/README.md index 58d2d34..88f9ef5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ -# ninja FeatureOne v1.0.7 +# ninja FeatureOne v2.0.0 [![NuGet version](https://badge.fury.io/nu/FeatureOne.svg)](https://badge.fury.io/nu/FeatureOne) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/NinjaRocks/FeatureOne/blob/master/License.md) [![CI](https://github.com/NinjaRocks/FeatureOne/actions/workflows/CI-Build.yml/badge.svg)](https://github.com/NinjaRocks/FeatureOne/actions/workflows/CI-Build.yml) [![GitHub Release](https://img.shields.io/github/v/release/ninjarocks/FeatureOne?logo=github&sort=semver)](https://github.com/ninjarocks/FeatureOne/releases/latest) [![CodeQL](https://github.com/NinjaRocks/FeatureOne/actions/workflows/codeql.yml/badge.svg)](https://github.com/NinjaRocks/FeatureOne/actions/workflows/codeql.yml) [![.Net Stardard](https://img.shields.io/badge/.Net%20Standard-2.1-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/2.1) .Net Library to implement feature toggles. -- - +> #### Nuget Packages +> --- +> `FeatureOne` - Provides core funtionality to implement feature toggles with `no` backend storage provider. Needs package consumer to provide `IStorageProvider` implementation. Ideal for use case that requires custom storage backend. Please see below for more details. +> +>`FeatureOne.SQL` - Provides FeatureOne funtionality to implement feature toggles using `SQL` Storage backend. ### What is a feature toggle? > Feature toggle is a mechanism that allows code to be turned “on” or “off” remotely without the need for a deploy. Feature toggles are commonly used in applications to gradually roll out new features, allowing teams to test changes on a small subset of users before releasing them to everyone. @@ -25,7 +29,7 @@ Add logical check in codebase to wrap the functionality under a `feature toggle` ``` var featureName = "dashboard_widget"; // Name of functionality or feature to toggle. - if(Features.Current.IsEnable(featureName, claimsPrincipal){ // See other IsEnable() overloads + if(Features.Current.IsEnable(featureName){ // See other IsEnable() overloads showDashboardWidget(); } ``` @@ -36,14 +40,15 @@ Add a `toggle` definition to storage ie. a store in database or file or other st A toggle constitutes a collection of `conditions` that evaluate separately when the toggle is run. You can additionally specify an `operator` in the toggle definition to determine the overall success to include success of `any` constituent condition or success of `all` consituent conditions. > Toggles run at runtime based on consitituent conditions that evaluate separately against user claims (generally logged in user principal). -JSON Syntax for Feature Toggle is below +Below is a serialized JSON representation of a Feature Toggle. ``` { "feature_name":{ -- Feature name - "toggle":{ -- Toggle details for the feature + "toggle":{ -- Toggle definition for the feature - "operator":"any|all", -- Evaluate overall toggle to true - -- when `any` condition is met or `all` conditions are met. + "operator":"any|all", -- Logical Operator - any (OR) & all (AND) + -- ie. Evaluate overall toggle to true when `any` condition is met or + -- `all` conditions are met. "conditions":[{ -- collection of conditions "type":"simple|regex" -- type of condition @@ -54,6 +59,7 @@ JSON Syntax for Feature Toggle is below } } ``` + ### Condition Types There are two types of toggle conditions that can be used out of box. @@ -64,7 +70,7 @@ Below is the serialized representation of toggle with simple condition. ``` { "dashboard_widget":{ - "toggle"{ + "toggle":{ "conditions":[{ "type":"Simple", -- Simple Condition. "isEnabled":true|false -- Enabled or disable the feature. @@ -73,57 +79,91 @@ Below is the serialized representation of toggle with simple condition. } } ``` +C# representation of a feature with simple toggle is +``` +var feature = new Feature +{ + Name ="dashboard_widget", // Feature Name + Toggle = new Toggle // Toggle definition + { + // Logical operator to be applied when evaluating consituent conditions. + Operator = Operator.Any, // Default is Any (Logical OR) + + Conditions = new[] + { + // Simple condition that can be set to true/false for feature to be enabled/disabled. + new SimpleCondition { IsEnabled = true } + } + } +} +``` #### ii. Regex Condition -`Regex` condition allows evaluating a regex expression against specified user claim to enable a given feature. +`Regex` condition allows evaluating a regex expression against specified user claim value to enable a given feature. Below is the serialized representation of toggle with regex condition. ``` { "dashboard_widget":{ - "toggle"{ + "toggle":{ "conditions":[{ "type":"Regex", -- Regex Condition "claim":"email", -- Claim 'email' to be used for evaluation. - "expression":"*@gbk.com" -- Regex expression to be used for evaulation. + "expression":"*@gbk.com" -- Regex expression to be used for evaluation. }] } } } ``` - -### Step 3. Provide Storage Provider Implementation. -To use FeatureOne, you need to provide implementation of `Storage Provider` to get all the feature toggles from storage medium of choice. -Implement `IStorageProvider` interface to get configured feature toggles from storage. -The interface has `Get()` method that returns a collection of `KeyValuePair` with `key` mapping to `featureName` and `value` mapping to json string representation of the `toggle` +C# representation of a feature with regex toggle is ``` - /// - /// Interface to implement storage provider. - /// - public interface IStorageProvider + +var feature = new Feature +{ + Name ="dashboard_widget", // Feature Name + Toggle = new Toggle // Toggle definition + { + Operator = Operator.Any, + Conditions = new[] { + // Regex condition that evalues role of user to be administrator to enable the feature. + new RegexCondition { Claim = "role", Expression = "administrator" } + } + } +} +``` + +### Step 3. Implement Storage Provider. +To use FeatureOne, you need to provide implementation for `Storage Provider` to get all the feature toggles from storage medium of choice. +Implement `IStorageProvider` interface to return feature toggles from storage. +The interface has `GetByName()` method that returns an array of `IFeature` +``` + /// + /// Interface to implement storage provider. + /// + public interface IStorageProvider + { /// - /// Implement this method to get all feature toggles from storage. + /// Implement to get storage feature toggles by a given name. /// - /// - /// Example: - /// Key - "dashboard_widget" - /// Value - "{\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": true}]}" - /// - /// KeyValuePair Array - IEnumerable> Get(); - } + /// Array of Features + IFeature[] GetByName(string name); + } ``` +A production storage provider should be an implementation with `API` , `SQL` or `File system` storage backend. + +An implementation option is to store features as serialized json to backend medium. Ideally, you may also want to use `caching` in the production implementation to optimise calls to the storage backend. + + Below is an example of dummy provider implementation. -> A production provider should be an implementation with `API` , `SQL` or `File system` storage backend. Ideally, you may also want to use `caching` of feature toggles in the production implementation to optimise calls to the storage medium. ``` public class CustomStoreProvider : IStorageProvider { - public IEnumerable> Get() + public Feature[] GetByName(string name) { return new[] { - new KeyValuePair("feature-01", "{\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": true}]}"), - new KeyValuePair("feature-02", "{\"operator\":\"all\",\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": false}, {\"type\":\"RegexCondition\",\"claim\":\"email\",\"expression\":\"*@gbk.com\"}]}") + new Feature("feature-01",new Toggle(Operator.Any, new[]{ new SimpleCondition{IsEnabled=true}})), + new Feature("feature-02",new Toggle(Operator.All, new SimpleCondition { IsEnabled = false }, new RegexCondition{Claim="email", Expression= "*@gbk.com" })) }; } } @@ -134,27 +174,18 @@ In bootstrap code, initialize the `Features` class with dependencies as shown be i. With `storage provider` implementation. ``` - var storageProvider = new SQlStorageProviderImpl(); + var storageProvider = new CustomStorageProviderImpl(); Features.Initialize(() => new Features(new FeatureStore(storageProvider))); ``` ii. With `storage provider` and `logger` implementations. ``` var logger = new CustomLoggerImpl(); - var storageProvider = new SQlStorageProviderImpl(); + var storageProvider = new CustomStorageProviderImpl(); Features.Initialize(() => new Features(new FeatureStore(storageProvider, logger), logger)); ``` -iii. With `storage provider`, `logger` and custom `toggle deserializer` implementations. -``` - var logger = new CustomLoggerImpl(); - var storageProvider = new SQlStorageProviderImpl(); - var toggleDeserializer = new CustomToggleDeserializerImpl(); - - Features.Initialize(() => new Features(new FeatureStore(storageProvider, logger, toggleDeserializer), logger)); -``` - How to Extend FeatureOne -- @@ -175,39 +206,59 @@ The interface provides `evaluate()` method that returns a boolean result of eval bool Evaluate(IDictionary claims); } ``` -`Please Note` The condition class should only include `primitive` data type `properties` for default deserialization. If you need to implement a much complex toggle condition with non-primitive properties then also provide custom implementation of `IToggleDeserializer` to support its deserialization along with other conditions. - Example below shows sample implementation of a custom condition. ``` // toggle condition to show feature after given hour during the day. public class TimeCondition : ICondition { - public int Hour {get; set;} = 12; // Primitive int property. + public int Hour {get; set;} = 12; - bool Evaluate(IDictionary claims) + public bool Evaluate(IDictionary claims) { return (DateTime.Now.Hour > Hour); } } ``` - Example usage of above condition in toggle to allow non-admin users access to a feature only after 14 hrs. + Example usage of above condition in toggle to allow non-admin users access to a feature only after 12 hrs. + C# representation of the feature is + +``` +var feature = new Feature +{ + Name ="feature_pen_test", // Feature Name + Toggle = new Toggle // Toggle definition + { + Operator = Operator.Any, // Enabled when one of below conditions are true. + Conditions = new[] + { + // Custom condition - allow access after 12 o'clock + new TimeCondition { Hour = 12 }, + // Regex condition for allowing admin users by role claim. + new RegexCondition { Claim = "role", Expression = "^administrator$"} + } + } +} +``` +JSON Serialized representation is ``` { + "feature_pen_test":{ + "toggle":{ "operator":"any", -- Any below condition evaluation to true should succeed the toggle. "conditions":[{ - "type":"Time", -- Time condition to all access to all after 14hrs + "type":"Time", -- Time condition to allow access after 12 o'clock. "Hour":14 }, { "type":"Regex", -- Regex to allow admin access - "claim":"user_role", + "claim":"role", "expression":"^administrator$" }] + } } ``` - ### ii. Logger You could optionally provide an implementation of a logger by wrapping your favourite logging libaray under `IFeatureLogger` interface. Please see the interface definition below. @@ -243,6 +294,91 @@ Please see the interface definition below. void Warn(string message); } ``` +## FeatureOne.SQL - Feature toggles with SQL Backend. +In addition to all above offerings, the FeatureOne.SQL package provides out of box SQL storage provider. + +SQL support can easily be installed as a separate nuget package. +``` +$ dotnet add package FeatureOne.SQL --version 2.0.0 +``` +Supports Db Providers `MSSQL: System.Data.SqlClient`, `ODBC: System.Data.Odbc`, `OLEDB: System.Data.OleDb`, `SQLite: System.Data.SQLite`, `MySQL: MySql.Data.MySqlClient` & `PostgreSQL: Npgsql`. + + + +For any other SQL provider, You need to add provider factory to `DbProviderFactories.RegisterFactory("ProviderName", ProviderFactory)` and pass the provider specific `connection settings` in SQLConfiguration. +### Database Setup +> Requires creating a feature table with columns for feature name, toggle definition and feature archival. + +SQL SCRIPT below. +``` +CREATE TABLE TFeatures ( + Id INT NOT NULL IDENTITY PRIMARY KEY, + Name VARCHAR(255) NOT NULL, + Toggle NVARCHAR(4000) NOT NULL, + Archived BIT CONSTRAINT DF_TFeatures_Archived DEFAULT (0) +); +``` + +### Example Table Record +> Feature toggles need to be `scripted` to backend database in JSON format. + +Please see example entries below. + +| Name |Toggle | Archived | +|||| +| dashboard_widget |{ "conditions":[{ "type":"Simple", "isEnabled": true }] } | false | +|pen_test_dashboard| { "operator":"any", "conditions":[{ "type":"simple", "isEnabled":false}, { "type":"Regex", "claim":"email","expression":"^[a-zA-Z0-9_.+-]+@gbk.com" }]} | false| + +`Please Note` Any custom condition implementation should only include `primitive type` properties to work with `default` ICondition `deserialization`. When you need to implement a much complex toggle condition with `non-primitive` properties then you need to provide `custom` implementation of `IConditionDeserializer` to support its deserialization to toggle condition object. + +### Bootstrap initialization +> See below bootstrap initialization for FeatureOne with SQL backend. +#### SQL Configuration - Set connection string and other settings. +``` + var sqlConfiguration = new SQLConfiguration + { + ConnectionSettings = new ConnectionSettings + { + Providername = DbProviderName.MSSql, // provider Name for DbFactory registration. + ConnectionString ="Data Source=Powerstation; Initial Catalog=Features; Integrated Security=SSPI;" + }, + FeatureTable = new FeatureTable + { + TableName = "[Features].[dbo].[TFeatures]", // Table and column name overrides. + NameColumn = "[Name]", + ToggleColumn = "[Toggle]", + ArchivedColumn = "[Archived]" + }, + CacheSettings = new CacheSettings + { + EnableCache = true, // Enable cache with absolute expiry in Minutes. + ExpiryInMinutes = 60 + } + } +``` +i. With SQL configuration. +``` + var storageProvider = new SQlStorageProvider(sqlConfiguration); + Features.Initialize(() => new Features(new FeatureStore(storageProvider))); +``` +ii. With Custom logger implementation, default is no logger. +``` + var logger = new CustomLoggerImpl(); + var storageProvider = new SQlStorageProvider(sqlConfiguration, logger); + + Features.Initialize(() => new Features(new FeatureStore(storageProvider, logger), logger)); +``` + +iii. With other overloads - Custom cache and Toggle Condition deserializer. +``` + var toggleConditionDeserializer = CustomConditionDeserializerImpl(); // Implements IConditionDeserializer + var featureCache = CustomFeatureCache(); // Implements ICache + + var storageProvider = new SQlStorageProvider(sqlConfiguration, logger, featureCache, toggleConditionDeserializer); + + Features.Initialize(() => new Features(new FeatureStore(storageProvider, logger), logger)); +``` + Credits -- Thank you for reading. Please fork, explore, contribute and report. Happy Coding !! :) diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..88b63ad --- /dev/null +++ b/_config.yml @@ -0,0 +1 @@ +markdown: GFM diff --git a/src/FeatureOne/Core/Stores/ConditionFactory.cs b/src/FeatureOne.SQL/ConditionDeserializer.cs similarity index 79% rename from src/FeatureOne/Core/Stores/ConditionFactory.cs rename to src/FeatureOne.SQL/ConditionDeserializer.cs index c0aca26..596fc4c 100644 --- a/src/FeatureOne/Core/Stores/ConditionFactory.cs +++ b/src/FeatureOne.SQL/ConditionDeserializer.cs @@ -5,10 +5,11 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; +using FeatureOne.Core; -namespace FeatureOne.Core.Stores +namespace FeatureOne.SQL { - public static class ConditionFactory + internal class ConditionDeserializer : IConditionDeserializer { private static Type[] loaddedTypes; @@ -17,32 +18,36 @@ private static Type[] LoaddedTypes get { if (loaddedTypes == null || loaddedTypes.Length == 0) - loaddedTypes = Assembly.GetExecutingAssembly().GetTypes(); + loaddedTypes = AppDomain.CurrentDomain.GetAssemblies().SelectMany(x => x.GetTypes()) + .Where(p => typeof(ICondition).IsAssignableFrom(p)).ToArray(); return loaddedTypes; } } - public static ICondition Create(JsonObject JsonObject) + public ICondition Deserialize(JsonObject condition) { - if (JsonObject == null) - throw new ArgumentNullException(nameof(JsonObject)); + if (condition == null) + throw new ArgumentNullException(nameof(condition)); - var typeName = JsonObject?["type"]?.ToString(); + var typeName = condition?["type"]?.ToString(); var toggle = CreateInstance(new NamePostFix(typeName, "Condition")); - HydrateToggle(toggle, JsonObject); + HydrateToggle(toggle, condition); return toggle; } - public static ICondition[] Create(JsonObject[] conditions) + private static ICondition CreateInstance(NamePostFix conditionName) { - if (conditions == null) - throw new ArgumentNullException(nameof(conditions)); + var type = LoaddedTypes + .FirstOrDefault(p => typeof(ICondition).IsAssignableFrom(p) && p.Name.Equals(conditionName.Name, StringComparison.OrdinalIgnoreCase)); - return conditions.Select(s => Create(s)).ToArray(); + if (type == null) + throw new Exception($"Could not find a toggle type for: '{conditionName.Name}'"); + + return (ICondition)Activator.CreateInstance(type, true); } private static void HydrateToggle(ICondition toggleCondition, JsonObject state) @@ -75,16 +80,5 @@ private static PropertyInfo[] GetProperties(ICondition condition) return propertyInfos; } - - private static ICondition CreateInstance(NamePostFix conditionName) - { - var type = LoaddedTypes - .FirstOrDefault(p => typeof(ICondition).IsAssignableFrom(p) && p.Name.Equals(conditionName.Name, StringComparison.OrdinalIgnoreCase)); - - if (type == null) - throw new Exception($"Could not find a toggle type for: '{conditionName.Name}'"); - - return (ICondition)Activator.CreateInstance(type, true); - } } } \ No newline at end of file diff --git a/src/FeatureOne.SQL/DbRecord.cs b/src/FeatureOne.SQL/DbRecord.cs new file mode 100644 index 0000000..c6a64f3 --- /dev/null +++ b/src/FeatureOne.SQL/DbRecord.cs @@ -0,0 +1,8 @@ +namespace FeatureOne.SQL +{ + public class DbRecord + { + public string Name { get; set; } + public string Toggle { get; set; } + } +} \ No newline at end of file diff --git a/src/FeatureOne.SQL/FeatureCache.cs b/src/FeatureOne.SQL/FeatureCache.cs new file mode 100644 index 0000000..aa07ee0 --- /dev/null +++ b/src/FeatureOne.SQL/FeatureCache.cs @@ -0,0 +1,13 @@ +using System; +using System.Runtime.Caching; + +namespace FeatureOne.SQL +{ + internal class FeatureCache : ICache + { + public void Add(string key, object value, int expiry) + => MemoryCache.Default.Add(key, value, new CacheItemPolicy { SlidingExpiration = TimeSpan.FromMinutes(expiry) }); + + public object Get(string key) => MemoryCache.Default.Get(key); + } +} \ No newline at end of file diff --git a/src/FeatureOne.SQL/FeatureOne.SQL.csproj b/src/FeatureOne.SQL/FeatureOne.SQL.csproj new file mode 100644 index 0000000..cce9617 --- /dev/null +++ b/src/FeatureOne.SQL/FeatureOne.SQL.csproj @@ -0,0 +1,73 @@ + + + + netstandard2.1 + disable + disable + True + False + AssemblyInfo.cs + true + true + FeatureOne.SQL + FeatureOne.SQL + False + snupkg + FeatureOne.SQL + ninja.shayk + Ninja.Sha!4H + FeatureOne + .Net library to implement feature toggles with SQL backend. + Copyright (c) 2023 Ninja Sha!4h + README.md + https://github.com/NinjaRocks/FeatureOne + git + feature-toggle; feature-flag; feature-flags; feature-toggles; .netstandard2.1; featureOne; SQL-Backend; SQL-Toggles; SQL + 2.0.0 + License.md + ninja-icon-16.png + + Release Notes v2.0.0. - SQL Storage Provider + Core Functionality :- + Added support for SQL storage provider for Feature Toggle. + - Supports MSSQL, SQLite, ODBC, OLEDB, MySQL, PostgreSQL Db providers. + - Provides memory caching - enabled via configuration. + - Added extensibility for Custom implementations- + - Provides extension point to support more SQL providers. + - Provides extenion point for custom caching. + - Provides extension point for custom deserializer for Toggle Conditions. + + + + + + True + \ + + + True + \ + + + True + \ + + + + + + + + + + + + + + + + + + + + diff --git a/src/FeatureOne.SQL/ICache.cs b/src/FeatureOne.SQL/ICache.cs new file mode 100644 index 0000000..8119e31 --- /dev/null +++ b/src/FeatureOne.SQL/ICache.cs @@ -0,0 +1,20 @@ +namespace FeatureOne.SQL +{ + public interface ICache + { + /// + /// Add item to cache + /// + /// + /// + /// + void Add(string key, object value, int expiry); + + /// + /// Get item from cache + /// + /// + /// + object Get(string key); + } +} \ No newline at end of file diff --git a/src/FeatureOne.SQL/IConditionDeserializer.cs b/src/FeatureOne.SQL/IConditionDeserializer.cs new file mode 100644 index 0000000..f612504 --- /dev/null +++ b/src/FeatureOne.SQL/IConditionDeserializer.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Nodes; +using FeatureOne.Core; + +namespace FeatureOne.SQL +{ + /// + /// Implement to provide deserialization strategy of condition types. + /// + public interface IConditionDeserializer + { + ICondition Deserialize(JsonObject condition); + } +} \ No newline at end of file diff --git a/src/FeatureOne.SQL/IDbRepository.cs b/src/FeatureOne.SQL/IDbRepository.cs new file mode 100644 index 0000000..0d0b8f9 --- /dev/null +++ b/src/FeatureOne.SQL/IDbRepository.cs @@ -0,0 +1,7 @@ +namespace FeatureOne.SQL +{ + public interface IDbRepository + { + DbRecord[] GetByName(string name); + } +} \ No newline at end of file diff --git a/src/FeatureOne.SQL/IToggleDeserializer.cs b/src/FeatureOne.SQL/IToggleDeserializer.cs new file mode 100644 index 0000000..c6cc118 --- /dev/null +++ b/src/FeatureOne.SQL/IToggleDeserializer.cs @@ -0,0 +1,9 @@ +using FeatureOne.Core; + +namespace FeatureOne.SQL +{ + public interface IToggleDeserializer + { + IToggle Deserialize(string toggle); + } +} \ No newline at end of file diff --git a/src/FeatureOne/Core/NamePostFix.cs b/src/FeatureOne.SQL/NamePostFix.cs similarity index 94% rename from src/FeatureOne/Core/NamePostFix.cs rename to src/FeatureOne.SQL/NamePostFix.cs index 4dab788..43e500f 100644 --- a/src/FeatureOne/Core/NamePostFix.cs +++ b/src/FeatureOne.SQL/NamePostFix.cs @@ -1,6 +1,6 @@ using System; -namespace FeatureOne.Core +namespace FeatureOne.SQL { public class NamePostFix { diff --git a/src/FeatureOne.SQL/SQLConfiguration.cs b/src/FeatureOne.SQL/SQLConfiguration.cs new file mode 100644 index 0000000..ea12d3e --- /dev/null +++ b/src/FeatureOne.SQL/SQLConfiguration.cs @@ -0,0 +1,127 @@ +namespace FeatureOne.SQL +{ + public class SQLConfiguration + { + public SQLConfiguration() + { + FeatureTable = new FeatureTable(); + CacheSettings = new CacheSettings(); + ConnectionSettings = new ConnectionSettings(); + } + + /// + /// Database settings - Connection string and provider name. + /// + public ConnectionSettings ConnectionSettings { get; set; } + + /// + /// Feature table name and column aliases + /// + public FeatureTable FeatureTable { get; set; } + + /// + /// Feature cache settings. + /// + public CacheSettings CacheSettings { get; set; } + } + + public class ConnectionSettings + { + /// + /// Connection string to feature database. + /// + public string ConnectionString { get; set; } + + /// + /// Provider name for connection factory. Please see DbProviderName class for supported constants. + /// + /// + /// Supported providers: + /// System.Data.SqlClient + /// System.Data.Odbc + /// System.Data.OleDb + /// System.Data.SQLite + /// MySql.Data.MySqlClient + /// Npgsql + /// + public string ProviderName { get; set; } = DbProviderName.MSSql; + } + + /// + /// Provider name for connection factories. + /// + public class DbProviderName + { + /// + /// Provider - System.Data.SqlClient + /// + public const string MSSql = "System.Data.SqlClient"; + + /// + /// Provider - System.Data.Odbc + /// + public const string Odbc = "System.Data.Odbc"; + + /// + /// Provider - System.Data.OleDb + /// + public const string OleDb = "System.Data.OleDb"; + + /// + /// Provider - System.Data.SqlClient + /// + public const string SQLite = "System.Data.SQLite"; + + /// + /// Provider - MySql.Data.MySqlClient + /// + public const string MySql = "MySql.Data.MySqlClient"; + + /// + /// Provider - Npgsql + /// + public const string PostgresSql = "Npgsql"; + } + + /// + /// Feature table settings to override table aliases. + /// + public class FeatureTable + { + /// + /// Table Name for the feature table. + /// + public string TableName { get; set; } = "TFeatures"; + + /// + /// Feature name column name. + /// + public string NameColumn { get; set; } = "Name"; + + /// + /// Feature toggle column name. + /// + public string ToggleColumn { get; set; } = "Toggle"; + + /// + /// Feature archived column name. + /// + public string ArchivedColumn { get; set; } = "Archived"; + } + + /// + /// Cache settings for storage provider. + /// + public class CacheSettings + { + /// + /// Use cache when true, default is false for no cache. + /// + public bool EnableCache { get; set; } = false; + + /// + /// Cache item expiry value in minutes. + /// + public int ExpiryInMinutes { get; set; } = 60; + } +} \ No newline at end of file diff --git a/src/FeatureOne.SQL/StorageProvider/DbRepository.cs b/src/FeatureOne.SQL/StorageProvider/DbRepository.cs new file mode 100644 index 0000000..70c4e0b --- /dev/null +++ b/src/FeatureOne.SQL/StorageProvider/DbRepository.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Data.Odbc; +using System.Data.OleDb; +using System.Data.SqlClient; +using System.Data.SQLite; +using System.Linq; +using MySql.Data.MySqlClient; +using Npgsql; + +namespace FeatureOne.SQL.StorageProvider +{ + internal class DbRepository : IDbRepository + { + private readonly SQLConfiguration sqlConfiguration; + private readonly IFeatureLogger logger; + + public DbRepository(SQLConfiguration sqlConfiguration, IFeatureLogger logger) + { + this.sqlConfiguration = sqlConfiguration ?? throw new ArgumentNullException(nameof(SQLConfiguration)); + this.logger = logger; + + var names = DbProviderFactories.GetProviderInvariantNames(); + + if (!names.Any(x => x.Equals("System.Data.SqlClient") && SqlClientFactory.Instance != null)) + DbProviderFactories.RegisterFactory("System.Data.SqlClient", SqlClientFactory.Instance); + + if (!names.Any(x => x.Equals("System.Data.Odbc")) && OdbcFactory.Instance != null) + DbProviderFactories.RegisterFactory("System.Data.Odbc", OdbcFactory.Instance); + + if (!names.Any(x => x.Equals("System.Data.OleDb")) && OleDbFactory.Instance != null) + DbProviderFactories.RegisterFactory("System.Data.OleDb", OleDbFactory.Instance); + + if (!names.Any(x => x.Equals("System.Data.SQLite")) && SQLiteFactory.Instance != null) + DbProviderFactories.RegisterFactory("System.Data.SQLite", SQLiteFactory.Instance); + + if (!names.Any(x => x.Equals("MySql.Data.MySqlClient")) && MySqlClientFactory.Instance != null) + DbProviderFactories.RegisterFactory("MySql.Data.MySqlClient", MySqlClientFactory.Instance); + + if (!names.Any(x => x.Equals("Npgsql")) && NpgsqlFactory.Instance != null) + DbProviderFactories.RegisterFactory("Npgsql", NpgsqlFactory.Instance); + } + + public DbRecord[] GetByName(string name) + { + var dbRecords = new List(); + try + { + var factory = DbProviderFactories.GetFactory(sqlConfiguration.ConnectionSettings.ProviderName); + + if (factory == null) + throw new InvalidOperationException($"Provider: {sqlConfiguration.ConnectionSettings.ProviderName} is not supported. Please register entry in DbProviderFactories "); + + using (var connection = factory.CreateConnection()) + { + connection.ConnectionString = sqlConfiguration.ConnectionSettings.ConnectionString; + + connection.Open(); + try + { + using (var command = connection.CreateCommand()) + { + command.CommandType = CommandType.Text; + command.CommandText = sqlConfiguration.FeatureTable.CreateSQL(name); + + var reader = command.ExecuteReader(); + while (reader.Read()) + dbRecords.Add(new DbRecord { Name = reader.GetString(0), Toggle = reader.GetString(1) }); + + reader.Close(); + } + } + catch (Exception e) + { + logger?.Error($"FeatureOne.SQL, Action='Repository.GetByName()', Exception='{e}'."); + } + finally + { + connection.Close(); + } + } + + logger?.Error($"FeatureOne.SQL, Action='Repository.GetByName()', Success=Feature Count'{dbRecords.Count}'."); + + return dbRecords.ToArray(); + } + catch (Exception ex) + { + logger?.Error($"FeatureOne.SQL, Action='Repository.GetByName()', Exception='{ex}'."); + } + + return Array.Empty(); + } + } +} \ No newline at end of file diff --git a/src/FeatureOne.SQL/StorageProvider/SQLStatement.cs b/src/FeatureOne.SQL/StorageProvider/SQLStatement.cs new file mode 100644 index 0000000..2b319fc --- /dev/null +++ b/src/FeatureOne.SQL/StorageProvider/SQLStatement.cs @@ -0,0 +1,10 @@ +namespace FeatureOne.SQL.StorageProvider +{ + public static class SQLStatement + { + public static string CreateSQL(this FeatureTable table, string featureName) + { + return $"Select {table.NameColumn}, {table.ToggleColumn} From {table.TableName} Where {table.ArchivedColumn} = 0 and {table.NameColumn} = '{featureName}'"; + } + } +} \ No newline at end of file diff --git a/src/FeatureOne.SQL/StorageProvider/SQLStorageProvider.cs b/src/FeatureOne.SQL/StorageProvider/SQLStorageProvider.cs new file mode 100644 index 0000000..9cc4493 --- /dev/null +++ b/src/FeatureOne.SQL/StorageProvider/SQLStorageProvider.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using FeatureOne.Core; +using FeatureOne.Core.Stores; + +namespace FeatureOne.SQL.StorageProvider +{ + public class SQLStorageProvider : IStorageProvider + { + internal IDbRepository repository; + internal IToggleDeserializer deserializer; + internal ICache cache; + + internal CacheSettings cacheSettings; + + public SQLStorageProvider(SQLConfiguration sqlConfiguration, IFeatureLogger logger = null, ICache cache = null, IConditionDeserializer conditionDeserializer = null) + { + this.cacheSettings = sqlConfiguration?.CacheSettings ?? new CacheSettings(); + this.repository = new DbRepository(sqlConfiguration, logger ?? new NullLogger()); + this.deserializer = new ToggleDeserializer(conditionDeserializer ?? new ConditionDeserializer()); + this.cache = cache ?? new FeatureCache(); + } + + public SQLStorageProvider(IDbRepository repository, IToggleDeserializer deserializer, ICache cache, CacheSettings cacheSettings) + { + this.repository = repository; + this.deserializer = deserializer; + this.cache = cache; + this.cacheSettings = cacheSettings ?? new CacheSettings(); + } + + public IFeature[] GetByName(string name) + { + DbRecord[] dbFeatures = null; + + if (cacheSettings.EnableCache) + dbFeatures = (DbRecord[])cache?.Get(name); + + if (dbFeatures == null) + dbFeatures = repository.GetByName(name); + + if (cacheSettings.EnableCache) + cache.Add(name, dbFeatures, cacheSettings.ExpiryInMinutes); + + return dbFeatures != null && dbFeatures.Any() + ? dbFeatures.Where(x => !string.IsNullOrEmpty(x.Toggle)).Select(f => new Feature(f.Name, deserializer.Deserialize(f.Toggle))).ToArray() + : Array.Empty(); + } + } +} \ No newline at end of file diff --git a/src/FeatureOne.SQL/ToggleDeserializer.cs b/src/FeatureOne.SQL/ToggleDeserializer.cs new file mode 100644 index 0000000..0d156cd --- /dev/null +++ b/src/FeatureOne.SQL/ToggleDeserializer.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using FeatureOne.Core; + +[assembly: InternalsVisibleTo("FeatureOne.SQL.Tests")] + +namespace FeatureOne.SQL +{ + internal class ToggleDeserializer : IToggleDeserializer + { + private readonly IConditionDeserializer conditionDeserializer; + + public ToggleDeserializer() : this(new ConditionDeserializer()) + { + } + + public ToggleDeserializer(IConditionDeserializer conditionDeserializer) => + this.conditionDeserializer = conditionDeserializer; + + public IToggle Deserialize(string toggle) + { + var jObject = JsonNode.Parse(toggle); + + var toggleOperator = jObject["operator"]?.ToString() ?? Operator.Any.ToString(); + var toggleConditions = jObject["conditions"].Deserialize(); + + return new Toggle + ( + Enum.TryParse(toggleOperator, true, out var @operator) ? @operator : Operator.Any, + toggleConditions.Select(t => conditionDeserializer.Deserialize(t)).ToArray() + ); + } + } +} \ No newline at end of file diff --git a/src/FeatureOne/AssemblyInfo.cs b/src/FeatureOne/AssemblyInfo.cs index b1cd85e..7be6fd1 100644 --- a/src/FeatureOne/AssemblyInfo.cs +++ b/src/FeatureOne/AssemblyInfo.cs @@ -15,11 +15,11 @@ [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyCopyrightAttribute("2023")] [assembly: System.Reflection.AssemblyDescriptionAttribute(".Net Library to implement feature toggles.")] -[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.7.0")] +[assembly: System.Reflection.AssemblyFileVersionAttribute("2.0.0.0")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.7")] [assembly: System.Reflection.AssemblyProductAttribute("FeatureOne")] [assembly: System.Reflection.AssemblyTitleAttribute("FeatureOne")] -[assembly: System.Reflection.AssemblyVersionAttribute("1.0.7.0")] +[assembly: System.Reflection.AssemblyVersionAttribute("2.0.0.0")] [assembly: System.Reflection.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/NinjaRocks/FeatureOne")] // Generated by the MSBuild WriteCodeFragment class. diff --git a/src/FeatureOne/ClassDiagram.cd b/src/FeatureOne/ClassDiagram.cd index b17c4b9..f44c6c1 100644 --- a/src/FeatureOne/ClassDiagram.cd +++ b/src/FeatureOne/ClassDiagram.cd @@ -9,6 +9,7 @@ + @@ -24,7 +25,7 @@ AAAAIAAAAAAAAAAAAAAAAgAAAAAAAAAQAAAAAAAAAAA= Core\Toggles\Conditions\RegexCondition.cs - + @@ -38,68 +39,31 @@ - + - AgAAAAAAAAAAAQAAAAGAAAAAAAAAAAAAgAAAAAAAAAA= + AgAAAAAAAAAAAQACAAGAAAAAAAAAAAAAAAAAAAAAAAA= Features.cs - + - - - - - - - - - - - - - - - - + + + + + - EAAAAAAAAAAAAQAAAAAAAAAAAAAgAAAAggAAAAAAAAA= + AAAAAAAAAAAAAAACAAAAAAAAAAAgAAAAAAAAAAACAAA= Core\Stores\FeatureStore.cs - - - - - - - - - - - - - - - - - AAAAAAAAAAIAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAA= - Configuration.cs - - - + - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA= - Core\Stores\ToggleDeserializer.cs - + @@ -123,32 +87,25 @@ - + - AAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAgAAAAAAAAA= + AAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAA= IFeatureStore.cs - - - - AAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Core\Stores\IStoreProvider.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA= - Core\Stores\IToggleDeserializer.cs - - - + AAAAAAAAAAAAAYAAAAAAAAAAAAAAAAEAAAAAAEAAAAA= IFeatureLogger.cs + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAg= + Core\Stores\IStorageProvider.cs + + \ No newline at end of file diff --git a/src/FeatureOne/Core/Stores/FeatureStore.cs b/src/FeatureOne/Core/Stores/FeatureStore.cs index 061d08c..3f9d63d 100644 --- a/src/FeatureOne/Core/Stores/FeatureStore.cs +++ b/src/FeatureOne/Core/Stores/FeatureStore.cs @@ -1,56 +1,37 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Xml.Linq; namespace FeatureOne.Core.Stores { public class FeatureStore : IFeatureStore { - private IStorageProvider storageProvider; - private IFeatureLogger logger; - private IToggleDeserializer toggleDeserializer; + private readonly IStorageProvider storageProvider; + private readonly IFeatureLogger logger; - public FeatureStore(IStorageProvider storageProvider) : this(storageProvider, new NullLogger(), new ToggleDeserializer()) + public FeatureStore(IStorageProvider storageProvider) : this(storageProvider, new NullLogger()) { } - public FeatureStore(IStorageProvider storageProvider, IFeatureLogger logger) : this(storageProvider, logger, new ToggleDeserializer()) - { - } - - public FeatureStore(IStorageProvider storageProvider, IFeatureLogger logger, IToggleDeserializer toggleDeserializer) + public FeatureStore(IStorageProvider storageProvider, IFeatureLogger logger) { this.storageProvider = storageProvider; this.logger = logger; - this.toggleDeserializer = toggleDeserializer; - } - - public IEnumerable FindStartsWith(string key) - { - return GetAll().Where(x => x.Name.Value.StartsWith(key, StringComparison.OrdinalIgnoreCase)); } - public IEnumerable GetAll() + public IEnumerable FindStartsWith(string name) { - var features = storageProvider.Get(); + var features = storageProvider.GetByName(name); if (features == null || !features.Any()) + { + logger?.Info($"FeatureOne, Action='StorageProvider.Get', Message='Retrieved Features list was empty.'"); return Enumerable.Empty(); + } var result = new List(); - foreach (var feature in features) - { - try - { - result.Add(new Feature(feature.Key, toggleDeserializer.Deserializer(feature.Value))); - logger?.Info($"FeatureOne, Action='StorageProvider.Get', Feature='{feature.Key}', Message='Reterieved Success'"); - } - catch (Exception ex) - { - logger?.Error($"FeatureOne, Action='StorageProvider.Get', Feature='{feature.Key}', Toggle='{feature.Value}' Exception='{ex}'."); - } - } + foreach (var feature in features.Where(x => x.Toggle?.Conditions != null && x.Toggle.Conditions.Any())) + result.Add(feature); return result; } diff --git a/src/FeatureOne/Core/Stores/IStorageProvider.cs b/src/FeatureOne/Core/Stores/IStorageProvider.cs index 10da015..7ef239b 100644 --- a/src/FeatureOne/Core/Stores/IStorageProvider.cs +++ b/src/FeatureOne/Core/Stores/IStorageProvider.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace FeatureOne.Core.Stores { /// @@ -8,14 +6,9 @@ namespace FeatureOne.Core.Stores public interface IStorageProvider { /// - /// Implement this method to get all feature toggless from storage. + /// Implement to get storage feature toggles by a given name. /// - /// - /// Example: - /// Key : "Feature-01" - /// Value : "{\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": true}]}" - /// - /// - IEnumerable> Get(); + /// Array of Features + IFeature[] GetByName(string name); } } \ No newline at end of file diff --git a/src/FeatureOne/Core/Stores/IToggleDeserializer.cs b/src/FeatureOne/Core/Stores/IToggleDeserializer.cs deleted file mode 100644 index 71eb766..0000000 --- a/src/FeatureOne/Core/Stores/IToggleDeserializer.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FeatureOne.Core.Stores -{ - public interface IToggleDeserializer - { - IToggle Deserializer(string input); - } -} \ No newline at end of file diff --git a/src/FeatureOne/Core/Stores/NullStoreProvder.cs b/src/FeatureOne/Core/Stores/NullStoreProvder.cs deleted file mode 100644 index 560d088..0000000 --- a/src/FeatureOne/Core/Stores/NullStoreProvder.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace FeatureOne.Core.Stores -{ - public class NullStoreProvder : IStorageProvider - { - public IEnumerable> Get() - { - return Enumerable.Empty>(); - } - } -} \ No newline at end of file diff --git a/src/FeatureOne/Core/Stores/NullStoreProvider.cs b/src/FeatureOne/Core/Stores/NullStoreProvider.cs new file mode 100644 index 0000000..fdea649 --- /dev/null +++ b/src/FeatureOne/Core/Stores/NullStoreProvider.cs @@ -0,0 +1,9 @@ +using System; + +namespace FeatureOne.Core.Stores +{ + public class NullStoreProvider : IStorageProvider + { + public IFeature[] GetByName(string name) => Array.Empty(); + } +} \ No newline at end of file diff --git a/src/FeatureOne/Core/Stores/ToggleDeserializer.cs b/src/FeatureOne/Core/Stores/ToggleDeserializer.cs deleted file mode 100644 index 57a946c..0000000 --- a/src/FeatureOne/Core/Stores/ToggleDeserializer.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace FeatureOne.Core.Stores -{ - public class ToggleDeserializer : IToggleDeserializer - { - public IToggle Deserializer(string input) - { - var jObject = JsonNode.Parse(input); - - var toggleOperator = jObject["operator"]?.ToString() ?? Operator.Any.ToString(); - var toggleConditions = jObject["conditions"].Deserialize(); - - return new Toggle - ( - Enum.TryParse(toggleOperator, true, out var @operator) ? @operator : Operator.Any, - ConditionFactory.Create(toggleConditions) - ); - } - } -} \ No newline at end of file diff --git a/src/FeatureOne/Core/Toggle.cs b/src/FeatureOne/Core/Toggle.cs index b7a0a4d..4ac4b4e 100644 --- a/src/FeatureOne/Core/Toggle.cs +++ b/src/FeatureOne/Core/Toggle.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -5,10 +6,14 @@ namespace FeatureOne.Core { public class Toggle : IToggle { - public Toggle(Operator @operator, ICondition[] conditions) + public Toggle() : this(Operator.Any, Array.Empty()) + { + } + + public Toggle(Operator @operator, params ICondition[] conditions) { Operator = @operator; - Conditions = conditions; + Conditions = conditions ?? Array.Empty(); } public Operator Operator { get; set; } diff --git a/src/FeatureOne/FeatureName.cs b/src/FeatureOne/FeatureName.cs index f7ff851..f4559b0 100644 --- a/src/FeatureOne/FeatureName.cs +++ b/src/FeatureOne/FeatureName.cs @@ -18,5 +18,7 @@ public FeatureName(string name) public string Value { get; } public static implicit operator string(FeatureName s) => s?.Value; + + public static implicit operator FeatureName(string s) => new FeatureName(s); } } \ No newline at end of file diff --git a/src/FeatureOne/FeatureOne.csproj b/src/FeatureOne/FeatureOne.csproj index 7a64cf9..e820148 100644 --- a/src/FeatureOne/FeatureOne.csproj +++ b/src/FeatureOne/FeatureOne.csproj @@ -18,21 +18,23 @@ Ninja.Sha!4H FeatureOne .Net library to implement feature toggles. - 2023 + Copyright (c) 2023 Ninja Sha!4h README.md https://github.com/NinjaRocks/FeatureOne git feature-toggle; feature-flag; feature-flags; feature-toggles; .netstandard2.1; featureOne - 1.0.7 + 2.0.0 LICENSE.md ninja-icon-16.png - Release Notes v1.0.7 - release to target .net standard 2.1. - -Core Functionality :- -- To Implement Feature Toggles to hide/show program features. -- Provides Out of box Simple and Regex toggle conditions that can be extended for any use case. -- Provides Extensibility for custom implementation of backend storages. - + + Release Notes v2.0.0. - Breaking changes to previous release. + Core Functionality :- + - Contract changes to IStorageProvider - Get() method returns array of IFeature type. + - To Implement Feature Toggles to hide/show program features. + - Provides Out of box Simple and Regex toggle conditions . + - Provides extensibility to implement custom toggle conditions for any bespoke use case. + - Provides extensibility to implement backend storage. No Backend storage exists by default. + @@ -50,8 +52,5 @@ Core Functionality :- - - - + - diff --git a/src/FeatureOne/Features.cs b/src/FeatureOne/Features.cs index 06cbd20..4a54ad6 100644 --- a/src/FeatureOne/Features.cs +++ b/src/FeatureOne/Features.cs @@ -25,6 +25,16 @@ public Features(IFeatureStore featureStore, IFeatureLogger logger) public static void Initialize(Func factory) => Current = factory(); + /// + /// Determines whether the feature is enabled. + /// + /// feature name + /// + public bool IsEnabled(string name) + { + return IsEnabled(name, Enumerable.Empty()); + } + /// /// Determines whether the feature is enabled for given claims principal. /// diff --git a/src/FeatureOne/IFeatureStore.cs b/src/FeatureOne/IFeatureStore.cs index cfffd39..3c7c576 100644 --- a/src/FeatureOne/IFeatureStore.cs +++ b/src/FeatureOne/IFeatureStore.cs @@ -5,7 +5,5 @@ namespace FeatureOne public interface IFeatureStore { IEnumerable FindStartsWith(string key); - - IEnumerable GetAll(); } } \ No newline at end of file diff --git a/test/FeatureOne.SQL.Tests/App.config b/test/FeatureOne.SQL.Tests/App.config new file mode 100644 index 0000000..48dc347 --- /dev/null +++ b/test/FeatureOne.SQL.Tests/App.config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/FeatureOne.SQL.Tests/E2e Tests/E2eLogger.cs b/test/FeatureOne.SQL.Tests/E2e Tests/E2eLogger.cs new file mode 100644 index 0000000..dc7d60d --- /dev/null +++ b/test/FeatureOne.SQL.Tests/E2e Tests/E2eLogger.cs @@ -0,0 +1,10 @@ +namespace FeatureOne.SQL.Tests.E2e +{ + internal class E2eLogger : IFeatureLogger + { + public void Debug(string message) => Console.WriteLine("Debug:" + message); + public void Error(string message) => Console.WriteLine("Error:" + message); + public void Info(string message) => Console.WriteLine("Info:" + message); + public void Warn(string message) => Console.WriteLine("Warn:" + message); + } +} \ No newline at end of file diff --git a/test/FeatureOne.SQL.Tests/E2e Tests/End2EndTests.SQLite.cs b/test/FeatureOne.SQL.Tests/E2e Tests/End2EndTests.SQLite.cs new file mode 100644 index 0000000..67a2c9e --- /dev/null +++ b/test/FeatureOne.SQL.Tests/E2e Tests/End2EndTests.SQLite.cs @@ -0,0 +1,48 @@ +using System.Security.Claims; +using FeatureOne.Core.Stores; +using FeatureOne.SQL.StorageProvider; +using NUnit.Framework.Internal; + +namespace FeatureOne.SQL.Tests.E2e +{ + public class End2EndTests + { + [SetUp] + public void Setup() + { + var connectionString = $"DataSource={Environment.CurrentDirectory}//Features.db;mode=readonly;cache=shared"; + + Console.WriteLine(connectionString); + + var configuration = new SQLConfiguration { ConnectionSettings = new ConnectionSettings { ConnectionString = connectionString, ProviderName = DbProviderName.SQLite } }; + + var logger = new E2eLogger(); + + var provider = new SQLStorageProvider(configuration); + + Features.Initialize(() => new Features(new FeatureStore(provider, logger), logger)); + } + + [Test] + public void TestForDashboardWidgetToBeEnabled() + { + var enabled = Features.Current.IsEnabled("dashboard_widget"); + Assert.IsTrue(enabled); + } + + [Test] + public void TestForGBKDashboardToBeEnabledForUsersWithGBKEmails() + { + var enabled = Features.Current.IsEnabled("gbk_dashboard"); + Assert.False(enabled); + + var user1_claims = new[] { new Claim("email", "ninja@udt.com") }; + enabled = Features.Current.IsEnabled("gbk_dashboard", user1_claims); + Assert.False(enabled); + + var user2_claims = new[] { new Claim("email", "ninja@gbk.com") }; + enabled = Features.Current.IsEnabled("gbk_dashboard", user2_claims); + Assert.True(enabled); + } + } +} \ No newline at end of file diff --git a/test/FeatureOne.SQL.Tests/FeatureOne.SQL.Tests.csproj b/test/FeatureOne.SQL.Tests/FeatureOne.SQL.Tests.csproj new file mode 100644 index 0000000..520e101 --- /dev/null +++ b/test/FeatureOne.SQL.Tests/FeatureOne.SQL.Tests.csproj @@ -0,0 +1,34 @@ + + + + net6.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/test/FeatureOne.SQL.Tests/Features.db b/test/FeatureOne.SQL.Tests/Features.db new file mode 100644 index 0000000..bc781e3 Binary files /dev/null and b/test/FeatureOne.SQL.Tests/Features.db differ diff --git a/test/FeatureOne.Tests/Toggles/ToggleConditionFactoryTest.cs b/test/FeatureOne.SQL.Tests/UnitTests/ConditionDeserializerTest.cs similarity index 62% rename from test/FeatureOne.Tests/Toggles/ToggleConditionFactoryTest.cs rename to test/FeatureOne.SQL.Tests/UnitTests/ConditionDeserializerTest.cs index e872c45..4aedce6 100644 --- a/test/FeatureOne.Tests/Toggles/ToggleConditionFactoryTest.cs +++ b/test/FeatureOne.SQL.Tests/UnitTests/ConditionDeserializerTest.cs @@ -1,17 +1,17 @@ -using FeatureOne.Core.Stores; using FeatureOne.Core.Toggles.Conditions; +using FeatureOne.SQL; using System.Text.Json.Nodes; -namespace FeatureOne.Test.Toggles +namespace FeatureOne.SQL.Tests.UnitTests { [TestFixture] - public sealed class ToggleConditionFactoryTest + public sealed class ConditionDeserializerTest { [Test] public void TestToggleConditionForNUllInput() { JsonObject jObj = null; - Assert.Throws(() => ConditionFactory.Create(jObj)); + Assert.Throws(() => new ConditionDeserializer().Deserialize(jObj)); } [Test] @@ -19,8 +19,8 @@ public void TestToggleConditionForCorrectSimpleInstanceType() { var json = "{\r\n\t\t\t \"type\":\"Simple\",\r\n\t\t\t \"IsEnabled\":\"true\"\r\n\t\t}"; - var jobject = JsonObject.Parse(json)?.AsObject(); - var toggleCondition = ConditionFactory.Create(jobject); + var jobject = JsonNode.Parse(json)?.AsObject(); + var toggleCondition = new ConditionDeserializer().Deserialize(jobject); Assert.IsInstanceOf(typeof(SimpleCondition), toggleCondition); } @@ -30,8 +30,8 @@ public void TestToggleConditionForCorrectRegexInstanceType() { var json = "{\r\n\t\t\t \"type\":\"RegexCondition\",\r\n\t\t\t \"claim\":\"email\",\r\n\t\t\t \"expression\":\"*@gbk.com\"\r\n\t\t }"; - var jobject = JsonObject.Parse(json)?.AsObject(); - var toggleCondition = ConditionFactory.Create(jobject); + var jobject = JsonNode.Parse(json)?.AsObject(); + var toggleCondition = new ConditionDeserializer().Deserialize(jobject); Assert.IsInstanceOf(typeof(RegexCondition), toggleCondition); } diff --git a/test/FeatureOne.Tests/Toggles/NamePostFixTest.cs b/test/FeatureOne.SQL.Tests/UnitTests/NamePostFixTest.cs similarity index 91% rename from test/FeatureOne.Tests/Toggles/NamePostFixTest.cs rename to test/FeatureOne.SQL.Tests/UnitTests/NamePostFixTest.cs index e38f6dd..37ae4ec 100644 --- a/test/FeatureOne.Tests/Toggles/NamePostFixTest.cs +++ b/test/FeatureOne.SQL.Tests/UnitTests/NamePostFixTest.cs @@ -1,6 +1,4 @@ -using FeatureOne.Core; - -namespace FeatureOne.Tests.Toggles +namespace FeatureOne.SQL.Tests.UnitTests { [TestFixture] public class NamePostFixTest diff --git a/test/FeatureOne.SQL.Tests/UnitTests/SQLStatementTest.cs b/test/FeatureOne.SQL.Tests/UnitTests/SQLStatementTest.cs new file mode 100644 index 0000000..8d03e83 --- /dev/null +++ b/test/FeatureOne.SQL.Tests/UnitTests/SQLStatementTest.cs @@ -0,0 +1,16 @@ +using FeatureOne.SQL.StorageProvider; + +namespace FeatureOne.SQL.Tests.UnitTests +{ + [TestFixture] + internal class SQLStatementTest + { + [Test] + public void TestCreateSQL() + { + var table = new FeatureTable(); + var sql = table.CreateSQL("Foo"); + Assert.AreEqual("Select Name, Toggle From TFeatures Where Archived = 0 and Name = 'Foo'", sql); + } + } +} \ No newline at end of file diff --git a/test/FeatureOne.SQL.Tests/UnitTests/SQLStorageProviderTest.cs b/test/FeatureOne.SQL.Tests/UnitTests/SQLStorageProviderTest.cs new file mode 100644 index 0000000..5029f18 --- /dev/null +++ b/test/FeatureOne.SQL.Tests/UnitTests/SQLStorageProviderTest.cs @@ -0,0 +1,95 @@ +using System.Runtime.Caching; +using FeatureOne.SQL.StorageProvider; +using Moq; + +namespace FeatureOne.SQL.Tests.UnitTests +{ + internal class SQLStorageProviderTest + { + [SetUp] + public void Setup() + { + } + + [Test] + public void TestStorageProviderInit() + { + var cacheSettings = new CacheSettings(); + var configuration = new SQLConfiguration { CacheSettings = cacheSettings }; + + var provider = new SQLStorageProvider(configuration); + + Assert.IsNotNull(provider.cacheSettings); + Assert.That(provider.cacheSettings, Is.EqualTo(cacheSettings)); + Assert.IsNotNull(provider.repository); + Assert.IsNotNull(provider.deserializer); + Assert.IsNotNull(provider.cache); + + provider = new SQLStorageProvider(configuration, new Mock().Object); + + Assert.IsNotNull(provider.cacheSettings); + Assert.That(provider.cacheSettings, Is.EqualTo(cacheSettings)); + Assert.IsNotNull(provider.repository); + Assert.IsNotNull(provider.deserializer); + Assert.IsNotNull(provider.cache); + + provider = new SQLStorageProvider(new SQLConfiguration(), new Mock().Object, new Mock().Object, new Mock().Object); + + Assert.IsNotNull(provider.cacheSettings); + Assert.That(provider.cacheSettings, Is.Not.EqualTo(cacheSettings)); + Assert.IsNotNull(provider.repository); + Assert.IsNotNull(provider.deserializer); + Assert.IsNotNull(provider.cache); + + provider = new SQLStorageProvider(new Mock().Object, new Mock().Object, new Mock().Object, null); + + Assert.IsNotNull(provider.cacheSettings); + Assert.That(provider.cacheSettings, Is.Not.EqualTo(cacheSettings)); + Assert.IsNotNull(provider.repository); + Assert.IsNotNull(provider.deserializer); + Assert.IsNotNull(provider.cache); + } + + [Test] + public void TestGetMethodToUseNoCache() + { + var cache = new Mock(); + + var repository = new Mock(); + repository.Setup(x => x.GetByName("Foo")).Returns(new[] { new DbRecord { Name = "Foo", Toggle = "Toggle" } }); + + var deserializer = new Mock(); + var provider = new SQLStorageProvider(repository.Object, deserializer.Object, cache.Object, new CacheSettings()); + + var result = provider.GetByName("Foo"); + + repository.Verify(x => x.GetByName("Foo"), Times.Once()); + deserializer.Verify(x => x.Deserialize(It.IsAny()), Times.Once()); + + cache.Verify(x => x.Get(It.IsAny()), Times.Never()); + cache.Verify(x => x.Add(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + } + + [Test] + public void TestGetMethodToUseCache() + { + var dbRecords = new[] { new DbRecord { Name = "Foo", Toggle = "Toggle" } }; + var repository = new Mock(); + var cache = new Mock(); + + repository.Setup(x => x.GetByName("Foo")).Returns(dbRecords); + var deserializer = new Mock(); + var cacheSettings = new CacheSettings { EnableCache = true, ExpiryInMinutes = 4 }; + + var provider = new SQLStorageProvider(repository.Object, deserializer.Object, cache.Object, cacheSettings); + + var result = provider.GetByName("Foo"); + + repository.Verify(x => x.GetByName("Foo"), Times.Once()); + deserializer.Verify(x => x.Deserialize(It.IsAny()), Times.Once()); + + cache.Verify(x => x.Get("Foo"), Times.Once()); + cache.Verify(x => x.Add("Foo", dbRecords, 4), Times.Once()); + } + } +} \ No newline at end of file diff --git a/test/FeatureOne.Tests/Stores/ToggleDeserializerTests.cs b/test/FeatureOne.SQL.Tests/UnitTests/ToggleDeserializerTest.cs similarity index 74% rename from test/FeatureOne.Tests/Stores/ToggleDeserializerTests.cs rename to test/FeatureOne.SQL.Tests/UnitTests/ToggleDeserializerTest.cs index a86e817..a429fa1 100644 --- a/test/FeatureOne.Tests/Stores/ToggleDeserializerTests.cs +++ b/test/FeatureOne.SQL.Tests/UnitTests/ToggleDeserializerTest.cs @@ -1,11 +1,11 @@ -using FeatureOne.Core; -using FeatureOne.Core.Stores; +using FeatureOne.Core; using FeatureOne.Core.Toggles.Conditions; +using FeatureOne.SQL; -namespace FeatureOne.Tests.Stores +namespace FeatureOne.SQL.Tests.UnitTests { [TestFixture] - internal class ToggleDeserializerTests + internal class ToggleDeserializerTest { [Test] public void TestDeSerialize() @@ -13,7 +13,7 @@ public void TestDeSerialize() var deserializer = new ToggleDeserializer(); var toggle = deserializer - .Deserializer("{\"operator\":\"all\",\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": false}, {\"type\":\"RegexCondition\",\"claim\":\"email\",\"expression\":\"*@gbk.com\"}]}"); + .Deserialize("{\"operator\":\"all\",\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": false}, {\"type\":\"RegexCondition\",\"claim\":\"email\",\"expression\":\"*@gbk.com\"}]}"); Assert.That(toggle.Operator, Is.EqualTo(Operator.All)); Assert.That(toggle.Conditions.Length, Is.EqualTo(2)); @@ -23,6 +23,7 @@ public void TestDeSerialize() Assert.IsInstanceOf(toggle.Conditions[0]); Assert.That(((SimpleCondition)toggle.Conditions[0]).IsEnabled, Is.EqualTo(false)); }); + Assert.Multiple(() => { Assert.IsInstanceOf(toggle.Conditions[1]); diff --git a/test/FeatureOne.SQL.Tests/Usings.cs b/test/FeatureOne.SQL.Tests/Usings.cs new file mode 100644 index 0000000..cefced4 --- /dev/null +++ b/test/FeatureOne.SQL.Tests/Usings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/test/FeatureOne.Tests/E2E Tests/ConsoleLogger.cs b/test/FeatureOne.Tests/ConsoleLogger.cs similarity index 91% rename from test/FeatureOne.Tests/E2E Tests/ConsoleLogger.cs rename to test/FeatureOne.Tests/ConsoleLogger.cs index df789ba..1eda6fa 100644 --- a/test/FeatureOne.Tests/E2E Tests/ConsoleLogger.cs +++ b/test/FeatureOne.Tests/ConsoleLogger.cs @@ -1,4 +1,4 @@ -namespace FeatureOne.Tests.Registeration +namespace FeatureOne.Tests { public class ConsoleLogger : IFeatureLogger { @@ -22,4 +22,4 @@ public void Warn(string message) Console.WriteLine(message); } } -} +} \ No newline at end of file diff --git a/test/FeatureOne.Tests/CustomStoreProvider.cs b/test/FeatureOne.Tests/CustomStoreProvider.cs new file mode 100644 index 0000000..8cf1156 --- /dev/null +++ b/test/FeatureOne.Tests/CustomStoreProvider.cs @@ -0,0 +1,21 @@ +using FeatureOne.Core; +using FeatureOne.Core.Stores; +using FeatureOne.Core.Toggles.Conditions; + +namespace FeatureOne.Tests +{ + public class CustomStoreProvider : IStorageProvider + { + private List> list = new List>() + { + new Tuple("feature-01", new Feature("feature-01",new Toggle(Operator.Any, new[]{ new SimpleCondition{IsEnabled=true}}))), + new Tuple("feature-02", new Feature("feature-02",new Toggle(Operator.Any, new SimpleCondition { IsEnabled = false }, new RegexCondition{Claim="email", Expression= "^[a-zA-Z0-9_.+-]+@gbk.com" }))), + new Tuple("feature-03", new Feature("feature-03",new Toggle(Operator.Any, new[]{ new ReleaseOnCondition{ ReleaseOn=new DateTime(3022,12, 1)}}))), + }; + + public IFeature[] GetByName(string name) + { + return list.Where(t => t.Item1 == name).Select(f => f.Item2).ToArray(); + } + } +} \ No newline at end of file diff --git a/test/FeatureOne.Tests/E2E Tests/E2ETests.cs b/test/FeatureOne.Tests/E2E Tests/E2ETests.cs index 0ed8370..92d8903 100644 --- a/test/FeatureOne.Tests/E2E Tests/E2ETests.cs +++ b/test/FeatureOne.Tests/E2E Tests/E2ETests.cs @@ -1,4 +1,4 @@ -using FeatureOne.Core.Stores; +using FeatureOne.Core.Stores; using System.Security.Claims; namespace FeatureOne.Tests.Registeration @@ -6,24 +6,50 @@ namespace FeatureOne.Tests.Registeration [TestFixture] internal class E2ETests { - [Test] public void TestE2EOfServices() { var logger = new ConsoleLogger(); - var storageProvider = new StorageProvider(); + var storageProvider = new CustomStoreProvider(); + + // feature-01 -> simple condition as enabled. Principal should not affect. + + // feature-02 -> consists of two conditions. + // enabled = (simple condition as disable) OR (regex condition enabled for email with domain gbk.com). + // Principal affects regex condition included. Features.Initialize(() => new Features(new FeatureStore(storageProvider, logger), logger)); - var principal = new ClaimsPrincipal(new ClaimsIdentity(new List { new Claim("user", "ninja") })); - var isEnabled = Features.Current.IsEnabled("feature-01", principal); + // feature-01 -> simple condition as enabled. + var isEnabled = Features.Current.IsEnabled("feature-01"); + Assert.That(isEnabled, Is.True); + // feature-01 -> simple condition as enabled. Principal should not affect. + isEnabled = Features.Current.IsEnabled("feature-01", principal); + Assert.That(isEnabled, Is.True); + + // feature-02 -> simple condition as disabled. + isEnabled = Features.Current.IsEnabled("feature-02"); + Assert.That(isEnabled, Is.False); + // feature-02 -> simple condition as disabled. Principal should affect only regex condition. + isEnabled = Features.Current.IsEnabled("feature-02", principal); + Assert.That(isEnabled, Is.False); + + var principal2 = new ClaimsPrincipal(new ClaimsIdentity(new List + { + new Claim("email", "ninja@gbk.com") + })); + + isEnabled = Features.Current.IsEnabled("feature-02", principal2); Assert.That(isEnabled, Is.True); - } + + isEnabled = Features.Current.IsEnabled("feature-03"); + Assert.That(isEnabled, Is.False); + } } -} +} \ No newline at end of file diff --git a/test/FeatureOne.Tests/E2E Tests/StorageProvider.cs b/test/FeatureOne.Tests/E2E Tests/StorageProvider.cs deleted file mode 100644 index ca38d29..0000000 --- a/test/FeatureOne.Tests/E2E Tests/StorageProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FeatureOne.Core.Stores; - -namespace FeatureOne.Tests.Registeration -{ - public class StorageProvider : IStorageProvider - { - public IEnumerable> Get() - { - return new[] - { - new KeyValuePair("feature-01", "{\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": true}]}"), - new KeyValuePair("feature-02", "{\"operator\":\"all\",\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": false}, {\"type\":\"RegexCondition\",\"claim\":\"email\",\"expression\":\"*@gbk.com\"}]}") - }; - } - } -} diff --git a/test/FeatureOne.Tests/FeatureNameTest.cs b/test/FeatureOne.Tests/FeatureNameTest.cs index 8250888..46bc47d 100644 --- a/test/FeatureOne.Tests/FeatureNameTest.cs +++ b/test/FeatureOne.Tests/FeatureNameTest.cs @@ -1,4 +1,4 @@ -namespace FeatureOne.Test +namespace FeatureOne.Test { [TestFixture] public class FeatureNameTest @@ -23,5 +23,16 @@ public void TestNameForUnsupportedInputs(string input) { Assert.Throws(() => new FeatureName(input)); } + + [Test] + public void TestForStringOperators() + { + var input = "x"; + var ft1 = new FeatureName(input); + Assert.That((string)ft1, Is.EqualTo(input)); + + var ft2 = (FeatureName)input; + Assert.That(ft2.Value, Is.EqualTo(input)); + } } } \ No newline at end of file diff --git a/test/FeatureOne.Tests/ReleaseOnCondition.cs b/test/FeatureOne.Tests/ReleaseOnCondition.cs new file mode 100644 index 0000000..7a3dd57 --- /dev/null +++ b/test/FeatureOne.Tests/ReleaseOnCondition.cs @@ -0,0 +1,17 @@ +using FeatureOne.Core; + +namespace FeatureOne.Tests +{ + internal class ReleaseOnCondition : ICondition + { + /// + /// UTC Release date & time. + /// + public DateTime ReleaseOn { get; set; } + + public bool Evaluate(IDictionary claims) + { + return (DateTime.UtcNow >= ReleaseOn); + } + } +} \ No newline at end of file diff --git a/test/FeatureOne.Tests/Stores/FeatureStoreTests.cs b/test/FeatureOne.Tests/Stores/FeatureStoreTests.cs index 4b30ffa..6dd44e9 100644 --- a/test/FeatureOne.Tests/Stores/FeatureStoreTests.cs +++ b/test/FeatureOne.Tests/Stores/FeatureStoreTests.cs @@ -1,4 +1,4 @@ -using FeatureOne.Core; +using FeatureOne.Core; using FeatureOne.Core.Stores; using FeatureOne.Core.Toggles.Conditions; using Moq; @@ -18,20 +18,20 @@ public void Setup() { logger = new Mock(); storeProvider = new Mock(); - storeProvider.Setup(x => x.Get()) + storeProvider.Setup(x => x.GetByName(It.IsAny())) .Returns(new[] { - new KeyValuePair("feature-01", "{\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": true}]}"), - new KeyValuePair("feature-02", "{\"operator\":\"all\",\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": false}, {\"type\":\"RegexCondition\",\"claim\":\"email\",\"expression\":\"*@gbk.com\"}]}") - }); + new Feature("feature-01",new Toggle(Operator.Any, new[]{ new SimpleCondition{IsEnabled=true}})), + new Feature("feature-02",new Toggle(Operator.All, new SimpleCondition { IsEnabled = false }, new RegexCondition{Claim="email", Expression= "*@gbk.com" })) + }); featureStore = new FeatureStore(storeProvider.Object, logger.Object); } [Test] - public void TestGetAllToReturnCorrectFeaturesConfiguredStoreInProvider() + public void TestFindToReturnCorrectFeaturesConfiguredStoreInProvider() { - var features = featureStore.GetAll(); + var features = featureStore.FindStartsWith("feature"); Assert.That(features.Count(), Is.EqualTo(2)); @@ -63,16 +63,16 @@ public void TestGetAllToReturnCorrectFeaturesConfiguredStoreInProvider() } [Test] - public void TestGetAllToReturnAnyDeserializedFeaturesInStoreProvideAndLogErrorsForFailures() + public void TestFindToReturnAnyDeserializedFeaturesInStoreProvideAndLogErrorsForFailures() { - storeProvider.Setup(x => x.Get()) + storeProvider.Setup(x => x.GetByName(It.IsAny())) .Returns(new[] { - new KeyValuePair("feature-01", "{\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": true}]}"), - new KeyValuePair("feature-02", "Invalid Toggle String") - }); + new Feature("feature-01",new Toggle(Operator.Any, new[]{ new SimpleCondition{IsEnabled=true}})), + new Feature("feature-02",new Toggle(Operator.All, null)) + }); - var features = featureStore.GetAll(); + var features = featureStore.FindStartsWith("feature"); Assert.That(features.Count(), Is.EqualTo(1)); @@ -85,19 +85,6 @@ public void TestGetAllToReturnAnyDeserializedFeaturesInStoreProvideAndLogErrorsF Assert.IsInstanceOf(feature01.Toggle.Conditions[0]); Assert.That(((SimpleCondition)feature01.Toggle.Conditions[0]).IsEnabled, Is.EqualTo(true)); }); - - logger.Verify(x => x.Error(It.Is(msg => msg.Contains("feature-02"))), Times.Once()); - } - } - - public class CustomStoreProvider : IStorageProvider - { - public IEnumerable> Get() - { - return new[] { - new KeyValuePair("feature-01", "{\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": true}]}"), - new KeyValuePair("feature-02", "{\"operator\":\"all\",\"conditions\":[{\"type\":\"Simple\",\"isEnabled\": false}, {\"type\":\"RegexCondition\",\"claim\":\"email\",\"expression\":\"*@gbk.com\"}]}") - }; } } } \ No newline at end of file