├── .github └── workflows │ ├── build-and-test.yml │ └── release.yml ├── .gitignore ├── FeatBit.ServerSdk.sln ├── LICENSE.md ├── README.md ├── examples ├── ConsoleApp │ ├── ConsoleApp.csproj │ └── Program.cs └── WebApiApp │ ├── FeatBitHealthCheck.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── WebApiApp.csproj │ ├── appsettings.Development.json │ └── appsettings.json ├── global.json ├── src └── FeatBit.ServerSdk │ ├── Bootstrapping │ ├── IBootstrapProvider.cs │ ├── JsonBootstrapProvider.cs │ └── NullBootstrapProvider.cs │ ├── Concurrent │ ├── AtomicBoolean.cs │ └── StatusManager.cs │ ├── DataSynchronizer │ ├── DataSet.cs │ ├── DataSynchronizerStatus.cs │ ├── IDataSynchronizer.cs │ ├── NullDataSynchronizer.cs │ └── WebSocketDataSynchronizer.cs │ ├── DependencyInjection │ ├── FbClientHostedService.cs │ └── ServiceCollectionExtensions.cs │ ├── Evaluation │ ├── DispatchAlgorithm.cs │ ├── EvalDetail.cs │ ├── EvalResult.cs │ ├── EvaluationContext.cs │ ├── Evaluator.ConditionMatcher.cs │ ├── Evaluator.RuleMatcher.cs │ ├── Evaluator.SegmentMatcher.cs │ ├── Evaluator.cs │ ├── IEvaluator.cs │ ├── Operator.cs │ └── OperatorTypes.cs │ ├── Events │ ├── DefaultEventBuffer.cs │ ├── DefaultEventDispatcher.cs │ ├── DefaultEventDispather.Log.cs │ ├── DefaultEventProcessor.cs │ ├── DefaultEventSender.Log.cs │ ├── DefaultEventSender.cs │ ├── DefaultEventSerializer.cs │ ├── DeliveryStatus.cs │ ├── IEvent.cs │ ├── IEventBuffer.cs │ ├── IEventDispatcher.cs │ ├── IEventProcessor.cs │ ├── IEventSender.cs │ ├── IEventSerializer.cs │ └── NullEventProcessor.cs │ ├── FbClient.cs │ ├── FbClientStatus.cs │ ├── FeatBit.ServerSdk.csproj │ ├── Http │ ├── HttpConstants.cs │ └── HttpErrors.cs │ ├── IFbClient.cs │ ├── Json │ ├── ReusableJsonSerializerOptions.cs │ └── VersionJsonConverter.cs │ ├── Model │ ├── Condition.cs │ ├── FbUser.cs │ ├── FbUserBuilder.cs │ ├── FeatureFlag.cs │ ├── FeatureFlagBuilder.cs │ ├── Segment.cs │ ├── SegmentBuilder.cs │ └── Variation.cs │ ├── NuGet.md │ ├── Options │ ├── FbOptions.cs │ └── FbOptionsBuilder.cs │ ├── Properties │ └── AssemblyInfo.cs │ ├── Retry │ ├── BackoffAndJitterRetryPolicy.cs │ ├── DefaultRetryPolicy.cs │ ├── IRetryPolicy.cs │ └── RetryContext.cs │ ├── Store │ ├── DefaultMemoryStore.cs │ ├── IMemoryStore.cs │ ├── StorableObject.cs │ └── StoreKeys.cs │ ├── Transport │ ├── ConnectionToken.cs │ ├── DuplexPipe.cs │ ├── FbWebSocket.Log.cs │ ├── FbWebSocket.cs │ ├── TextMessageFormatter.cs │ ├── TextMessageParser.cs │ ├── WebSocketExtensions.cs │ ├── WebSocketTransport.Log.cs │ └── WebSocketTransport.cs │ ├── ValueConverters.cs │ └── icon.png └── tests └── FeatBit.ServerSdk.Tests ├── Bootstrapping ├── JsonBootstrapProviderTests.PopulateStore.verified.txt ├── JsonBootstrapProviderTests.UseValidJson.verified.txt ├── JsonBootstrapProviderTests.cs └── featbit-bootstrap.json ├── Concurrent ├── AtomicBooleanTests.cs └── StatusManagerTests.cs ├── DataSynchronizer ├── WebSocketDataSynchronizerTests.cs ├── full-data-set.json └── patch-data-set.json ├── Evaluation ├── ConditionMatcherTests.cs ├── DispatchAlgorithmTests.cs ├── EvaluatorTests.cs ├── RuleMatcherTests.cs └── SegmentMatcherTests.cs ├── Events ├── AsyncEventTests.cs ├── DefaultEventBufferTests.cs ├── DefaultEventDispatcherTests.cs ├── DefaultEventProcessorTests.cs ├── DefaultEventSenderTests.cs ├── DefaultEventSerializerTests.SerializeCombinedEvents.verified.txt ├── DefaultEventSerializerTests.SerializeEvalEvent.verified.txt ├── DefaultEventSerializerTests.SerializeEvalEvents.verified.txt ├── DefaultEventSerializerTests.SerializeMetricEvent.verified.txt ├── DefaultEventSerializerTests.SerializeMetricEvents.verified.txt ├── DefaultEventSerializerTests.cs └── IntEvent.cs ├── FbClientOfflineTests.cs ├── FbClientTests.cs ├── FeatBit.ServerSdk.Tests.csproj ├── Model ├── DeserializationTests.DeserializeFeatureFlag.verified.txt ├── DeserializationTests.DeserializeSegment.verified.txt ├── DeserializationTests.cs ├── one-flag.json └── one-segment.json ├── Options ├── FbOptionsBuilderTests.HasDefaultValues.verified.txt └── FbOptionsBuilderTests.cs ├── Retry ├── BackoffAndJitterRetryPolicyTests.cs └── DefaultRetryPolicyTests.cs ├── Store └── DefaultMemoryStoreTests.cs ├── TestApp.cs ├── TestAppCollection.cs ├── TestData.cs ├── TestStartup.cs ├── Transport ├── ConnectionTokenTests.cs ├── FbWebSocketTests.cs └── WebSocketsTransportTests.cs ├── UriTests.cs ├── Usings.cs └── ValueConverterTests.cs /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a .NET project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net 3 | 4 | name: build and test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up .NET 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | global-json-file: global.json 23 | 24 | - name: Restore Packages 25 | run: dotnet restore 26 | 27 | - name: Build Solution 28 | run: dotnet build -c Release --no-restore 29 | 30 | - name: Run Tests 31 | run: dotnet test -c Release --no-build --verbosity normal 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | env: 9 | VERSION: 1.0.0 10 | 11 | jobs: 12 | build: 13 | environment: Production 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up .NET 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | global-json-file: global.json 23 | 24 | - name: Set Version Variable 25 | env: 26 | TAG: ${{ github.ref_name }} 27 | run: echo "VERSION=${TAG#v}" >> $GITHUB_ENV 28 | 29 | - name: Restore Packages 30 | run: dotnet restore 31 | 32 | - name: Build Solution 33 | run: dotnet build -c Release --no-restore /p:Version=$VERSION 34 | 35 | - name: Run Tests 36 | run: dotnet test -c Release --no-build --verbosity normal 37 | 38 | - name: Pack FeatBit.ServerSdk 39 | run: dotnet pack ./src/FeatBit.ServerSdk/FeatBit.ServerSdk.csproj -c Release --no-restore --no-build --output ${VERSION} 40 | 41 | - name: Publish NuGet Package 42 | run: dotnet nuget push ${VERSION}/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate 43 | -------------------------------------------------------------------------------- /FeatBit.ServerSdk.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AF2C39EB-C569-437A-863D-8E2ACB81621D}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{5AFEEA5E-0264-4EF0-AB21-FB6C1EE46754}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FeatBit.ServerSdk", "src\FeatBit.ServerSdk\FeatBit.ServerSdk.csproj", "{BC91B3B1-D996-4F01-9C10-69B05E37A90D}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FeatBit.ServerSdk.Tests", "tests\FeatBit.ServerSdk.Tests\FeatBit.ServerSdk.Tests.csproj", "{63F37641-2F11-4B0D-8A88-D345FF03822E}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{F6B47580-EB54-432C-AD5A-B918DF4B4C9C}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleApp", "examples\ConsoleApp\ConsoleApp.csproj", "{1DC2EE80-678C-4DF1-9298-0465E64B4FDB}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiApp", "examples\WebApiApp\WebApiApp.csproj", "{68C5EB7D-A4A6-4A86-B38C-C15765F6B883}" 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Release|Any CPU = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(NestedProjects) = preSolution 29 | {BC91B3B1-D996-4F01-9C10-69B05E37A90D} = {AF2C39EB-C569-437A-863D-8E2ACB81621D} 30 | {63F37641-2F11-4B0D-8A88-D345FF03822E} = {5AFEEA5E-0264-4EF0-AB21-FB6C1EE46754} 31 | {1DC2EE80-678C-4DF1-9298-0465E64B4FDB} = {F6B47580-EB54-432C-AD5A-B918DF4B4C9C} 32 | {68C5EB7D-A4A6-4A86-B38C-C15765F6B883} = {F6B47580-EB54-432C-AD5A-B918DF4B4C9C} 33 | EndGlobalSection 34 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 35 | {BC91B3B1-D996-4F01-9C10-69B05E37A90D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {BC91B3B1-D996-4F01-9C10-69B05E37A90D}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {BC91B3B1-D996-4F01-9C10-69B05E37A90D}.Release|Any CPU.ActiveCfg = Release|Any CPU 38 | {BC91B3B1-D996-4F01-9C10-69B05E37A90D}.Release|Any CPU.Build.0 = Release|Any CPU 39 | {63F37641-2F11-4B0D-8A88-D345FF03822E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {63F37641-2F11-4B0D-8A88-D345FF03822E}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {63F37641-2F11-4B0D-8A88-D345FF03822E}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {63F37641-2F11-4B0D-8A88-D345FF03822E}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {1DC2EE80-678C-4DF1-9298-0465E64B4FDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 44 | {1DC2EE80-678C-4DF1-9298-0465E64B4FDB}.Debug|Any CPU.Build.0 = Debug|Any CPU 45 | {1DC2EE80-678C-4DF1-9298-0465E64B4FDB}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {1DC2EE80-678C-4DF1-9298-0465E64B4FDB}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {68C5EB7D-A4A6-4A86-B38C-C15765F6B883}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {68C5EB7D-A4A6-4A86-B38C-C15765F6B883}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {68C5EB7D-A4A6-4A86-B38C-C15765F6B883}.Release|Any CPU.ActiveCfg = Release|Any CPU 50 | {68C5EB7D-A4A6-4A86-B38C-C15765F6B883}.Release|Any CPU.Build.0 = Release|Any CPU 51 | EndGlobalSection 52 | EndGlobal 53 | -------------------------------------------------------------------------------- /examples/ConsoleApp/ConsoleApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 21 | 22 | -------------------------------------------------------------------------------- /examples/ConsoleApp/Program.cs: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/new-console-template for more information 2 | 3 | using FeatBit.Sdk.Server; 4 | using FeatBit.Sdk.Server.Model; 5 | using FeatBit.Sdk.Server.Options; 6 | using Microsoft.Extensions.Logging; 7 | using Serilog; 8 | 9 | // Set secret to your FeatBit SDK secret. 10 | const string secret = ""; 11 | if (string.IsNullOrWhiteSpace(secret)) 12 | { 13 | Console.WriteLine("Please edit Program.cs to set secret to your FeatBit SDK secret first. Exiting..."); 14 | Environment.Exit(1); 15 | } 16 | 17 | // Creates a new client to connect to FeatBit with a custom option. 18 | 19 | // init serilog 20 | Log.Logger = new LoggerConfiguration() 21 | // use debug logs when troubleshooting 22 | .MinimumLevel.Debug() 23 | .WriteTo.File("featbit-logs.txt") 24 | .CreateLogger(); 25 | 26 | var serilogLoggerFactory = LoggerFactory.Create(opt => opt.AddSerilog()); 27 | var options = new FbOptionsBuilder(secret) 28 | .Streaming(new Uri("wss://app-eval.featbit.co")) 29 | .Event(new Uri("https://app-eval.featbit.co")) 30 | .LoggerFactory(serilogLoggerFactory) 31 | .Build(); 32 | 33 | var client = new FbClient(options); 34 | if (!client.Initialized) 35 | { 36 | Console.WriteLine("FbClient failed to initialize. Exiting..."); 37 | Environment.Exit(-1); 38 | } 39 | 40 | while (true) 41 | { 42 | Console.WriteLine("Please input userKey/flagKey, for example 'user-id/use-new-algorithm'. Input 'exit' to exit."); 43 | var input = Console.ReadLine(); 44 | if (string.IsNullOrWhiteSpace(input)) 45 | { 46 | continue; 47 | } 48 | 49 | if (input == "exit") 50 | { 51 | Console.WriteLine("Exiting, please wait..."); 52 | break; 53 | } 54 | 55 | var keys = input.Split('/'); 56 | if (keys.Length != 2) 57 | { 58 | Console.WriteLine(); 59 | continue; 60 | } 61 | 62 | var userKey = keys[0]; 63 | var flagKey = keys[1]; 64 | 65 | var user = FbUser.Builder(userKey).Build(); 66 | 67 | var detail = client.StringVariationDetail(flagKey, user, "fallback"); 68 | Console.WriteLine($"Kind: {detail.Kind}, Reason: {detail.Reason}, Value: {detail.Value}, ValueId: {detail.ValueId}"); 69 | Console.WriteLine(); 70 | } 71 | 72 | // Shuts down the client to ensure all pending events are sent. 73 | await client.CloseAsync(); 74 | 75 | Environment.Exit(1); -------------------------------------------------------------------------------- /examples/WebApiApp/FeatBitHealthCheck.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server; 2 | using Microsoft.Extensions.Diagnostics.HealthChecks; 3 | 4 | namespace WebApiApp; 5 | 6 | public class FeatBitHealthCheck : IHealthCheck 7 | { 8 | private readonly IFbClient _fbClient; 9 | 10 | public FeatBitHealthCheck(IFbClient fbClient) 11 | { 12 | _fbClient = fbClient; 13 | } 14 | 15 | public Task CheckHealthAsync( 16 | HealthCheckContext context, 17 | CancellationToken cancellationToken = default) 18 | { 19 | var status = _fbClient.Status; 20 | 21 | var result = status switch 22 | { 23 | FbClientStatus.Ready => HealthCheckResult.Healthy(), 24 | FbClientStatus.Stale => HealthCheckResult.Degraded(), 25 | _ => HealthCheckResult.Unhealthy() 26 | }; 27 | 28 | return Task.FromResult(result); 29 | } 30 | } -------------------------------------------------------------------------------- /examples/WebApiApp/Program.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server; 2 | using FeatBit.Sdk.Server.Model; 3 | using FeatBit.Sdk.Server.DependencyInjection; 4 | using HealthChecks.UI.Client; 5 | using Microsoft.AspNetCore.Diagnostics.HealthChecks; 6 | using WebApiApp; 7 | 8 | var builder = WebApplication.CreateBuilder(args); 9 | 10 | // Set secret to your FeatBit SDK secret. 11 | const string secret = ""; 12 | if (string.IsNullOrWhiteSpace(secret)) 13 | { 14 | Console.WriteLine("Please edit Program.cs to set secret to your FeatBit SDK secret first. Exiting..."); 15 | Environment.Exit(1); 16 | } 17 | 18 | // Note that by default, the FeatBit SDK will use the default logger factory provided by ASP.NET Core. 19 | builder.Services.AddFeatBit(options => 20 | { 21 | options.EnvSecret = secret; 22 | options.StreamingUri = new Uri("wss://app-eval.featbit.co"); 23 | options.EventUri = new Uri("https://app-eval.featbit.co"); 24 | options.StartWaitTime = TimeSpan.FromSeconds(3); 25 | }); 26 | 27 | builder.Services.AddHealthChecks() 28 | .AddCheck("FeatBit"); 29 | 30 | var app = builder.Build(); 31 | 32 | // curl -X GET --location http://localhost:5014/healthz 33 | app.MapHealthChecks("/healthz", new HealthCheckOptions 34 | { 35 | ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse 36 | }); 37 | 38 | // curl -X GET --location http://localhost:5014/variation-detail/game-runner?fallbackValue=lol 39 | app.MapGet("/variation-detail/{flagKey}", (IFbClient fbClient, string flagKey, string fallbackValue) => 40 | { 41 | var user = FbUser.Builder("tester-id").Name("tester").Build(); 42 | 43 | return fbClient.StringVariationDetail(flagKey, user, defaultValue: fallbackValue); 44 | }); 45 | 46 | app.Run(); -------------------------------------------------------------------------------- /examples/WebApiApp/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "WebApiApp": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:5014", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/WebApiApp/WebApiApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | -------------------------------------------------------------------------------- /examples/WebApiApp/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "FeatBit": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/WebApiApp/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "FeatBit": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*" 10 | } 11 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "6.0.0", 4 | "rollForward": "latestFeature", 5 | "allowPrerelease": false 6 | } 7 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Bootstrapping/IBootstrapProvider.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.DataSynchronizer; 2 | using FeatBit.Sdk.Server.Store; 3 | 4 | namespace FeatBit.Sdk.Server.Bootstrapping; 5 | 6 | internal interface IBootstrapProvider 7 | { 8 | DataSet DataSet(); 9 | 10 | void Populate(IMemoryStore store); 11 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Bootstrapping/JsonBootstrapProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using FeatBit.Sdk.Server.DataSynchronizer; 3 | using FeatBit.Sdk.Server.Store; 4 | 5 | namespace FeatBit.Sdk.Server.Bootstrapping; 6 | 7 | internal sealed class JsonBootstrapProvider : IBootstrapProvider 8 | { 9 | private readonly DataSet _dataSet; 10 | 11 | public JsonBootstrapProvider(string json) 12 | { 13 | using var jsonDocument = JsonDocument.Parse(json); 14 | var root = jsonDocument.RootElement; 15 | _dataSet = DataSynchronizer.DataSet.FromJsonElement(root.GetProperty("data")); 16 | } 17 | 18 | public DataSet DataSet() => _dataSet; 19 | 20 | public void Populate(IMemoryStore store) 21 | { 22 | var objects = _dataSet.GetStorableObjects(); 23 | store.Populate(objects); 24 | } 25 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Bootstrapping/NullBootstrapProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FeatBit.Sdk.Server.DataSynchronizer; 3 | using FeatBit.Sdk.Server.Model; 4 | using FeatBit.Sdk.Server.Store; 5 | 6 | namespace FeatBit.Sdk.Server.Bootstrapping; 7 | 8 | internal sealed class NullBootstrapProvider : IBootstrapProvider 9 | { 10 | private readonly DataSet _emptySet; 11 | 12 | public NullBootstrapProvider() 13 | { 14 | _emptySet = new DataSet 15 | { 16 | EventType = DataSynchronizer.DataSet.Full, 17 | FeatureFlags = Array.Empty(), 18 | Segments = Array.Empty() 19 | }; 20 | } 21 | 22 | public DataSet DataSet() => _emptySet; 23 | 24 | public void Populate(IMemoryStore store) 25 | { 26 | store.Populate(Array.Empty()); 27 | } 28 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Concurrent/AtomicBoolean.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | 3 | // From: https://github.com/akkadotnet/akka.net/blob/dev/src/core/Akka/Util/AtomicBoolean.cs 4 | namespace FeatBit.Sdk.Server.Concurrent 5 | { 6 | /// 7 | /// Implementation of the java.concurrent.util.AtomicBoolean type. 8 | /// 9 | /// Uses internally to enforce ordering of writes 10 | /// without any explicit locking. .NET's strong memory on write guarantees might already enforce 11 | /// this ordering, but the addition of the MemoryBarrier guarantees it. 12 | /// 13 | public sealed class AtomicBoolean 14 | { 15 | private const int FalseValue = 0; 16 | private const int TrueValue = 1; 17 | 18 | private int _value; 19 | 20 | /// 21 | /// Sets the initial value of this to . 22 | /// 23 | /// TBD 24 | public AtomicBoolean(bool initialValue = false) 25 | { 26 | _value = initialValue ? TrueValue : FalseValue; 27 | } 28 | 29 | /// 30 | /// The current value of this 31 | /// 32 | public bool Value 33 | { 34 | get 35 | { 36 | Interlocked.MemoryBarrier(); 37 | return _value == TrueValue; 38 | } 39 | set { Interlocked.Exchange(ref _value, value ? TrueValue : FalseValue); } 40 | } 41 | 42 | /// 43 | /// If equals , then set the Value to 44 | /// . 45 | /// 46 | /// TBD 47 | /// TBD 48 | /// true if was set 49 | public bool CompareAndSet(bool expected, bool newValue) 50 | { 51 | var expectedInt = expected ? TrueValue : FalseValue; 52 | var newInt = newValue ? TrueValue : FalseValue; 53 | return Interlocked.CompareExchange(ref _value, newInt, expectedInt) == expectedInt; 54 | } 55 | 56 | /// 57 | /// Atomically sets the to and returns the old . 58 | /// 59 | /// The new value 60 | /// The old value 61 | public bool GetAndSet(bool newValue) 62 | { 63 | return Interlocked.Exchange(ref _value, newValue ? TrueValue : FalseValue) == TrueValue; 64 | } 65 | 66 | /// 67 | /// Performs an implicit conversion from to . 68 | /// 69 | /// The boolean to convert 70 | /// The result of the conversion. 71 | public static implicit operator bool(AtomicBoolean atomicBoolean) 72 | { 73 | return atomicBoolean.Value; 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Concurrent/StatusManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace FeatBit.Sdk.Server.Concurrent; 5 | 6 | public sealed class StatusManager where TStatus : Enum 7 | { 8 | private TStatus _status; 9 | private readonly object _statusLock = new object(); 10 | private readonly Action _onStatusChanged; 11 | 12 | public StatusManager(TStatus initialStatus, Action onStatusChanged = null) 13 | { 14 | _status = initialStatus; 15 | _onStatusChanged = onStatusChanged; 16 | } 17 | 18 | public TStatus Status 19 | { 20 | get 21 | { 22 | lock (_statusLock) 23 | { 24 | return _status; 25 | } 26 | } 27 | } 28 | 29 | public bool CompareAndSet(TStatus expected, TStatus newStatus) 30 | { 31 | lock (_statusLock) 32 | { 33 | if (!EqualityComparer.Default.Equals(_status, expected)) 34 | { 35 | return false; 36 | } 37 | 38 | SetStatus(newStatus); 39 | return true; 40 | } 41 | } 42 | 43 | public void SetStatus(TStatus newStatus) 44 | { 45 | lock (_statusLock) 46 | { 47 | if (EqualityComparer.Default.Equals(_status, newStatus)) 48 | { 49 | return; 50 | } 51 | 52 | _status = newStatus; 53 | _onStatusChanged?.Invoke(_status); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/DataSynchronizer/DataSet.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json; 3 | using FeatBit.Sdk.Server.Json; 4 | using FeatBit.Sdk.Server.Model; 5 | using FeatBit.Sdk.Server.Store; 6 | 7 | namespace FeatBit.Sdk.Server.DataSynchronizer 8 | { 9 | internal class DataSet 10 | { 11 | public const string Full = "full"; 12 | public const string Patch = "patch"; 13 | 14 | public string EventType { get; set; } 15 | 16 | public FeatureFlag[] FeatureFlags { get; set; } 17 | 18 | public Segment[] Segments { get; set; } 19 | 20 | internal IEnumerable GetStorableObjects() 21 | { 22 | var objects = new List(); 23 | objects.AddRange(FeatureFlags); 24 | objects.AddRange(Segments); 25 | 26 | return objects; 27 | } 28 | 29 | internal static DataSet FromJsonElement(JsonElement jsonElement) 30 | { 31 | #if NETCOREAPP3_1 32 | var rawText = jsonElement.GetRawText(); 33 | var dataSet = JsonSerializer.Deserialize(rawText, ReusableJsonSerializerOptions.Web); 34 | #else 35 | // JsonElement.Deserialize only available on .NET 6.0+ 36 | var dataSet = jsonElement.Deserialize(ReusableJsonSerializerOptions.Web); 37 | #endif 38 | return dataSet; 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/DataSynchronizer/DataSynchronizerStatus.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.DataSynchronizer; 2 | 3 | public enum DataSynchronizerStatus 4 | { 5 | /// 6 | /// The initial state of the synchronizer when the SDK is being initialized. 7 | /// 8 | /// 9 | /// If it encounters an error that requires it to retry initialization, the state will remain at 10 | /// until it either succeeds and becomes , or 11 | /// permanently fails and becomes . 12 | /// 13 | Starting, 14 | 15 | /// 16 | /// Indicates that the synchronizer is currently operational and has not had any problems since the 17 | /// last time it received data. 18 | /// 19 | /// 20 | /// In streaming mode, this means that there is currently an open stream connection and that at least 21 | /// one initial message has been received on the stream. In polling mode, it means that the last poll 22 | /// request succeeded. 23 | /// 24 | Stable, 25 | 26 | /// 27 | /// Indicates that the synchronizer encountered an error that it will attempt to recover from. 28 | /// 29 | /// 30 | /// In streaming mode, this means that the stream connection failed, or had to be dropped due to some 31 | /// other error, and will be retried after a backoff delay. In polling mode, it means that the last poll 32 | /// request failed, and a new poll request will be made after the configured polling interval. 33 | /// 34 | Interrupted, 35 | 36 | /// 37 | /// Indicates that the synchronizer has been permanently shut down. 38 | /// 39 | /// 40 | /// This could be because it encountered an unrecoverable error (for instance, the Evaluation server 41 | /// rejected the SDK key: an invalid SDK key will never become valid), or because the SDK client was 42 | /// explicitly shut down. 43 | /// 44 | Stopped 45 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/DataSynchronizer/IDataSynchronizer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace FeatBit.Sdk.Server.DataSynchronizer 5 | { 6 | public interface IDataSynchronizer 7 | { 8 | /// 9 | /// Indicates whether the data synchronizer has finished initializing. 10 | /// 11 | public bool Initialized { get; } 12 | 13 | /// 14 | /// The current status of the data synchronizer. 15 | /// 16 | public DataSynchronizerStatus Status { get; } 17 | 18 | /// An event for receiving notifications of status changes. 19 | /// 20 | /// 21 | /// Any handlers attached to this event will be notified whenever any property of the status has changed. 22 | /// See for an explanation of the meaning of each property and what could cause it 23 | /// to change. 24 | /// 25 | /// 26 | /// The listener should return as soon as possible so as not to block subsequent notifications. 27 | /// 28 | /// 29 | event Action StatusChanged; 30 | 31 | /// 32 | /// Starts the data synchronizer. This is called once from the constructor. 33 | /// 34 | /// a Task which is completed once the data synchronizer has finished starting up 35 | Task StartAsync(); 36 | 37 | /// 38 | /// Stop the data synchronizer and dispose all resources. 39 | /// 40 | /// The Task 41 | Task StopAsync(); 42 | } 43 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/DataSynchronizer/NullDataSynchronizer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using FeatBit.Sdk.Server.Concurrent; 4 | 5 | namespace FeatBit.Sdk.Server.DataSynchronizer; 6 | 7 | internal sealed class NullDataSynchronizer : IDataSynchronizer 8 | { 9 | private readonly StatusManager _statusManager; 10 | 11 | public bool Initialized => true; 12 | public DataSynchronizerStatus Status => _statusManager.Status; 13 | public event Action StatusChanged; 14 | 15 | public NullDataSynchronizer() 16 | { 17 | _statusManager = new StatusManager( 18 | DataSynchronizerStatus.Stable, 19 | OnStatusChanged 20 | ); 21 | } 22 | 23 | public Task StartAsync() 24 | { 25 | _statusManager.SetStatus(DataSynchronizerStatus.Stable); 26 | return Task.FromResult(true); 27 | } 28 | 29 | public Task StopAsync() 30 | { 31 | _statusManager.SetStatus(DataSynchronizerStatus.Stopped); 32 | return Task.CompletedTask; 33 | } 34 | 35 | private void OnStatusChanged(DataSynchronizerStatus status) => StatusChanged?.Invoke(status); 36 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/DependencyInjection/FbClientHostedService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.Hosting; 4 | 5 | namespace FeatBit.Sdk.Server.DependencyInjection; 6 | 7 | /// 8 | /// The FbClientHostedService performs the following tasks: 9 | /// 10 | /// 11 | /// 12 | /// The constructor ensures that the FbClient is created before the application starts. 13 | /// 14 | /// 15 | /// 16 | /// 17 | /// The method closes the FbClient when the application host is performing a graceful shutdown. 18 | /// 19 | /// 20 | /// 21 | /// 22 | public class FbClientHostedService : IHostedService 23 | { 24 | private readonly IFbClient _fbClient; 25 | 26 | public FbClientHostedService(IFbClient fbClient) 27 | { 28 | _fbClient = fbClient; 29 | } 30 | 31 | public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; 32 | 33 | public async Task StopAsync(CancellationToken cancellationToken) 34 | { 35 | await _fbClient.CloseAsync(); 36 | } 37 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/DependencyInjection/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FeatBit.Sdk.Server.Options; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | 7 | namespace FeatBit.Sdk.Server.DependencyInjection; 8 | 9 | public static class ServiceCollectionExtensions 10 | { 11 | /// 12 | /// Adds FeatBit services to the specified . 13 | /// 14 | /// The to add services to. 15 | /// An to configure the provided . 16 | /// This method will block the current thread for a maximum duration specified in . 17 | public static void AddFeatBit(this IServiceCollection services, Action configureOptions) 18 | { 19 | var options = new FbOptionsBuilder().Build(); 20 | configureOptions(options); 21 | 22 | AddFeatBit(services, options); 23 | } 24 | 25 | /// 26 | /// Adds FeatBit services to the specified . 27 | /// 28 | /// The to add services to. 29 | /// The options for configuring FeatBit. 30 | /// This method will block the current thread for a maximum duration specified in . 31 | public static void AddFeatBit(this IServiceCollection services, FbOptions options) 32 | { 33 | var serviceDescriptor = new ServiceDescriptor( 34 | typeof(IFbClient), 35 | serviceProvider => 36 | { 37 | // Configure the logger factory if not provided or set to NullLoggerFactory 38 | if (options.LoggerFactory is null) 39 | { 40 | options.LoggerFactory = NullLoggerFactory.Instance; 41 | } 42 | else if (options.LoggerFactory is NullLoggerFactory) 43 | { 44 | var defaultLoggerFactory = serviceProvider.GetService(); 45 | if (defaultLoggerFactory != null) 46 | { 47 | options.LoggerFactory = defaultLoggerFactory; 48 | } 49 | } 50 | 51 | var client = new FbClient(options); 52 | return client; 53 | }, 54 | ServiceLifetime.Singleton 55 | ); 56 | services.Add(serviceDescriptor); 57 | 58 | // The FbClientHostedService ensures: 59 | // 1. FbClient is created before the application starts. 60 | // 2. FbClient is closed when the application host performs a graceful shutdown. 61 | services.AddHostedService(); 62 | } 63 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Evaluation/DispatchAlgorithm.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography; 3 | using System.Text; 4 | 5 | namespace FeatBit.Sdk.Server.Evaluation 6 | { 7 | internal static class DispatchAlgorithm 8 | { 9 | public static bool IsInRollout(string key, double[] rollouts) 10 | { 11 | var min = rollouts[0]; 12 | var max = rollouts[1]; 13 | 14 | // if [0, 1] 15 | if (min == 0d && 1d - max < 1e-5) 16 | { 17 | return true; 18 | } 19 | 20 | // if [0, 0] 21 | if (min == 0d && max == 0d) 22 | { 23 | return false; 24 | } 25 | 26 | var rollout = RolloutOfKey(key); 27 | return rollout >= min && rollout <= max; 28 | } 29 | 30 | public static double RolloutOfKey(string key) 31 | { 32 | using (var hasher = MD5.Create()) 33 | { 34 | var hashedKey = hasher.ComputeHash(Encoding.UTF8.GetBytes(key)); 35 | var magicNumber = BitConverter.ToInt32(hashedKey, 0); 36 | var percentage = Math.Abs((double)magicNumber / int.MinValue); 37 | 38 | return percentage; 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Evaluation/EvalDetail.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Evaluation 2 | { 3 | public class EvalDetail 4 | { 5 | /// 6 | /// The key of the flag that was evaluated. 7 | /// 8 | public string Key { get; set; } 9 | 10 | /// 11 | /// An enum indicating the category of the reason. 12 | /// 13 | public ReasonKind Kind { get; set; } 14 | 15 | /// 16 | /// A string describing the main factor that influenced the flag evaluation value. 17 | /// 18 | public string Reason { get; set; } 19 | 20 | /// 21 | /// The result of the flag evaluation. This will be either one of the flag's variations or the default 22 | /// value that was specified when the flag was evaluated. 23 | /// 24 | public TValue Value { get; set; } 25 | 26 | /// 27 | /// The id of the flag value that was returned. 28 | /// 29 | public string ValueId { get; set; } 30 | 31 | /// 32 | /// Constructs a new EvalDetail instance. 33 | /// 34 | /// the flag key 35 | /// the reason kind 36 | /// the evaluation reason 37 | /// the flag value 38 | /// the id of flag value 39 | public EvalDetail(string key, ReasonKind kind, string reason, TValue value, string valueId) 40 | { 41 | Key = key; 42 | Kind = kind; 43 | Reason = reason; 44 | Value = value; 45 | ValueId = valueId; 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Evaluation/EvalResult.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | 3 | namespace FeatBit.Sdk.Server.Evaluation 4 | { 5 | internal class EvalResult 6 | { 7 | public ReasonKind Kind { get; set; } 8 | 9 | public string Reason { get; set; } 10 | 11 | public Variation Variation { get; set; } 12 | 13 | private EvalResult(ReasonKind kind, string reason, Variation variation) 14 | { 15 | Kind = kind; 16 | Reason = reason; 17 | Variation = variation; 18 | } 19 | 20 | // Indicates that the caller provided a flag key that did not match any known flag. 21 | public static readonly EvalResult FlagNotFound = new(ReasonKind.Error, "flag not found", Variation.Empty); 22 | 23 | // Indicates that there was an internal inconsistency in the flag data, e.g. a rule specified a nonexistent 24 | // variation. 25 | public static readonly EvalResult MalformedFlag = new(ReasonKind.Error, "malformed flag", Variation.Empty); 26 | 27 | public static EvalResult FlagOff(Variation variation) 28 | { 29 | return new EvalResult(ReasonKind.Off, "flag off", variation); 30 | } 31 | 32 | public static EvalResult Targeted(Variation variation) 33 | { 34 | return new EvalResult(ReasonKind.TargetMatch, "target match", variation); 35 | } 36 | 37 | public static EvalResult RuleMatched(string ruleName, Variation value) 38 | { 39 | return new EvalResult(ReasonKind.RuleMatch, $"match rule {ruleName}", value); 40 | } 41 | 42 | public static EvalResult Fallthrough(Variation variation) 43 | { 44 | return new EvalResult(ReasonKind.Fallthrough, "fall through targets and rules", variation); 45 | } 46 | 47 | public EvalDetail AsEvalDetail(string key) 48 | { 49 | return new EvalDetail(key, Kind, Reason, Variation.Value, Variation.Id); 50 | } 51 | } 52 | 53 | public enum ReasonKind 54 | { 55 | /// 56 | /// Indicates that the caller tried to evaluate a flag before the client had successfully initialized. 57 | /// 58 | ClientNotReady, 59 | 60 | /// 61 | /// Indicates that the flag was off and therefore returned its configured off value. 62 | /// 63 | Off, 64 | 65 | /// 66 | /// Indicates that the flag was on but the user did not match any targets or rules. 67 | /// 68 | Fallthrough, 69 | 70 | /// 71 | /// Indicates that the user key was specifically targeted for this flag. 72 | /// 73 | TargetMatch, 74 | 75 | /// 76 | /// Indicates that the user matched one of the flag's rules. 77 | /// 78 | RuleMatch, 79 | 80 | /// 81 | /// Indicates that the result value was not of the requested type, e.g. you requested a 82 | /// but the value was an . 83 | /// 84 | WrongType, 85 | 86 | /// 87 | /// Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected 88 | /// error. In this case the result value will be the default value that the caller passed to the client. 89 | /// 90 | Error 91 | } 92 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Evaluation/EvaluationContext.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | 3 | namespace FeatBit.Sdk.Server.Evaluation 4 | { 5 | internal class EvaluationContext 6 | { 7 | public string FlagKey { get; set; } 8 | 9 | public FbUser FbUser { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Evaluation/Evaluator.ConditionMatcher.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | 3 | namespace FeatBit.Sdk.Server.Evaluation 4 | { 5 | internal partial class Evaluator 6 | { 7 | internal static bool IsMatchCondition(Condition condition, FbUser user) 8 | { 9 | var userValue = user.ValueOf(condition.Property); 10 | 11 | var theOperator = Operator.Get(condition.Op); 12 | return theOperator.IsMatch(userValue, condition.Value); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Evaluation/Evaluator.RuleMatcher.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | 3 | namespace FeatBit.Sdk.Server.Evaluation 4 | { 5 | internal partial class Evaluator 6 | { 7 | internal const string IsInSegmentProperty = "User is in segment"; 8 | 9 | internal const string IsNotInSegmentProperty = "User is not in segment"; 10 | 11 | internal bool IsMatchRule(TargetRule rule, FbUser user) 12 | { 13 | foreach (var condition in rule.Conditions) 14 | { 15 | // in segment condition 16 | if (condition.Property is IsInSegmentProperty) 17 | { 18 | if (!IsMatchAnySegment(condition, user)) 19 | { 20 | return false; 21 | } 22 | } 23 | // not in segment condition 24 | else if (condition.Property is IsNotInSegmentProperty) 25 | { 26 | if (IsMatchAnySegment(condition, user)) 27 | { 28 | return false; 29 | } 30 | } 31 | // common condition 32 | else if (!IsMatchCondition(condition, user)) 33 | { 34 | return false; 35 | } 36 | } 37 | 38 | return true; 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Evaluation/Evaluator.SegmentMatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Text.Json; 3 | using FeatBit.Sdk.Server.Model; 4 | using FeatBit.Sdk.Server.Store; 5 | 6 | namespace FeatBit.Sdk.Server.Evaluation 7 | { 8 | internal partial class Evaluator 9 | { 10 | internal static bool IsMatchSegment(Segment segment, FbUser user) 11 | { 12 | if (segment.Excluded.Contains(user.Key)) 13 | { 14 | return false; 15 | } 16 | 17 | if (segment.Included.Contains(user.Key)) 18 | { 19 | return true; 20 | } 21 | 22 | // if any rule match this user 23 | return segment.Rules.Any( 24 | rule => rule.Conditions.All(condition => IsMatchCondition(condition, user)) 25 | ); 26 | } 27 | 28 | internal bool IsMatchAnySegment(Condition segmentCondition, FbUser user) 29 | { 30 | var segmentIds = JsonSerializer.Deserialize(segmentCondition.Value); 31 | if (segmentIds == null || !segmentIds.Any()) 32 | { 33 | return false; 34 | } 35 | 36 | foreach (var segmentId in segmentIds) 37 | { 38 | var segment = _store.Get(StoreKeys.ForSegment(segmentId)); 39 | if (IsMatchSegment(segment, user)) 40 | { 41 | return true; 42 | } 43 | } 44 | 45 | return false; 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Evaluation/IEvaluator.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Events; 2 | using FeatBit.Sdk.Server.Model; 3 | 4 | namespace FeatBit.Sdk.Server.Evaluation 5 | { 6 | internal interface IEvaluator 7 | { 8 | (EvalResult evalResult, EvalEvent evalEvent) Evaluate(EvaluationContext context); 9 | 10 | (EvalResult evalResult, EvalEvent evalEvent) Evaluate(FeatureFlag flag, FbUser user); 11 | } 12 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Evaluation/OperatorTypes.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Evaluation 2 | { 3 | public class OperatorTypes 4 | { 5 | // numeric 6 | public const string LessThan = "LessThan"; 7 | public const string BiggerThan = "BiggerThan"; 8 | public const string LessEqualThan = "LessEqualThan"; 9 | public const string BiggerEqualThan = "BiggerEqualThan"; 10 | 11 | // compare 12 | public const string Equal = "Equal"; 13 | public const string NotEqual = "NotEqual"; 14 | 15 | // contains/not contains 16 | public const string Contains = "Contains"; 17 | public const string NotContain = "NotContain"; 18 | 19 | // starts with/ends with 20 | public const string StartsWith = "StartsWith"; 21 | public const string EndsWith = "EndsWith"; 22 | 23 | // match regex/not match regex 24 | public const string MatchRegex = "MatchRegex"; 25 | public const string NotMatchRegex = "NotMatchRegex"; 26 | 27 | // is one of/ not one of 28 | public const string IsOneOf = "IsOneOf"; 29 | public const string NotOneOf = "NotOneOf"; 30 | 31 | // is true/ is false 32 | public const string IsTrue = "IsTrue"; 33 | public const string IsFalse = "IsFalse"; 34 | } 35 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/DefaultEventBuffer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace FeatBit.Sdk.Server.Events 4 | { 5 | internal sealed class DefaultEventBuffer : IEventBuffer 6 | { 7 | private readonly int _capacity; 8 | private readonly List _events; 9 | 10 | public DefaultEventBuffer(int capacity) 11 | { 12 | _capacity = capacity; 13 | _events = new List(); 14 | } 15 | 16 | public bool AddEvent(IEvent @event) 17 | { 18 | if (_events.Count >= _capacity) 19 | { 20 | return false; 21 | } 22 | 23 | _events.Add(@event); 24 | return true; 25 | } 26 | 27 | public int Count => _events.Count; 28 | 29 | public bool IsEmpty => Count == 0; 30 | 31 | public void Clear() => _events.Clear(); 32 | 33 | public IEvent[] EventsSnapshot => _events.ToArray(); 34 | } 35 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/DefaultEventDispather.Log.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace FeatBit.Sdk.Server.Events; 5 | 6 | internal sealed partial class DefaultEventDispatcher 7 | { 8 | private static partial class Log 9 | { 10 | [LoggerMessage(1, LogLevel.Debug, "Start dispatch loop.")] 11 | public static partial void StartDispatchLoop(ILogger logger); 12 | 13 | [LoggerMessage(2, LogLevel.Debug, "Finish dispatch loop.")] 14 | public static partial void FinishDispatchLoop(ILogger logger); 15 | 16 | [LoggerMessage(3, LogLevel.Debug, "Added event to buffer.")] 17 | public static partial void AddedEventToBuffer(ILogger logger); 18 | 19 | [LoggerMessage(4, LogLevel.Debug, "{Count} events has been flushed.")] 20 | public static partial void EventsFlushed(ILogger logger, int count); 21 | 22 | [LoggerMessage(5, LogLevel.Debug, "Flush empty buffer.")] 23 | public static partial void FlushEmptyBuffer(ILogger logger); 24 | 25 | [LoggerMessage(6, LogLevel.Warning, 26 | "Exceeded event queue capacity, event will be dropped. Increase capacity to avoid dropping events.")] 27 | public static partial void ExceededCapacity(ILogger logger); 28 | 29 | [LoggerMessage(7, LogLevel.Debug, 30 | "The number of flush workers has reached the limit. This flush event will be skipped.")] 31 | public static partial void TooManyFlushWorkers(ILogger logger); 32 | 33 | [LoggerMessage(8, LogLevel.Error, "Unexpected error in event dispatcher thread.")] 34 | public static partial void DispatchError(ILogger logger, Exception ex); 35 | } 36 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/DefaultEventProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using FeatBit.Sdk.Server.Concurrent; 6 | using FeatBit.Sdk.Server.Options; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace FeatBit.Sdk.Server.Events 10 | { 11 | internal sealed class DefaultEventProcessor : IEventProcessor 12 | { 13 | private readonly BlockingCollection _eventQueue; 14 | private readonly Timer _flushTimer; 15 | private readonly IEventDispatcher _eventDispatcher; 16 | private readonly ILogger _logger; 17 | 18 | private readonly AtomicBoolean _closed = new AtomicBoolean(); 19 | internal bool HasClosed => _closed.Value; // internal for testing 20 | private readonly AtomicBoolean _capacityExceeded = new AtomicBoolean(); 21 | 22 | public DefaultEventProcessor( 23 | FbOptions options, 24 | ILogger logger = null, 25 | Func, IEventDispatcher> dispatcherFactory = null) 26 | { 27 | _eventQueue = new BlockingCollection(options.MaxEventsInQueue); 28 | _flushTimer = new Timer(AutoFlush, null, options.AutoFlushInterval, options.AutoFlushInterval); 29 | 30 | var factory = dispatcherFactory ?? DefaultEventDispatcherFactory; 31 | _eventDispatcher = factory(options, _eventQueue); 32 | 33 | _logger = logger ?? options.LoggerFactory.CreateLogger(); 34 | } 35 | 36 | private static IEventDispatcher DefaultEventDispatcherFactory(FbOptions options, BlockingCollection queue) 37 | { 38 | return new DefaultEventDispatcher(options, queue); 39 | } 40 | 41 | public bool Record(IEvent @event) 42 | { 43 | if (@event == null) 44 | { 45 | return false; 46 | } 47 | 48 | try 49 | { 50 | if (_eventQueue.TryAdd(@event)) 51 | { 52 | _capacityExceeded.GetAndSet(false); 53 | } 54 | else 55 | { 56 | if (!_capacityExceeded.GetAndSet(true)) 57 | { 58 | // The main thread is seriously backed up with not-yet-processed events. 59 | _logger.LogWarning( 60 | "Events are being produced faster than they can be processed. We shouldn't see this." 61 | ); 62 | } 63 | 64 | // If the message is a flush message, then it could never be completed if we cannot 65 | // add it to the queue. So we are going to complete it here to prevent the calling 66 | // code from hanging indefinitely. 67 | if (@event is FlushEvent flushEvent) 68 | { 69 | flushEvent.Complete(); 70 | } 71 | 72 | return false; 73 | } 74 | } 75 | catch (Exception ex) 76 | { 77 | _logger.LogError(ex, "Error adding event in a queue."); 78 | return false; 79 | } 80 | 81 | return true; 82 | } 83 | 84 | public void Flush() 85 | { 86 | Record(new FlushEvent()); 87 | } 88 | 89 | public bool FlushAndWait(TimeSpan timeout) 90 | { 91 | var flush = new FlushEvent(); 92 | Record(flush); 93 | return flush.WaitForCompletion(timeout); 94 | } 95 | 96 | public async Task FlushAndWaitAsync(TimeSpan timeout) 97 | { 98 | var flush = new FlushEvent(); 99 | Record(flush); 100 | return await flush.WaitForCompletionAsync(timeout); 101 | } 102 | 103 | public void FlushAndClose(TimeSpan timeout) 104 | { 105 | if (_closed.GetAndSet(true)) 106 | { 107 | // already closed, nothing more to do 108 | return; 109 | } 110 | 111 | // stop flush timer if it was running 112 | _flushTimer?.Dispose(); 113 | 114 | // flush remaining events 115 | Record(new FlushEvent()); 116 | 117 | // send an shutdown event to dispatcher 118 | var shutdown = new ShutdownEvent(); 119 | Record(shutdown); 120 | 121 | // wait for the shutdown event to complete within the specified timeout 122 | var successfullyShutdown = shutdown.WaitForCompletion(timeout); 123 | if (!successfullyShutdown) 124 | { 125 | _logger.LogWarning("Event processor shutdown did not complete within the specified timeout."); 126 | } 127 | 128 | // mark the event queue as complete for adding 129 | _eventQueue.CompleteAdding(); 130 | 131 | // dispose resources 132 | _eventDispatcher?.Dispose(); 133 | _eventQueue.Dispose(); 134 | } 135 | 136 | private void AutoFlush(object stateInfo) 137 | { 138 | Flush(); 139 | } 140 | } 141 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/DefaultEventSender.Log.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace FeatBit.Sdk.Server.Events 5 | { 6 | internal partial class DefaultEventSender 7 | { 8 | private static partial class Log 9 | { 10 | [LoggerMessage(1, LogLevel.Debug, "Start send event: {Body}")] 11 | public static partial void SendStarted(ILogger logger, string body); 12 | 13 | [LoggerMessage(2, LogLevel.Debug, "Event delivery took {ElapsedMs} ms, response status {Status}.")] 14 | public static partial void SendFinished(ILogger logger, long elapsedMs, int status); 15 | 16 | [LoggerMessage(3, LogLevel.Debug, "Event sending task was cancelled due to a handle timeout.")] 17 | public static partial void SendTaskWasCanceled(ILogger logger); 18 | 19 | [LoggerMessage(4, LogLevel.Debug, "Exception occurred when sending event.")] 20 | public static partial void ErrorSendEvent(ILogger logger, Exception ex); 21 | 22 | [LoggerMessage(5, LogLevel.Warning, "Send event failed: {Reason}.")] 23 | public static partial void SendFailed(ILogger logger, string reason); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/DefaultEventSender.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Net.Http; 4 | using System.Net.Http.Headers; 5 | using System.Text; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using FeatBit.Sdk.Server.Http; 9 | using FeatBit.Sdk.Server.Options; 10 | using Microsoft.Extensions.Logging; 11 | 12 | namespace FeatBit.Sdk.Server.Events 13 | { 14 | internal partial class DefaultEventSender : IEventSender 15 | { 16 | private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(1); 17 | private static readonly TimeSpan DefaultReadTimeout = TimeSpan.FromSeconds(1); 18 | private static readonly TimeSpan DefaultTimeout = DefaultConnectTimeout + DefaultReadTimeout; 19 | 20 | private readonly Uri _eventUri; 21 | private const string EventPath = "api/public/insight/track"; 22 | private readonly int _maxAttempts; 23 | private readonly TimeSpan _retryInterval; 24 | 25 | private readonly HttpClient _httpClient; 26 | 27 | private static readonly MediaTypeHeaderValue JsonContentType = new MediaTypeHeaderValue("application/json") 28 | { 29 | CharSet = "utf-8" 30 | }; 31 | 32 | private readonly Stopwatch _stopwatch = new Stopwatch(); 33 | private readonly ILogger _logger; 34 | 35 | public DefaultEventSender(FbOptions options, HttpClient httpClient = null) 36 | { 37 | _httpClient = httpClient ?? NewHttpClient(); 38 | AddDefaultHeaders(options); 39 | 40 | _eventUri = new Uri(options.EventUri, EventPath); 41 | _maxAttempts = options.MaxSendEventAttempts; 42 | _retryInterval = options.SendEventRetryInterval; 43 | 44 | _logger = options.LoggerFactory.CreateLogger(); 45 | } 46 | 47 | public async Task SendAsync(byte[] payload) 48 | { 49 | for (var attempt = 0; attempt < _maxAttempts; attempt++) 50 | { 51 | if (attempt > 0) 52 | { 53 | await Task.Delay(_retryInterval); 54 | } 55 | 56 | bool isRecoverable; 57 | string error; 58 | using var cts = new CancellationTokenSource(DefaultTimeout); 59 | 60 | try 61 | { 62 | var response = await SendCoreAsync(payload, cts); 63 | if (response.IsSuccessStatusCode) 64 | { 65 | return DeliveryStatus.Succeeded; 66 | } 67 | 68 | error = response.ReasonPhrase; 69 | isRecoverable = HttpErrors.IsRecoverable((int)response.StatusCode); 70 | } 71 | catch (TaskCanceledException ex) 72 | { 73 | if (ex.CancellationToken == cts.Token) 74 | { 75 | Log.SendTaskWasCanceled(_logger); 76 | 77 | // The task was canceled due to a handle timeout, do not retry it. 78 | return DeliveryStatus.Failed; 79 | } 80 | 81 | // Otherwise this was a request timeout. 82 | isRecoverable = true; 83 | error = "request timeout"; 84 | } 85 | catch (Exception ex) 86 | { 87 | Log.ErrorSendEvent(_logger, ex); 88 | isRecoverable = true; 89 | error = ex.Message; 90 | } 91 | 92 | Log.SendFailed(_logger, error); 93 | if (!isRecoverable) 94 | { 95 | return DeliveryStatus.FailedAndMustShutDown; 96 | } 97 | } 98 | 99 | Log.SendFailed(_logger, "Reconnect retries have been exhausted after max failed attempts."); 100 | return DeliveryStatus.Failed; 101 | } 102 | 103 | private async Task SendCoreAsync(byte[] payload, CancellationTokenSource cts) 104 | { 105 | // check log level to avoid unnecessary string allocation 106 | if (_logger.IsEnabled(LogLevel.Trace)) 107 | { 108 | var body = Encoding.UTF8.GetString(payload); 109 | Log.SendStarted(_logger, body); 110 | } 111 | 112 | using var content = new ByteArrayContent(payload); 113 | content.Headers.ContentType = JsonContentType; 114 | 115 | _stopwatch.Restart(); 116 | using var response = await _httpClient.PostAsync(_eventUri, content, cts.Token); 117 | _stopwatch.Stop(); 118 | 119 | Log.SendFinished(_logger, _stopwatch.ElapsedMilliseconds, (int)response.StatusCode); 120 | 121 | return response; 122 | } 123 | 124 | private static HttpClient NewHttpClient() 125 | { 126 | #if NETCOREAPP || NET6_0 127 | var handler = new SocketsHttpHandler 128 | { 129 | ConnectTimeout = DefaultConnectTimeout 130 | }; 131 | #else 132 | var handler = new HttpClientHandler(); 133 | #endif 134 | var client = new HttpClient(handler, false); 135 | return client; 136 | } 137 | 138 | private void AddDefaultHeaders(FbOptions options) 139 | { 140 | _httpClient.DefaultRequestHeaders.Add("Authorization", options.EnvSecret); 141 | _httpClient.DefaultRequestHeaders.Add("User-Agent", HttpConstants.UserAgent); 142 | } 143 | } 144 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/DefaultEventSerializer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.Json; 4 | using FeatBit.Sdk.Server.Model; 5 | 6 | namespace FeatBit.Sdk.Server.Events 7 | { 8 | internal class DefaultEventSerializer : IEventSerializer 9 | { 10 | public byte[] Serialize(IEvent @event) 11 | { 12 | using var stream = new MemoryStream(); 13 | using var writer = new Utf8JsonWriter(stream); 14 | 15 | WriteEvent(@event, writer); 16 | 17 | writer.Flush(); 18 | return stream.ToArray(); 19 | } 20 | 21 | public byte[] Serialize(ReadOnlyMemory events) 22 | { 23 | var span = events.Span; 24 | 25 | using var stream = new MemoryStream(); 26 | using var writer = new Utf8JsonWriter(stream); 27 | 28 | writer.WriteStartArray(); 29 | for (var i = 0; i < span.Length; i++) 30 | { 31 | WriteEvent(span[i], writer); 32 | } 33 | 34 | writer.WriteEndArray(); 35 | 36 | writer.Flush(); 37 | return stream.ToArray(); 38 | } 39 | 40 | private static void WriteEvent(IEvent ue, Utf8JsonWriter writer) 41 | { 42 | switch (ue) 43 | { 44 | case EvalEvent ee: 45 | WriteEvalEvent(ee, writer); 46 | break; 47 | case MetricEvent me: 48 | WriteMetricEvent(me, writer); 49 | break; 50 | } 51 | } 52 | 53 | private static void WriteEvalEvent(EvalEvent ee, Utf8JsonWriter writer) 54 | { 55 | writer.WriteStartObject(); 56 | 57 | WriteUser(ee.User, writer); 58 | 59 | writer.WriteStartArray("variations"); 60 | 61 | writer.WriteStartObject(); 62 | writer.WriteString("featureFlagKey", ee.FlagKey); 63 | WriteVariation(ee.Variation, writer); 64 | writer.WriteNumber("timestamp", ee.Timestamp); 65 | writer.WriteBoolean("sendToExperiment", ee.SendToExperiment); 66 | writer.WriteEndObject(); 67 | 68 | writer.WriteEndArray(); 69 | 70 | writer.WriteEndObject(); 71 | } 72 | 73 | private static void WriteVariation(Variation variation, Utf8JsonWriter writer) 74 | { 75 | writer.WriteStartObject("variation"); 76 | 77 | writer.WriteString("id", variation.Id); 78 | writer.WriteString("value", variation.Value); 79 | 80 | writer.WriteEndObject(); 81 | } 82 | 83 | private static void WriteUser(FbUser user, Utf8JsonWriter writer) 84 | { 85 | writer.WriteStartObject("user"); 86 | 87 | writer.WriteString("keyId", user.Key); 88 | writer.WriteString("name", user.Name); 89 | 90 | writer.WriteStartArray("customizedProperties"); 91 | foreach (var kv in user.Custom) 92 | { 93 | writer.WriteStartObject(); 94 | writer.WriteString("name", kv.Key); 95 | writer.WriteString("value", kv.Value); 96 | writer.WriteEndObject(); 97 | } 98 | 99 | writer.WriteEndArray(); 100 | 101 | writer.WriteEndObject(); 102 | } 103 | 104 | private static void WriteMetricEvent(MetricEvent ee, Utf8JsonWriter writer) 105 | { 106 | writer.WriteStartObject(); 107 | 108 | WriteUser(ee.User, writer); 109 | 110 | writer.WriteStartArray("metrics"); 111 | 112 | writer.WriteStartObject(); 113 | writer.WriteString("appType", MetricEvent.AppType); 114 | writer.WriteString("route", MetricEvent.Route); 115 | writer.WriteString("type", MetricEvent.Type); 116 | writer.WriteString("eventName", ee.EventName); 117 | writer.WriteNumber("numericValue", ee.NumericValue); 118 | writer.WriteNumber("timestamp", ee.Timestamp); 119 | writer.WriteEndObject(); 120 | 121 | writer.WriteEndArray(); 122 | 123 | writer.WriteEndObject(); 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/DeliveryStatus.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Events 2 | { 3 | internal enum DeliveryStatus 4 | { 5 | Succeeded, 6 | Failed, 7 | FailedAndMustShutDown 8 | } 9 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/IEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using FeatBit.Sdk.Server.Model; 4 | 5 | namespace FeatBit.Sdk.Server.Events 6 | { 7 | internal interface IEvent 8 | { 9 | } 10 | 11 | internal abstract class AsyncEvent : IEvent 12 | { 13 | private readonly TaskCompletionSource _innerTcs; 14 | private readonly Task _innerTask; 15 | 16 | public bool IsCompleted => _innerTask.IsCompleted; 17 | 18 | internal AsyncEvent() 19 | { 20 | _innerTcs = new TaskCompletionSource(); 21 | _innerTask = _innerTcs.Task; 22 | } 23 | 24 | internal bool WaitForCompletion(TimeSpan timeout) 25 | { 26 | if (timeout <= TimeSpan.Zero) 27 | { 28 | _innerTask.Wait(); 29 | return true; 30 | } 31 | 32 | return _innerTask.Wait(timeout); 33 | } 34 | 35 | internal Task WaitForCompletionAsync(TimeSpan timeout) 36 | { 37 | if (timeout <= TimeSpan.Zero) 38 | { 39 | return _innerTask; 40 | } 41 | 42 | var timeoutTask = Task.Delay(timeout).ContinueWith(_ => false); 43 | return Task.WhenAny(_innerTask, timeoutTask).Result; 44 | } 45 | 46 | internal void Complete() 47 | { 48 | _innerTcs.SetResult(true); 49 | } 50 | } 51 | 52 | internal sealed class FlushEvent : AsyncEvent 53 | { 54 | } 55 | 56 | internal sealed class ShutdownEvent : AsyncEvent 57 | { 58 | } 59 | 60 | internal class PayloadEvent : IEvent 61 | { 62 | } 63 | 64 | internal sealed class EvalEvent : PayloadEvent 65 | { 66 | public FbUser User { get; set; } 67 | 68 | public string FlagKey { get; set; } 69 | 70 | public long Timestamp { get; set; } 71 | 72 | public Variation Variation { get; set; } 73 | 74 | public bool SendToExperiment { get; set; } 75 | 76 | public EvalEvent(FbUser user, string flagKey, Variation variation, bool sendToExperiment) 77 | { 78 | User = user; 79 | FlagKey = flagKey; 80 | Variation = variation; 81 | Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); 82 | SendToExperiment = sendToExperiment; 83 | } 84 | } 85 | 86 | internal sealed class MetricEvent : PayloadEvent 87 | { 88 | public const string AppType = "dotnet-server-side"; 89 | public const string Route = "index/metric"; 90 | public const string Type = "CustomEvent"; 91 | 92 | public FbUser User { get; set; } 93 | 94 | public string EventName { get; set; } 95 | 96 | public double NumericValue { get; set; } 97 | 98 | public long Timestamp { get; set; } 99 | 100 | public MetricEvent(FbUser user, string eventName, double numericValue) 101 | { 102 | User = user; 103 | EventName = eventName; 104 | NumericValue = numericValue; 105 | Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/IEventBuffer.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Events 2 | { 3 | internal interface IEventBuffer 4 | { 5 | bool AddEvent(IEvent @event); 6 | 7 | bool IsEmpty { get; } 8 | 9 | void Clear(); 10 | 11 | IEvent[] EventsSnapshot { get; } 12 | } 13 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/IEventDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace FeatBit.Sdk.Server.Events 4 | { 5 | internal interface IEventDispatcher : IDisposable 6 | { 7 | } 8 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/IEventProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace FeatBit.Sdk.Server.Events 5 | { 6 | /// 7 | /// Represents a processor that can process events. 8 | /// 9 | internal interface IEventProcessor 10 | { 11 | /// 12 | /// Records an . 13 | /// 14 | /// The event to be recorded. 15 | /// A boolean value indicating whether the operation succeeded or not. 16 | bool Record(IEvent @event); 17 | 18 | /// 19 | /// Triggers an asynchronous event flush. 20 | /// 21 | void Flush(); 22 | 23 | /// 24 | /// Blocking version of . 25 | /// 26 | /// maximum time to wait; zero or negative timeout means indefinitely 27 | /// true if completed, false if timed out 28 | bool FlushAndWait(TimeSpan timeout); 29 | 30 | /// 31 | /// Asynchronous version of . 32 | /// 33 | /// 34 | /// The difference between this and is that you can await the task to simulate 35 | /// blocking behavior. 36 | /// 37 | /// maximum time to wait; zero or negative timeout means indefinitely 38 | /// a task that resolves to true if completed, false if timed out 39 | Task FlushAndWaitAsync(TimeSpan timeout); 40 | 41 | /// 42 | /// Flush all events and close this processor. 43 | /// 44 | /// maximum time to wait; zero or negative timeout means indefinitely 45 | /// true if completed, false if timed out 46 | void FlushAndClose(TimeSpan timeout); 47 | } 48 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/IEventSender.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace FeatBit.Sdk.Server.Events 4 | { 5 | internal interface IEventSender 6 | { 7 | Task SendAsync(byte[] payload); 8 | } 9 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/IEventSerializer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace FeatBit.Sdk.Server.Events 4 | { 5 | internal interface IEventSerializer 6 | { 7 | public byte[] Serialize(IEvent @event); 8 | 9 | public byte[] Serialize(ReadOnlyMemory events); 10 | } 11 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Events/NullEventProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace FeatBit.Sdk.Server.Events; 5 | 6 | internal sealed class NullEventProcessor : IEventProcessor 7 | { 8 | public bool Record(IEvent @event) => true; 9 | 10 | public void Flush() 11 | { 12 | } 13 | 14 | public bool FlushAndWait(TimeSpan timeout) 15 | { 16 | return true; 17 | } 18 | 19 | public Task FlushAndWaitAsync(TimeSpan timeout) 20 | { 21 | return Task.FromResult(true); 22 | } 23 | 24 | public void FlushAndClose(TimeSpan timeout) 25 | { 26 | } 27 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/FbClientStatus.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server; 2 | 3 | public enum FbClientStatus 4 | { 5 | /// 6 | /// FbClient has not been initialized and cannot yet evaluate flags. 7 | /// 8 | NotReady, 9 | 10 | /// 11 | /// FbClient is ready to resolve flags. 12 | /// 13 | Ready, 14 | 15 | /// 16 | /// FbClient's cached data may not be up-to-date with the source of truth. 17 | /// 18 | Stale, 19 | 20 | /// 21 | /// FbClient has entered an irrecoverable error state or has been explicitly shut down. 22 | /// 23 | Closed 24 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/FeatBit.ServerSdk.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0;netstandard2.1;netcoreapp3.1;net6.0;net462 5 | portable 6 | FeatBit.ServerSdk 7 | Library 8 | FeatBit.ServerSdk 9 | FeatBit.Sdk.Server 10 | FeatBit Server-Side .NET SDK 11 | FeatBit 12 | FeatBit 13 | FeatBit 14 | Copyright 2023 FeatBit 15 | true 16 | snupkg 17 | Apache-2.0 18 | https://github.com/featbit/featbit-dotnet-sdk 19 | https://github.com/featbit/featbit-dotnet-sdk 20 | main 21 | icon.png 22 | git 23 | NuGet.md 24 | true 25 | featbit feature toggle featuretoggle continuous delivery featuremanagement feature-flags toggling 26 | true 27 | 1591; 28 | 10 29 | 30 | 31 | 32 | true 33 | true 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Http/HttpConstants.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Http; 2 | 3 | public static class HttpConstants 4 | { 5 | public const string UserAgent = "featbit-dotnet-server-sdk"; 6 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Http/HttpErrors.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Http; 2 | 3 | internal static class HttpErrors 4 | { 5 | /// 6 | /// Returns true if this type of error could be expected to eventually resolve itself, 7 | /// or false if it indicates a configuration problem or client logic error such that the 8 | /// client should give up on making any further requests. 9 | /// 10 | /// a status code 11 | /// true if retrying is appropriate 12 | public static bool IsRecoverable(int status) 13 | { 14 | if (status is >= 400 and <= 499) 15 | { 16 | return status is 400 or 408 or 429; 17 | } 18 | 19 | return true; 20 | } 21 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Json/ReusableJsonSerializerOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace FeatBit.Sdk.Server.Json 4 | { 5 | public class ReusableJsonSerializerOptions 6 | { 7 | #if NETCOREAPP3_1 8 | // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/configure-options?pivots=dotnet-core-3-1 9 | // A JsonSerializerOptions constructor that specifies a set of defaults is not available in .NET Core 3.1. 10 | public static readonly JsonSerializerOptions Web = new JsonSerializerOptions 11 | { 12 | PropertyNameCaseInsensitive = true, 13 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase 14 | }; 15 | #else 16 | // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/configure-options?pivots=dotnet-6-0#web-defaults-for-jsonserializeroptions 17 | public static readonly JsonSerializerOptions Web = new JsonSerializerOptions(JsonSerializerDefaults.Web); 18 | #endif 19 | } 20 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Json/VersionJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace FeatBit.Sdk.Server.Json 6 | { 7 | /// 8 | /// Convert updatedAt field to version field. 9 | /// 10 | internal class VersionJsonConverter : JsonConverter 11 | { 12 | public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 13 | { 14 | return reader.GetDateTimeOffset().ToUnixTimeMilliseconds(); 15 | } 16 | 17 | public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) 18 | { 19 | var dateTime = DateTimeOffset.FromUnixTimeMilliseconds(value).UtcDateTime; 20 | 21 | writer.WriteString("updatedAt", dateTime); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Model/Condition.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Model 2 | { 3 | internal sealed class Condition 4 | { 5 | public string Property { get; set; } 6 | 7 | public string Op { get; set; } 8 | 9 | public string Value { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Model/FbUser.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace FeatBit.Sdk.Server.Model 4 | { 5 | public class FbUser 6 | { 7 | public readonly string Key; 8 | public readonly string Name; 9 | public readonly Dictionary Custom; 10 | 11 | internal FbUser(string key, string name, Dictionary custom) 12 | { 13 | Key = key; 14 | Name = name; 15 | Custom = custom; 16 | } 17 | 18 | /// 19 | /// Creates an for constructing a user object using a fluent syntax. 20 | /// 21 | /// 22 | /// This is the only method for building a . The has methods 23 | /// for setting any number of properties, after which you call to get the 24 | /// resulting instance. 25 | /// 26 | /// 27 | /// 28 | /// var user = FbUser.Builder("a-unique-key-of-user") 29 | /// .Name("user-name") 30 | /// .Custom("email", "test@example.com") 31 | /// .Build(); 32 | /// 33 | /// 34 | /// a that uniquely identifies a user 35 | /// a builder object 36 | public static IFbUserBuilder Builder(string key) 37 | { 38 | return new FbUserBuilder(key); 39 | } 40 | 41 | public string ValueOf(string property) 42 | { 43 | if (string.IsNullOrWhiteSpace(property)) 44 | { 45 | return string.Empty; 46 | } 47 | 48 | if (property == "keyId") 49 | { 50 | return Key; 51 | } 52 | 53 | if (property == "name") 54 | { 55 | return Name; 56 | } 57 | 58 | return Custom.TryGetValue(property, out var value) ? value : string.Empty; 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Model/FbUserBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace FeatBit.Sdk.Server.Model 5 | { 6 | public interface IFbUserBuilder 7 | { 8 | /// 9 | /// Creates a based on the properties that have been set on the builder. 10 | /// Modifying the builder after this point does not affect the returned . 11 | /// 12 | /// the configured object 13 | FbUser Build(); 14 | 15 | /// 16 | /// Sets the full name for a user. 17 | /// 18 | /// the name for the user 19 | /// the same builder 20 | IFbUserBuilder Name(string name); 21 | 22 | /// 23 | /// Adds a custom attribute with a string value. 24 | /// 25 | /// the key for the custom attribute 26 | /// the value for the custom attribute 27 | /// the same builder 28 | IFbUserBuilder Custom(string key, string value); 29 | } 30 | 31 | internal class FbUserBuilder : IFbUserBuilder 32 | { 33 | private readonly string _key; 34 | private string _name; 35 | private readonly Dictionary _custom; 36 | 37 | public FbUserBuilder(string key) 38 | { 39 | _key = key; 40 | _name = string.Empty; 41 | _custom = new Dictionary(); 42 | } 43 | 44 | public FbUser Build() 45 | { 46 | return new FbUser(_key, _name, _custom); 47 | } 48 | 49 | public IFbUserBuilder Name(string name) 50 | { 51 | if (!string.IsNullOrWhiteSpace(name)) 52 | { 53 | _name = name; 54 | } 55 | 56 | return this; 57 | } 58 | 59 | public IFbUserBuilder Custom(string key, string value) 60 | { 61 | if (string.IsNullOrWhiteSpace(key)) 62 | { 63 | throw new ArgumentException("key cannot be null or empty"); 64 | } 65 | 66 | _custom[key] = value; 67 | return this; 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Model/FeatureFlag.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using FeatBit.Sdk.Server.Evaluation; 5 | using FeatBit.Sdk.Server.Store; 6 | 7 | namespace FeatBit.Sdk.Server.Model 8 | { 9 | internal sealed class FeatureFlag : StorableObject 10 | { 11 | public override string StoreKey => StoreKeys.ForFeatureFlag(Key); 12 | 13 | public Guid Id { get; set; } 14 | 15 | public string Key { get; set; } 16 | 17 | public string VariationType { get; set; } 18 | 19 | public ICollection Variations { get; set; } 20 | 21 | public ICollection TargetUsers { get; set; } 22 | 23 | public ICollection Rules { get; set; } 24 | 25 | public bool IsEnabled { get; set; } 26 | 27 | public string DisabledVariationId { get; set; } 28 | 29 | public Fallthrough Fallthrough { get; set; } 30 | 31 | public bool ExptIncludeAllTargets { get; set; } 32 | 33 | #if NETCOREAPP3_1 34 | // for deserialization 35 | public FeatureFlag() 36 | { 37 | } 38 | #endif 39 | 40 | public FeatureFlag( 41 | Guid id, 42 | string key, 43 | long version, 44 | string variationType, 45 | ICollection variations, 46 | ICollection targetUsers, 47 | ICollection rules, 48 | bool isEnabled, 49 | string disabledVariationId, 50 | Fallthrough fallthrough, 51 | bool exptIncludeAllTargets) 52 | { 53 | Id = id; 54 | Key = key; 55 | Version = version; 56 | VariationType = variationType; 57 | Variations = variations; 58 | TargetUsers = targetUsers; 59 | Rules = rules; 60 | IsEnabled = isEnabled; 61 | DisabledVariationId = disabledVariationId; 62 | Fallthrough = fallthrough; 63 | ExptIncludeAllTargets = exptIncludeAllTargets; 64 | } 65 | 66 | public Variation GetVariation(string variationId) 67 | { 68 | return Variations.FirstOrDefault(x => x.Id == variationId); 69 | } 70 | } 71 | 72 | internal sealed class TargetUser 73 | { 74 | public ICollection KeyIds { get; set; } 75 | 76 | public string VariationId { get; set; } 77 | } 78 | 79 | internal sealed class TargetRule 80 | { 81 | public string Name { get; set; } 82 | 83 | public string DispatchKey { get; set; } 84 | 85 | public bool IncludedInExpt { get; set; } 86 | 87 | public ICollection Conditions { get; set; } 88 | 89 | public ICollection Variations { get; set; } 90 | } 91 | 92 | internal sealed class Fallthrough 93 | { 94 | public string DispatchKey { get; set; } 95 | 96 | public bool IncludedInExpt { get; set; } 97 | 98 | public ICollection Variations { get; set; } 99 | } 100 | 101 | internal sealed class RolloutVariation 102 | { 103 | public string Id { get; set; } 104 | 105 | public double[] Rollout { get; set; } 106 | 107 | public double ExptRollout { get; set; } 108 | 109 | public bool IsInRollout(string key) => DispatchAlgorithm.IsInRollout(key, Rollout); 110 | 111 | public double DispatchRollout() 112 | { 113 | if (Rollout is not { Length: 2 }) 114 | { 115 | // malformed rollout 116 | return 0.0; 117 | } 118 | 119 | return Rollout[1] - Rollout[0]; 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Model/FeatureFlagBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace FeatBit.Sdk.Server.Model 5 | { 6 | internal class FeatureFlagBuilder 7 | { 8 | private Guid _id = Guid.NewGuid(); 9 | private string _key; 10 | private long _version; 11 | private string _variationType = string.Empty; 12 | private ICollection _variations = new List(); 13 | private ICollection _targetUsers = new List(); 14 | private ICollection _rules = new List(); 15 | private bool _isEnabled = true; 16 | private string _disabledVariationId = string.Empty; 17 | private Fallthrough _fallthrough; 18 | private bool _exptIncludeAllTargets = true; 19 | 20 | public FeatureFlag Build() 21 | { 22 | return new FeatureFlag(_id, _key, _version, _variationType, _variations, _targetUsers, _rules, _isEnabled, 23 | _disabledVariationId, _fallthrough, _exptIncludeAllTargets); 24 | } 25 | 26 | public FeatureFlagBuilder Id(Guid id) 27 | { 28 | _id = id; 29 | return this; 30 | } 31 | 32 | public FeatureFlagBuilder Key(string key) 33 | { 34 | _key = key; 35 | return this; 36 | } 37 | 38 | public FeatureFlagBuilder Version(long version) 39 | { 40 | _version = version; 41 | return this; 42 | } 43 | 44 | public FeatureFlagBuilder VariationType(string variationType) 45 | { 46 | _variationType = variationType; 47 | return this; 48 | } 49 | 50 | public FeatureFlagBuilder Variations(params Variation[] variations) 51 | { 52 | foreach (var variation in variations) 53 | { 54 | _variations.Add(variation); 55 | } 56 | 57 | return this; 58 | } 59 | 60 | public FeatureFlagBuilder Variations(List variations) 61 | { 62 | _variations = variations; 63 | return this; 64 | } 65 | 66 | public FeatureFlagBuilder TargetUsers(params TargetUser[] targetUsers) 67 | { 68 | foreach (var targetUser in targetUsers) 69 | { 70 | _targetUsers.Add(targetUser); 71 | } 72 | 73 | return this; 74 | } 75 | 76 | public FeatureFlagBuilder TargetUsers(List targetUsers) 77 | { 78 | _targetUsers = targetUsers; 79 | return this; 80 | } 81 | 82 | public FeatureFlagBuilder Rules(params TargetRule[] rules) 83 | { 84 | foreach (var rule in rules) 85 | { 86 | _rules.Add(rule); 87 | } 88 | 89 | return this; 90 | } 91 | 92 | public FeatureFlagBuilder Rules(List rules) 93 | { 94 | _rules = rules; 95 | return this; 96 | } 97 | 98 | public FeatureFlagBuilder IsEnabled(bool isEnabled) 99 | { 100 | _isEnabled = isEnabled; 101 | return this; 102 | } 103 | 104 | public FeatureFlagBuilder DisabledVariationId(string disabledVariationId) 105 | { 106 | _disabledVariationId = disabledVariationId; 107 | return this; 108 | } 109 | 110 | public FeatureFlagBuilder Fallthrough(Fallthrough fallthrough) 111 | { 112 | _fallthrough = fallthrough; 113 | return this; 114 | } 115 | 116 | public FeatureFlagBuilder ExptIncludeAllTargets(bool exptIncludeAllTargets) 117 | { 118 | _exptIncludeAllTargets = exptIncludeAllTargets; 119 | return this; 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Model/Segment.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using FeatBit.Sdk.Server.Store; 4 | 5 | namespace FeatBit.Sdk.Server.Model 6 | { 7 | internal sealed class Segment : StorableObject 8 | { 9 | public override string StoreKey => StoreKeys.ForSegment(Id.ToString()); 10 | 11 | public Guid Id { get; set; } 12 | 13 | public ICollection Included { get; set; } 14 | 15 | public ICollection Excluded { get; set; } 16 | 17 | public ICollection Rules { get; set; } 18 | 19 | #if NETCOREAPP3_1 20 | public Segment() 21 | { 22 | } 23 | #endif 24 | 25 | public Segment( 26 | Guid id, 27 | long version, 28 | ICollection included, 29 | ICollection excluded, 30 | ICollection rules) 31 | { 32 | Id = id; 33 | Version = version; 34 | Included = included; 35 | Excluded = excluded; 36 | Rules = rules; 37 | } 38 | } 39 | 40 | internal sealed class MatchRule 41 | { 42 | public ICollection Conditions { get; set; } = Array.Empty(); 43 | } 44 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Model/SegmentBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace FeatBit.Sdk.Server.Model 5 | { 6 | internal class SegmentBuilder 7 | { 8 | private Guid _id; 9 | private long _version; 10 | private ICollection _included = new List(); 11 | private ICollection _excluded = new List(); 12 | private ICollection _rules = new List(); 13 | 14 | public Segment Build() 15 | { 16 | return new Segment(_id, _version, _included, _excluded, _rules); 17 | } 18 | 19 | public SegmentBuilder Id(Guid id) 20 | { 21 | _id = id; 22 | return this; 23 | } 24 | 25 | public SegmentBuilder Version(long version) 26 | { 27 | _version = version; 28 | return this; 29 | } 30 | 31 | public SegmentBuilder Included(params string[] keys) 32 | { 33 | foreach (var key in keys) 34 | { 35 | _included.Add(key); 36 | } 37 | 38 | return this; 39 | } 40 | 41 | public SegmentBuilder Excluded(params string[] keys) 42 | { 43 | foreach (var key in keys) 44 | { 45 | _excluded.Add(key); 46 | } 47 | 48 | return this; 49 | } 50 | 51 | public SegmentBuilder Rules(List rules) 52 | { 53 | _rules = rules; 54 | return this; 55 | } 56 | 57 | public SegmentBuilder Rules(params MatchRule[] rules) 58 | { 59 | foreach (var rule in rules) 60 | { 61 | _rules.Add(rule); 62 | } 63 | 64 | return this; 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Model/Variation.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Model 2 | { 3 | internal sealed class Variation 4 | { 5 | public string Id { get; set; } 6 | 7 | public string Value { get; set; } 8 | 9 | public static readonly Variation Empty = new() 10 | { 11 | Id = string.Empty, 12 | Value = string.Empty 13 | }; 14 | } 15 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/NuGet.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | This is the .NET Server-Side SDK for the 100% open-source feature flags management 4 | platform [FeatBit](https://github.com/featbit/featbit). 5 | 6 | More documentation is available at the [SDK website](https://github.com/featbit/dotnet-server-sdk). -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("FeatBit.ServerSdk.Tests")] 4 | [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Retry/BackoffAndJitterRetryPolicy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace FeatBit.Sdk.Server.Retry 4 | { 5 | public class BackoffAndJitterRetryPolicy : IRetryPolicy 6 | { 7 | private static readonly Random Random = new Random(); 8 | 9 | private readonly TimeSpan _firstRetryDelay; 10 | private readonly TimeSpan _maxRetryDelay; 11 | private readonly double _jitterRatio; 12 | 13 | private static readonly TimeSpan DefaultFirstRetryDelay = TimeSpan.FromSeconds(1); 14 | private static readonly TimeSpan DefaultMaxRetryDelay = TimeSpan.FromSeconds(30); 15 | 16 | public BackoffAndJitterRetryPolicy(TimeSpan? firstRetryDelay = null, TimeSpan? maxRetryDelay = null) 17 | { 18 | _firstRetryDelay = firstRetryDelay ?? DefaultFirstRetryDelay; 19 | _maxRetryDelay = maxRetryDelay ?? DefaultMaxRetryDelay; 20 | _jitterRatio = 0.5d; 21 | } 22 | 23 | public TimeSpan NextRetryDelay(RetryContext retryContext) 24 | { 25 | var backoffTime = Backoff(retryContext.RetryAttempt); 26 | var delay = (long)Math.Round(Jitter(backoffTime) + backoffTime / 2); 27 | 28 | return TimeSpan.FromMilliseconds(delay); 29 | } 30 | 31 | private double Backoff(int retryAttempt) 32 | { 33 | var delay = _firstRetryDelay.TotalMilliseconds * Math.Pow(2, retryAttempt); 34 | var max = _maxRetryDelay.TotalMilliseconds; 35 | return Math.Min(delay, max); 36 | } 37 | 38 | private double Jitter(double backoff) 39 | { 40 | return backoff * _jitterRatio * Random.NextDouble(); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Retry/DefaultRetryPolicy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace FeatBit.Sdk.Server.Retry 5 | { 6 | internal sealed class DefaultRetryPolicy : IRetryPolicy 7 | { 8 | internal static readonly TimeSpan[] DefaultRetryDelays = 9 | { 10 | // retry immediately for the first 11 | TimeSpan.Zero, 12 | TimeSpan.FromSeconds(1), 13 | TimeSpan.FromSeconds(2), 14 | TimeSpan.FromSeconds(3), 15 | TimeSpan.FromSeconds(5), 16 | TimeSpan.FromSeconds(8), 17 | TimeSpan.FromSeconds(13), 18 | TimeSpan.FromSeconds(21), 19 | TimeSpan.FromSeconds(34), 20 | TimeSpan.FromSeconds(55) 21 | }; 22 | 23 | private readonly TimeSpan[] _retryDelays; 24 | 25 | public DefaultRetryPolicy() 26 | { 27 | _retryDelays = DefaultRetryDelays; 28 | } 29 | 30 | public DefaultRetryPolicy(IReadOnlyList retryDelays) 31 | { 32 | if (retryDelays == null || retryDelays.Count == 0) 33 | { 34 | throw new ArgumentException("retry delays cannot be null or empty", nameof(retryDelays)); 35 | } 36 | 37 | _retryDelays = new TimeSpan[retryDelays.Count]; 38 | for (var i = 0; i < retryDelays.Count; i++) 39 | { 40 | _retryDelays[i] = retryDelays[i]; 41 | } 42 | } 43 | 44 | public TimeSpan NextRetryDelay(RetryContext retryContext) 45 | { 46 | var index = retryContext.RetryAttempt % _retryDelays.Length; 47 | return _retryDelays[index]; 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Retry/IRetryPolicy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace FeatBit.Sdk.Server.Retry 4 | { 5 | /// 6 | /// An abstraction that controls when the client attempts to reconnect and how many times it does so. 7 | /// 8 | public interface IRetryPolicy 9 | { 10 | /// 11 | /// this will be called after the transport loses a connection to determine if and for how long to wait before the next reconnect attempt. 12 | /// 13 | /// 14 | /// Information related to the next possible reconnect attempt including the number of consecutive failed retries so far 15 | /// 16 | /// 17 | /// A representing the amount of time to wait from now before starting the next reconnect attempt. 18 | /// 19 | TimeSpan NextRetryDelay(RetryContext retryContext); 20 | } 21 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Retry/RetryContext.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Retry 2 | { 3 | /// 4 | /// The context passed to to help the policy determine 5 | /// how long to wait before the next retry and whether there should be another retry at all. 6 | /// 7 | public class RetryContext 8 | { 9 | /// 10 | /// The number of consecutive failed retries so far. 11 | /// 12 | public int RetryAttempt { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Store/DefaultMemoryStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace FeatBit.Sdk.Server.Store 6 | { 7 | public class DefaultMemoryStore : IMemoryStore 8 | { 9 | public bool Populated { get; private set; } 10 | 11 | private readonly object _writeLock = new object(); 12 | 13 | private volatile Dictionary _items = 14 | new Dictionary(); 15 | 16 | public void Populate(IEnumerable objects) 17 | { 18 | lock (_writeLock) 19 | { 20 | _items = objects.ToDictionary(storableObj => storableObj.StoreKey, storableObj => storableObj); 21 | Populated = true; 22 | } 23 | } 24 | 25 | public TObject Get(string key) where TObject : class 26 | { 27 | if (_items.TryGetValue(key, out var obj) && obj is TObject tObject) 28 | { 29 | return tObject; 30 | } 31 | 32 | return null; 33 | } 34 | 35 | public ICollection Find(Func predicate) 36 | { 37 | var result = new List(); 38 | 39 | foreach (var value in _items.Values.Where(predicate)) 40 | { 41 | if (value is TObject tObject) 42 | { 43 | result.Add(tObject); 44 | } 45 | } 46 | 47 | return result; 48 | } 49 | 50 | public bool Upsert(StorableObject storableObj) 51 | { 52 | var key = storableObj.StoreKey; 53 | 54 | lock (_writeLock) 55 | { 56 | // update item 57 | if (_items.TryGetValue(key, out var existed)) 58 | { 59 | if (existed.Version >= storableObj.Version) 60 | { 61 | return false; 62 | } 63 | 64 | _items[key] = storableObj; 65 | return true; 66 | } 67 | 68 | // add item 69 | _items.Add(key, storableObj); 70 | return true; 71 | } 72 | } 73 | 74 | public long Version() 75 | { 76 | var values = _items.Values; 77 | 78 | return values.Count == 0 ? 0 : values.Max(x => x.Version); 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Store/IMemoryStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace FeatBit.Sdk.Server.Store 5 | { 6 | /// 7 | /// Interface for a data storage that holds feature flags, segments or any other related data received by the SDK. 8 | /// 9 | /// 10 | /// 11 | /// Implementations must be thread-safe. 12 | /// 13 | /// 14 | public interface IMemoryStore 15 | { 16 | /// 17 | /// Indicates whether this store has been populated with any data yet. 18 | /// 19 | /// true if the store contains data 20 | bool Populated { get; } 21 | 22 | /// 23 | /// Overwrites the store's contents with a set of new items. 24 | /// 25 | /// 26 | /// 27 | /// All previous data will be discarded, regardless of versioning. 28 | /// 29 | /// 30 | /// a list of instances with 31 | /// their store keys. 32 | void Populate(IEnumerable objects); 33 | 34 | /// 35 | /// Retrieves an object from the store, if available. 36 | /// 37 | /// the unique key of the object within the store 38 | /// The object; null if the key is unknown 39 | TObject Get(string key) where TObject : class; 40 | 41 | /// 42 | /// Retrieves all the objects that match the conditions defined by the specified predicate. 43 | /// 44 | /// The delegate which defines the conditions of the elements to search for. 45 | /// A containing all the elements that match the conditions defined by the specified predicate, if found; 46 | /// otherwise, an empty 47 | ICollection Find(Func predicate); 48 | 49 | /// 50 | /// Updates or inserts an item in the store. For updates, the object will only be 51 | /// updated if the existing version is less than the new version. 52 | /// 53 | /// the item to insert or update 54 | /// true if the item was updated; false if it was not updated because the 55 | /// store contains an equal or greater version 56 | bool Upsert(StorableObject storableObj); 57 | 58 | /// 59 | /// Get the version of a storage 60 | /// 61 | /// a long value represents the version of a storage 62 | long Version(); 63 | } 64 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Store/StorableObject.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using FeatBit.Sdk.Server.Json; 3 | 4 | namespace FeatBit.Sdk.Server.Store 5 | { 6 | public abstract class StorableObject 7 | { 8 | [JsonPropertyName("updatedAt")] 9 | [JsonConverter(typeof(VersionJsonConverter))] 10 | public long Version { get; protected set; } 11 | 12 | public abstract string StoreKey { get; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Store/StoreKeys.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Store 2 | { 3 | internal static class StoreKeys 4 | { 5 | public const string SegmentPrefix = "segment_"; 6 | public const string FlagPrefix = "ff_"; 7 | 8 | public static string ForSegment(string segmentId) 9 | { 10 | return $"{SegmentPrefix}{segmentId}"; 11 | } 12 | 13 | public static string ForFeatureFlag(string flagKey) 14 | { 15 | return $"{FlagPrefix}{flagKey}"; 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Transport/ConnectionToken.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace FeatBit.Sdk.Server.Transport 6 | { 7 | internal static class ConnectionToken 8 | { 9 | private static readonly Dictionary NumberMap = new Dictionary() 10 | { 11 | { '0', 'Q' }, 12 | { '1', 'B' }, 13 | { '2', 'W' }, 14 | { '3', 'S' }, 15 | { '4', 'P' }, 16 | { '5', 'H' }, 17 | { '6', 'D' }, 18 | { '7', 'X' }, 19 | { '8', 'Z' }, 20 | { '9', 'U' } 21 | }; 22 | 23 | internal static string New(string envSecret) 24 | { 25 | var trimmed = envSecret.TrimEnd('='); 26 | var timestamp = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds(); 27 | var timestampLength = timestamp.ToString().Length; 28 | var start = Math.Max((int)Math.Floor(new Random().NextDouble() * trimmed.Length), 2); 29 | 30 | var sb = new StringBuilder(); 31 | 32 | sb.Append(EncodeNumber(start, 3)); 33 | sb.Append(EncodeNumber(timestamp.ToString().Length, 2)); 34 | sb.Append(trimmed.Substring(0, start)); 35 | sb.Append(EncodeNumber(timestamp, timestampLength)); 36 | sb.Append(trimmed.Substring(start)); 37 | 38 | return sb.ToString(); 39 | } 40 | 41 | private static string EncodeNumber(long number, int length) 42 | { 43 | var sb = new StringBuilder(); 44 | 45 | var paddedNumber = number.ToString().PadLeft(length, '0'); 46 | foreach (var n in paddedNumber.Substring(paddedNumber.Length - length)) 47 | { 48 | sb.Append(NumberMap[n]); 49 | } 50 | 51 | return sb.ToString(); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Transport/DuplexPipe.cs: -------------------------------------------------------------------------------- 1 | using System.IO.Pipelines; 2 | 3 | namespace FeatBit.Sdk.Server.Transport 4 | { 5 | internal sealed class DuplexPipe : IDuplexPipe 6 | { 7 | public DuplexPipe(PipeReader reader, PipeWriter writer) 8 | { 9 | Input = reader; 10 | Output = writer; 11 | } 12 | 13 | public PipeReader Input { get; } 14 | 15 | public PipeWriter Output { get; } 16 | 17 | public static DuplexPipePair CreateConnectionPair(PipeOptions transportPipeOptions, PipeOptions appPipeOptions) 18 | { 19 | var transport = new Pipe(transportPipeOptions); 20 | var application = new Pipe(appPipeOptions); 21 | 22 | var transportToApplication = new DuplexPipe(application.Reader, transport.Writer); 23 | var applicationToTransport = new DuplexPipe(transport.Reader, application.Writer); 24 | 25 | return new DuplexPipePair(applicationToTransport, transportToApplication); 26 | } 27 | 28 | // This class exists to work around issues with value tuple on .NET Framework 29 | public readonly struct DuplexPipePair 30 | { 31 | public IDuplexPipe Transport { get; } 32 | public IDuplexPipe Application { get; } 33 | 34 | public DuplexPipePair(IDuplexPipe transport, IDuplexPipe application) 35 | { 36 | Transport = transport; 37 | Application = application; 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Transport/TextMessageFormatter.cs: -------------------------------------------------------------------------------- 1 | using System.Buffers; 2 | 3 | namespace FeatBit.Sdk.Server.Transport 4 | { 5 | internal static class TextMessageFormatter 6 | { 7 | // This record separator is supposed to be used only for JSON payloads where 0x1e character 8 | // will not occur (is not a valid character) and therefore it is safe to not escape it 9 | public const byte RecordSeparator = 0x1e; 10 | 11 | public static void WriteRecordSeparator(IBufferWriter output) 12 | { 13 | var buffer = output.GetSpan(1); 14 | buffer[0] = RecordSeparator; 15 | output.Advance(1); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Transport/TextMessageParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Runtime.CompilerServices; 4 | 5 | namespace FeatBit.Sdk.Server.Transport 6 | { 7 | internal static class TextMessageParser 8 | { 9 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 10 | public static bool TryParseMessage(ref ReadOnlySequence buffer, out ReadOnlySequence payload) 11 | { 12 | if (buffer.IsSingleSegment) 13 | { 14 | var span = buffer.First.Span; 15 | var index = span.IndexOf(TextMessageFormatter.RecordSeparator); 16 | if (index == -1) 17 | { 18 | payload = default; 19 | return false; 20 | } 21 | 22 | payload = buffer.Slice(0, index); 23 | 24 | buffer = buffer.Slice(index + 1); 25 | 26 | return true; 27 | } 28 | else 29 | { 30 | return TryParseMessageMultiSegment(ref buffer, out payload); 31 | } 32 | } 33 | 34 | private static bool TryParseMessageMultiSegment(ref ReadOnlySequence buffer, out ReadOnlySequence payload) 35 | { 36 | var position = buffer.PositionOf(TextMessageFormatter.RecordSeparator); 37 | if (position == null) 38 | { 39 | payload = default; 40 | return false; 41 | } 42 | 43 | payload = buffer.Slice(0, position.Value); 44 | 45 | // Skip record separator 46 | buffer = buffer.Slice(buffer.GetPosition(1, position.Value)); 47 | 48 | return true; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Transport/WebSocketExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Buffers; 2 | using System.Diagnostics; 3 | using System.Net.WebSockets; 4 | using System.Runtime.InteropServices; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace FeatBit.Sdk.Server.Transport 9 | { 10 | internal static class WebSocketExtensions 11 | { 12 | public static ValueTask SendAsync(this WebSocket webSocket, ReadOnlySequence buffer, 13 | WebSocketMessageType webSocketMessageType, CancellationToken cancellationToken = default) 14 | { 15 | #if NETCOREAPP 16 | if (buffer.IsSingleSegment) 17 | { 18 | return webSocket.SendAsync(buffer.First, webSocketMessageType, endOfMessage: true, cancellationToken); 19 | } 20 | else 21 | { 22 | return SendMultiSegmentAsync(webSocket, buffer, webSocketMessageType, cancellationToken); 23 | } 24 | #else 25 | if (buffer.IsSingleSegment) 26 | { 27 | var isArray = MemoryMarshal.TryGetArray(buffer.First, out var segment); 28 | Debug.Assert(isArray); 29 | return new ValueTask(webSocket.SendAsync(segment, webSocketMessageType, endOfMessage: true, 30 | cancellationToken)); 31 | } 32 | else 33 | { 34 | return SendMultiSegmentAsync(webSocket, buffer, webSocketMessageType, cancellationToken); 35 | } 36 | #endif 37 | } 38 | 39 | private static async ValueTask SendMultiSegmentAsync(WebSocket webSocket, ReadOnlySequence buffer, 40 | WebSocketMessageType webSocketMessageType, CancellationToken cancellationToken = default) 41 | { 42 | var position = buffer.Start; 43 | // Get a segment before the loop so we can be one segment behind while writing 44 | // This allows us to do a non-zero byte write for the endOfMessage = true send 45 | buffer.TryGet(ref position, out var prevSegment); 46 | while (buffer.TryGet(ref position, out var segment)) 47 | { 48 | #if NETCOREAPP 49 | await webSocket.SendAsync(prevSegment, webSocketMessageType, endOfMessage: false, cancellationToken) 50 | .ConfigureAwait(false); 51 | #else 52 | var isArray = MemoryMarshal.TryGetArray(prevSegment, out var arraySegment); 53 | Debug.Assert(isArray); 54 | await webSocket.SendAsync(arraySegment, webSocketMessageType, endOfMessage: false, cancellationToken) 55 | .ConfigureAwait(false); 56 | #endif 57 | prevSegment = segment; 58 | } 59 | 60 | // End of message frame 61 | #if NETCOREAPP 62 | await webSocket.SendAsync(prevSegment, webSocketMessageType, endOfMessage: true, cancellationToken) 63 | .ConfigureAwait(false); 64 | #else 65 | var isArrayEnd = MemoryMarshal.TryGetArray(prevSegment, out var arraySegmentEnd); 66 | Debug.Assert(isArrayEnd); 67 | await webSocket.SendAsync(arraySegmentEnd, webSocketMessageType, endOfMessage: true, cancellationToken) 68 | .ConfigureAwait(false); 69 | #endif 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/Transport/WebSocketTransport.Log.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.WebSockets; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace FeatBit.Sdk.Server.Transport; 6 | 7 | internal sealed partial class WebSocketTransport 8 | { 9 | private static partial class Log 10 | { 11 | [LoggerMessage(1, LogLevel.Debug, "Started transport.", EventName = "StartedTransport")] 12 | public static partial void StartedTransport(ILogger logger); 13 | 14 | [LoggerMessage(2, LogLevel.Debug, "WebSocket closed by the server. Close status {CloseStatus}.", 15 | EventName = "WebSocketClosed")] 16 | public static partial void WebSocketClosed(ILogger logger, WebSocketCloseStatus? closeStatus); 17 | 18 | [LoggerMessage(3, LogLevel.Debug, "Message received. Type: {MessageType}, size: {Count}.", 19 | EventName = "MessageReceived")] 20 | public static partial void MessageReceived(ILogger logger, WebSocketMessageType messageType, int count); 21 | 22 | [LoggerMessage(4, LogLevel.Debug, "Receive loop canceled.", EventName = "ReceiveCanceled")] 23 | public static partial void ReceiveCanceled(ILogger logger); 24 | 25 | [LoggerMessage(5, LogLevel.Debug, "Receive loop stopped.", EventName = "ReceiveStopped")] 26 | public static partial void ReceiveStopped(ILogger logger); 27 | 28 | [LoggerMessage(6, LogLevel.Debug, "Received message from application. Payload size: {Count}.", 29 | EventName = "ReceivedFromApp")] 30 | public static partial void ReceivedFromApp(ILogger logger, long count); 31 | 32 | [LoggerMessage(7, LogLevel.Debug, "Error while sending a message.", EventName = "ErrorSendingMessage")] 33 | public static partial void ErrorSendingMessage(ILogger logger, Exception exception); 34 | 35 | [LoggerMessage(8, LogLevel.Debug, "Closing webSocket failed.", EventName = "ClosingWebSocketFailed")] 36 | public static partial void ClosingWebSocketFailed(ILogger logger, Exception exception); 37 | 38 | [LoggerMessage(9, LogLevel.Debug, "Send loop stopped.", EventName = "SendStopped")] 39 | public static partial void SendStopped(ILogger logger); 40 | 41 | [LoggerMessage(10, LogLevel.Debug, "Transport stopped.", EventName = "TransportStopped")] 42 | public static partial void TransportStopped(ILogger logger, Exception exception); 43 | } 44 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/ValueConverters.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server 2 | { 3 | internal delegate bool ValueConverter(string value, out TValue converted); 4 | 5 | internal static class ValueConverters 6 | { 7 | internal static readonly ValueConverter Bool = (string value, out bool converted) => 8 | bool.TryParse(value, out converted); 9 | 10 | internal static readonly ValueConverter String = (string value, out string converted) => 11 | { 12 | converted = value; 13 | return true; 14 | }; 15 | 16 | public static readonly ValueConverter Int = (string value, out int converted) => 17 | int.TryParse(value, out converted); 18 | 19 | public static readonly ValueConverter Float = (string value, out float converted) => 20 | float.TryParse(value, out converted); 21 | 22 | public static readonly ValueConverter Double = (string value, out double converted) => 23 | double.TryParse(value, out converted); 24 | } 25 | } -------------------------------------------------------------------------------- /src/FeatBit.ServerSdk/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/featbit/featbit-dotnet-sdk/0cfb2635f135f7b5c77a21865055d042b6f96f66/src/FeatBit.ServerSdk/icon.png -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Bootstrapping/JsonBootstrapProviderTests.PopulateStore.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | flag: { 3 | StoreKey: ff_example-flag, 4 | Id: Guid_1, 5 | Key: example-flag, 6 | VariationType: boolean, 7 | Variations: [ 8 | { 9 | Id: Guid_2, 10 | Value: true 11 | }, 12 | { 13 | Id: Guid_3, 14 | Value: false 15 | } 16 | ], 17 | TargetUsers: [ 18 | { 19 | KeyIds: [ 20 | true-1 21 | ], 22 | VariationId: Guid_2 23 | }, 24 | { 25 | KeyIds: [ 26 | false-1 27 | ], 28 | VariationId: Guid_3 29 | } 30 | ], 31 | Rules: [ 32 | { 33 | Name: Rule 1, 34 | IncludedInExpt: false, 35 | Conditions: [ 36 | { 37 | Property: age, 38 | Op: BiggerThan, 39 | Value: 22 40 | }, 41 | { 42 | Property: name, 43 | Op: EndsWith, 44 | Value: _qa 45 | } 46 | ], 47 | Variations: [ 48 | { 49 | Id: Guid_2, 50 | Rollout: [ 51 | 0.0, 52 | 1.0 53 | ], 54 | ExptRollout: 1.0 55 | } 56 | ] 57 | }, 58 | { 59 | Name: Rule 2, 60 | DispatchKey: keyId, 61 | IncludedInExpt: false, 62 | Conditions: [ 63 | { 64 | Property: country, 65 | Op: IsOneOf, 66 | Value: ["cn","us","jp","gb","es","ss"] 67 | } 68 | ], 69 | Variations: [ 70 | { 71 | Id: Guid_2, 72 | Rollout: [ 73 | 0.0, 74 | 0.2 75 | ], 76 | ExptRollout: 1.0 77 | }, 78 | { 79 | Id: Guid_3, 80 | Rollout: [ 81 | 0.2, 82 | 1.0 83 | ], 84 | ExptRollout: 1.0 85 | } 86 | ] 87 | } 88 | ], 89 | IsEnabled: true, 90 | DisabledVariationId: Guid_3, 91 | Fallthrough: { 92 | IncludedInExpt: true, 93 | Variations: [ 94 | { 95 | Id: Guid_2, 96 | Rollout: [ 97 | 0.0, 98 | 1.0 99 | ], 100 | ExptRollout: 1.0 101 | } 102 | ] 103 | }, 104 | ExptIncludeAllTargets: true, 105 | Version: 1674871495616 106 | }, 107 | segment: { 108 | StoreKey: segment_0779d76b-afc6-4886-ab65-af8c004273ad, 109 | Id: Guid_4, 110 | Included: [ 111 | true-1 112 | ], 113 | Excluded: [ 114 | false-1 115 | ], 116 | Rules: [ 117 | { 118 | Conditions: [ 119 | { 120 | Property: age, 121 | Op: LessEqualThan, 122 | Value: 22 123 | }, 124 | { 125 | Property: country, 126 | Op: IsOneOf, 127 | Value: ["cn","us","es"] 128 | }, 129 | { 130 | Property: name, 131 | Op: NotEqual, 132 | Value: bob 133 | }, 134 | { 135 | Property: isMember, 136 | Op: IsTrue, 137 | Value: IsTrue 138 | } 139 | ] 140 | } 141 | ], 142 | Version: 1674885283583 143 | } 144 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Bootstrapping/JsonBootstrapProviderTests.UseValidJson.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | EventType: full, 3 | FeatureFlags: [ 4 | { 5 | StoreKey: ff_example-flag, 6 | Id: Guid_1, 7 | Key: example-flag, 8 | VariationType: boolean, 9 | Variations: [ 10 | { 11 | Id: Guid_2, 12 | Value: true 13 | }, 14 | { 15 | Id: Guid_3, 16 | Value: false 17 | } 18 | ], 19 | TargetUsers: [ 20 | { 21 | KeyIds: [ 22 | true-1 23 | ], 24 | VariationId: Guid_2 25 | }, 26 | { 27 | KeyIds: [ 28 | false-1 29 | ], 30 | VariationId: Guid_3 31 | } 32 | ], 33 | Rules: [ 34 | { 35 | Name: Rule 1, 36 | IncludedInExpt: false, 37 | Conditions: [ 38 | { 39 | Property: age, 40 | Op: BiggerThan, 41 | Value: 22 42 | }, 43 | { 44 | Property: name, 45 | Op: EndsWith, 46 | Value: _qa 47 | } 48 | ], 49 | Variations: [ 50 | { 51 | Id: Guid_2, 52 | Rollout: [ 53 | 0.0, 54 | 1.0 55 | ], 56 | ExptRollout: 1.0 57 | } 58 | ] 59 | }, 60 | { 61 | Name: Rule 2, 62 | DispatchKey: keyId, 63 | IncludedInExpt: false, 64 | Conditions: [ 65 | { 66 | Property: country, 67 | Op: IsOneOf, 68 | Value: ["cn","us","jp","gb","es","ss"] 69 | } 70 | ], 71 | Variations: [ 72 | { 73 | Id: Guid_2, 74 | Rollout: [ 75 | 0.0, 76 | 0.2 77 | ], 78 | ExptRollout: 1.0 79 | }, 80 | { 81 | Id: Guid_3, 82 | Rollout: [ 83 | 0.2, 84 | 1.0 85 | ], 86 | ExptRollout: 1.0 87 | } 88 | ] 89 | } 90 | ], 91 | IsEnabled: true, 92 | DisabledVariationId: Guid_3, 93 | Fallthrough: { 94 | IncludedInExpt: true, 95 | Variations: [ 96 | { 97 | Id: Guid_2, 98 | Rollout: [ 99 | 0.0, 100 | 1.0 101 | ], 102 | ExptRollout: 1.0 103 | } 104 | ] 105 | }, 106 | ExptIncludeAllTargets: true, 107 | Version: 1674871495616 108 | } 109 | ], 110 | Segments: [ 111 | { 112 | StoreKey: segment_0779d76b-afc6-4886-ab65-af8c004273ad, 113 | Id: Guid_4, 114 | Included: [ 115 | true-1 116 | ], 117 | Excluded: [ 118 | false-1 119 | ], 120 | Rules: [ 121 | { 122 | Conditions: [ 123 | { 124 | Property: age, 125 | Op: LessEqualThan, 126 | Value: 22 127 | }, 128 | { 129 | Property: country, 130 | Op: IsOneOf, 131 | Value: ["cn","us","es"] 132 | }, 133 | { 134 | Property: name, 135 | Op: NotEqual, 136 | Value: bob 137 | }, 138 | { 139 | Property: isMember, 140 | Op: IsTrue, 141 | Value: IsTrue 142 | } 143 | ] 144 | } 145 | ], 146 | Version: 1674885283583 147 | } 148 | ] 149 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Bootstrapping/JsonBootstrapProviderTests.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | using FeatBit.Sdk.Server.Store; 3 | 4 | namespace FeatBit.Sdk.Server.Bootstrapping; 5 | 6 | [UsesVerify] 7 | public class JsonBootstrapProviderTests 8 | { 9 | [Fact] 10 | public async Task UseValidJson() 11 | { 12 | var provider = new JsonBootstrapProvider(TestData.BootstrapJson); 13 | 14 | var dataSet = provider.DataSet(); 15 | await Verify(dataSet); 16 | } 17 | 18 | [Fact] 19 | public void UseInvalidJson() 20 | { 21 | Assert.ThrowsAny(() => new JsonBootstrapProvider("{")); 22 | } 23 | 24 | [Fact] 25 | public async Task PopulateStore() 26 | { 27 | var provider = new JsonBootstrapProvider(TestData.BootstrapJson); 28 | var store = new DefaultMemoryStore(); 29 | 30 | provider.Populate(store); 31 | 32 | Assert.True(store.Populated); 33 | 34 | var flag = store.Get("ff_example-flag"); 35 | var segment = store.Get("segment_0779d76b-afc6-4886-ab65-af8c004273ad"); 36 | 37 | await Verify(new { flag, segment }); 38 | } 39 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Bootstrapping/featbit-bootstrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "messageType": "data-sync", 3 | "data": { 4 | "eventType": "full", 5 | "featureFlags": [ 6 | { 7 | "id": "174c7138-426d-4434-8a91-af8c00306de3", 8 | "createdAt": "2023-01-16T02:56:19.531Z", 9 | "updatedAt": "2023-01-28T02:04:55.616Z", 10 | "creatorId": "197dea2a-1044-497d-8f1e-f01d2e15a756", 11 | "updatorId": "197dea2a-1044-497d-8f1e-f01d2e15a756", 12 | "envId": "226b9bf8-4af3-4ffa-9b01-162270e4cd40", 13 | "name": "example flag", 14 | "key": "example-flag", 15 | "variationType": "boolean", 16 | "variations": [ 17 | { 18 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 19 | "value": "true" 20 | }, 21 | { 22 | "id": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 23 | "value": "false" 24 | } 25 | ], 26 | "targetUsers": [ 27 | { 28 | "keyIds": [ 29 | "true-1" 30 | ], 31 | "variationId": "3da96792-debf-4878-905a-c9b5f9178cd0" 32 | }, 33 | { 34 | "keyIds": [ 35 | "false-1" 36 | ], 37 | "variationId": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46" 38 | } 39 | ], 40 | "rules": [ 41 | { 42 | "id": "16273e69-8e1f-4828-bb9b-07576724f6d8", 43 | "name": "Rule 1", 44 | "dispatchKey": null, 45 | "includedInExpt": false, 46 | "conditions": [ 47 | { 48 | "property": "age", 49 | "op": "BiggerThan", 50 | "value": "22" 51 | }, 52 | { 53 | "property": "name", 54 | "op": "EndsWith", 55 | "value": "_qa" 56 | } 57 | ], 58 | "variations": [ 59 | { 60 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 61 | "rollout": [ 62 | 0, 63 | 1 64 | ], 65 | "exptRollout": 1 66 | } 67 | ] 68 | }, 69 | { 70 | "id": "05a6e5fa-6fb5-4bf8-8c2d-d556ed44c143", 71 | "name": "Rule 2", 72 | "dispatchKey": "keyId", 73 | "includedInExpt": false, 74 | "conditions": [ 75 | { 76 | "property": "country", 77 | "op": "IsOneOf", 78 | "value": "[\"cn\",\"us\",\"jp\",\"gb\",\"es\",\"ss\"]" 79 | } 80 | ], 81 | "variations": [ 82 | { 83 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 84 | "rollout": [ 85 | 0, 86 | 0.2 87 | ], 88 | "exptRollout": 1 89 | }, 90 | { 91 | "id": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 92 | "rollout": [ 93 | 0.2, 94 | 1 95 | ], 96 | "exptRollout": 1 97 | } 98 | ] 99 | } 100 | ], 101 | "isEnabled": true, 102 | "disabledVariationId": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 103 | "fallthrough": { 104 | "dispatchKey": null, 105 | "includedInExpt": true, 106 | "variations": [ 107 | { 108 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 109 | "rollout": [ 110 | 0, 111 | 1 112 | ], 113 | "exptRollout": 1 114 | } 115 | ] 116 | }, 117 | "exptIncludeAllTargets": true, 118 | "tags": [], 119 | "isArchived": false 120 | } 121 | ], 122 | "segments": [ 123 | { 124 | "id": "0779d76b-afc6-4886-ab65-af8c004273ad", 125 | "createdAt": "2023-01-16T04:01:56.63Z", 126 | "updatedAt": "2023-01-28T05:54:43.583Z", 127 | "envId": "226b9bf8-4af3-4ffa-9b01-162270e4cd40", 128 | "name": "full-segment", 129 | "description": "test segment", 130 | "included": [ 131 | "true-1" 132 | ], 133 | "excluded": [ 134 | "false-1" 135 | ], 136 | "rules": [ 137 | { 138 | "id": "19a77402-3ee7-4ea0-83bc-3e019b982d1b", 139 | "name": "Rule 1", 140 | "conditions": [ 141 | { 142 | "property": "age", 143 | "op": "LessEqualThan", 144 | "value": "22" 145 | }, 146 | { 147 | "property": "country", 148 | "op": "IsOneOf", 149 | "value": "[\"cn\",\"us\",\"es\"]" 150 | }, 151 | { 152 | "property": "name", 153 | "op": "NotEqual", 154 | "value": "bob" 155 | }, 156 | { 157 | "property": "isMember", 158 | "op": "IsTrue", 159 | "value": "IsTrue" 160 | } 161 | ] 162 | } 163 | ], 164 | "isArchived": false 165 | } 166 | ] 167 | } 168 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Concurrent/AtomicBooleanTests.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Concurrent; 2 | 3 | public class AtomicBooleanTests 4 | { 5 | [Fact] 6 | public void SetInitialValue() 7 | { 8 | var trueBoolean = new AtomicBoolean(true); 9 | Assert.True(trueBoolean.Value); 10 | 11 | var falseBoolean = new AtomicBoolean(false); 12 | Assert.False(falseBoolean.Value); 13 | } 14 | 15 | [Fact] 16 | public void CompareAndSetWhenMatch() 17 | { 18 | var atomicBoolean = new AtomicBoolean(true); 19 | 20 | var newValueSet = atomicBoolean.CompareAndSet(true, false); 21 | 22 | Assert.True(newValueSet); 23 | Assert.False(atomicBoolean.Value); 24 | } 25 | 26 | [Fact] 27 | public void CompareAndSetWhenNotMatch() 28 | { 29 | var atomicBoolean = new AtomicBoolean(true); 30 | 31 | var newValueSet = atomicBoolean.CompareAndSet(false, false); 32 | 33 | Assert.False(newValueSet); 34 | Assert.True(atomicBoolean.Value); 35 | } 36 | 37 | [Fact] 38 | public void GetAndSet() 39 | { 40 | var atomicBoolean = new AtomicBoolean(true); 41 | 42 | var oldValue = atomicBoolean.GetAndSet(false); 43 | 44 | Assert.False(atomicBoolean.Value); 45 | Assert.True(oldValue); 46 | } 47 | 48 | [Fact] 49 | public void CastToBool() 50 | { 51 | var atomicBoolean = new AtomicBoolean(true); 52 | bool boolValue = atomicBoolean; 53 | Assert.True(boolValue); 54 | } 55 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Concurrent/StatusManagerTests.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Concurrent; 2 | 3 | public class StatusManagerTests 4 | { 5 | public enum TestStatus 6 | { 7 | A, 8 | B 9 | } 10 | 11 | [Fact] 12 | public void SetInitialStatus() 13 | { 14 | var statusManager = new StatusManager(TestStatus.A); 15 | Assert.Equal(TestStatus.A, statusManager.Status); 16 | } 17 | 18 | [Fact] 19 | public void ChangesStatus() 20 | { 21 | var statusManager = new StatusManager(TestStatus.A); 22 | statusManager.SetStatus(TestStatus.B); 23 | Assert.Equal(TestStatus.B, statusManager.Status); 24 | } 25 | 26 | [Theory] 27 | [InlineData(TestStatus.A, TestStatus.B, true)] 28 | [InlineData(TestStatus.A, TestStatus.A, false)] 29 | public void TriggerIfStatusChangedEvent(TestStatus old, TestStatus @new, bool changed) 30 | { 31 | var eventTriggered = false; 32 | var statusManager = new StatusManager(old, newStatus => 33 | { 34 | Assert.Equal(newStatus, @new); 35 | eventTriggered = true; 36 | }); 37 | statusManager.SetStatus(@new); 38 | 39 | Assert.Equal(changed, eventTriggered); 40 | } 41 | 42 | [Theory] 43 | [InlineData(TestStatus.A, TestStatus.B, true)] 44 | [InlineData(TestStatus.B, TestStatus.A, false)] 45 | public void CompareAndSetStatus(TestStatus expected, TestStatus newStatus, bool setSuccess) 46 | { 47 | var statusManager = new StatusManager(TestStatus.A); 48 | var compareAndSetSuccess = statusManager.CompareAndSet(expected, newStatus); 49 | 50 | Assert.Equal(setSuccess, compareAndSetSuccess); 51 | Assert.Equal(newStatus, statusManager.Status); 52 | } 53 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/DataSynchronizer/WebSocketDataSynchronizerTests.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | using FeatBit.Sdk.Server.Options; 3 | using FeatBit.Sdk.Server.Store; 4 | 5 | namespace FeatBit.Sdk.Server.DataSynchronizer; 6 | 7 | [UsesVerify] 8 | [Collection(nameof(TestApp))] 9 | public class WebSocketDataSynchronizerTests 10 | { 11 | private readonly TestApp _app; 12 | 13 | public WebSocketDataSynchronizerTests(TestApp app) 14 | { 15 | _app = app; 16 | } 17 | 18 | [Fact] 19 | public async Task StartWithEmptyStoreAsync() 20 | { 21 | var options = new FbOptionsBuilder("qJHQTVfsZUOu1Q54RLMuIQ-JtrIvNK-k-bARYicOTNQA") 22 | .Streaming(new Uri("ws://localhost/")) 23 | .Build(); 24 | 25 | var store = new DefaultMemoryStore(); 26 | var synchronizer = new WebSocketDataSynchronizer(options, store, op => _app.CreateFbWebSocket(op)); 27 | Assert.Equal(DataSynchronizerStatus.Starting, synchronizer.Status); 28 | 29 | var startTask = synchronizer.StartAsync(); 30 | await startTask.WaitAsync(options.StartWaitTime); 31 | 32 | Assert.True(store.Populated); 33 | Assert.True(synchronizer.Initialized); 34 | Assert.Equal(DataSynchronizerStatus.Stable, synchronizer.Status); 35 | 36 | var flag = store.Get("ff_returns-true"); 37 | Assert.NotNull(flag); 38 | 39 | var segment = store.Get("segment_0779d76b-afc6-4886-ab65-af8c004273ad"); 40 | Assert.NotNull(segment); 41 | } 42 | 43 | [Fact] 44 | public async Task StartWithPopulatedStoreAsync() 45 | { 46 | var options = new FbOptionsBuilder("qJHQTVfsZUOu1Q54RLMuIQ-JtrIvNK-k-bARYicOTNQA") 47 | .Streaming(new Uri("ws://localhost/")) 48 | .Build(); 49 | 50 | var store = new DefaultMemoryStore(); 51 | var hello = new FeatureFlagBuilder().Key("hello-world").Version(1).Build(); 52 | store.Populate(new[] { hello }); 53 | 54 | var synchronizer = new WebSocketDataSynchronizer(options, store, op => _app.CreateFbWebSocket(op)); 55 | Assert.Equal(DataSynchronizerStatus.Starting, synchronizer.Status); 56 | 57 | var startTask = synchronizer.StartAsync(); 58 | await startTask.WaitAsync(options.StartWaitTime); 59 | 60 | Assert.True(synchronizer.Initialized); 61 | Assert.Equal(DataSynchronizerStatus.Stable, synchronizer.Status); 62 | 63 | var flag = store.Get("ff_returns-true"); 64 | Assert.NotNull(flag); 65 | } 66 | 67 | [Fact] 68 | public async Task ServerRejectConnection() 69 | { 70 | var options = new FbOptionsBuilder().Build(); 71 | var store = new DefaultMemoryStore(); 72 | 73 | var synchronizer = 74 | new WebSocketDataSynchronizer(options, store, _ => _app.CreateFbWebSocket("close-with-4003")); 75 | Assert.Equal(DataSynchronizerStatus.Starting, synchronizer.Status); 76 | 77 | _ = synchronizer.StartAsync(); 78 | 79 | var tcs = new TaskCompletionSource(); 80 | var onStatusChangedTask = tcs.Task; 81 | synchronizer.StatusChanged += _ => 82 | { 83 | Assert.False(synchronizer.Initialized); 84 | Assert.Equal(DataSynchronizerStatus.Stopped, synchronizer.Status); 85 | tcs.SetResult(); 86 | }; 87 | await onStatusChangedTask.WaitAsync(TimeSpan.FromSeconds(1)); 88 | } 89 | 90 | [Fact] 91 | public async Task ServerDisconnectedAfterStable() 92 | { 93 | var options = new FbOptionsBuilder() 94 | .ReconnectRetryDelays(new[] { TimeSpan.FromMilliseconds(200) }) 95 | .Build(); 96 | var store = new DefaultMemoryStore(); 97 | 98 | var webSocketUri = new Uri("ws://localhost/streaming?type=server&token=close-after-first-datasync"); 99 | var synchronizer = 100 | new WebSocketDataSynchronizer(options, store, op => _app.CreateFbWebSocket(op, webSocketUri)); 101 | Assert.Equal(DataSynchronizerStatus.Starting, synchronizer.Status); 102 | 103 | var startTask = synchronizer.StartAsync(); 104 | await startTask.WaitAsync(options.StartWaitTime); 105 | 106 | Assert.True(synchronizer.Initialized); 107 | Assert.Equal(DataSynchronizerStatus.Stable, synchronizer.Status); 108 | 109 | var tcs = new TaskCompletionSource(); 110 | var onStatusChangedTask = tcs.Task; 111 | synchronizer.StatusChanged += _ => 112 | { 113 | Assert.Equal(DataSynchronizerStatus.Interrupted, synchronizer.Status); 114 | tcs.SetResult(); 115 | }; 116 | await onStatusChangedTask.WaitAsync(TimeSpan.FromSeconds(1)); 117 | } 118 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/DataSynchronizer/full-data-set.json: -------------------------------------------------------------------------------- 1 | { 2 | "messageType": "data-sync", 3 | "data": { 4 | "eventType": "full", 5 | "featureFlags": [ 6 | { 7 | "id": "174c7138-426d-4434-8a91-af8c00306de3", 8 | "createdAt": "2023-01-16T02:56:19.531Z", 9 | "updatedAt": "2023-01-28T02:04:55.616Z", 10 | "creatorId": "197dea2a-1044-497d-8f1e-f01d2e15a756", 11 | "updatorId": "197dea2a-1044-497d-8f1e-f01d2e15a756", 12 | "envId": "226b9bf8-4af3-4ffa-9b01-162270e4cd40", 13 | "name": "returns true", 14 | "key": "returns-true", 15 | "variationType": "boolean", 16 | "variations": [ 17 | { 18 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 19 | "value": "true" 20 | }, 21 | { 22 | "id": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 23 | "value": "false" 24 | } 25 | ], 26 | "targetUsers": [], 27 | "rules": [], 28 | "isEnabled": true, 29 | "disabledVariationId": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 30 | "fallthrough": { 31 | "dispatchKey": null, 32 | "includedInExpt": true, 33 | "variations": [ 34 | { 35 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 36 | "rollout": [ 37 | 0, 38 | 1 39 | ], 40 | "exptRollout": 1 41 | } 42 | ] 43 | }, 44 | "exptIncludeAllTargets": true, 45 | "tags": [], 46 | "isArchived": false 47 | } 48 | ], 49 | "segments": [ 50 | { 51 | "id": "0779d76b-afc6-4886-ab65-af8c004273ad", 52 | "createdAt": "2023-01-16T04:01:56.63Z", 53 | "updatedAt": "2023-01-28T05:54:43.583Z", 54 | "envId": "226b9bf8-4af3-4ffa-9b01-162270e4cd40", 55 | "name": "full-segment", 56 | "description": "test segment", 57 | "included": [ 58 | "true-1" 59 | ], 60 | "excluded": [ 61 | "false-1" 62 | ], 63 | "rules": [ 64 | { 65 | "id": "19a77402-3ee7-4ea0-83bc-3e019b982d1b", 66 | "name": "Rule 1", 67 | "conditions": [ 68 | { 69 | "property": "age", 70 | "op": "LessEqualThan", 71 | "value": "22" 72 | }, 73 | { 74 | "property": "country", 75 | "op": "IsOneOf", 76 | "value": "[\"cn\",\"us\",\"es\"]" 77 | }, 78 | { 79 | "property": "name", 80 | "op": "NotEqual", 81 | "value": "bob" 82 | }, 83 | { 84 | "property": "isMember", 85 | "op": "IsTrue", 86 | "value": "IsTrue" 87 | } 88 | ] 89 | } 90 | ], 91 | "isArchived": false 92 | } 93 | ] 94 | } 95 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/DataSynchronizer/patch-data-set.json: -------------------------------------------------------------------------------- 1 | { 2 | "messageType": "data-sync", 3 | "data": { 4 | "eventType": "patch", 5 | "featureFlags": [ 6 | { 7 | "id": "174c7138-426d-4434-8a91-af8c00306de3", 8 | "createdAt": "2023-01-16T02:56:19.531Z", 9 | "updatedAt": "2023-02-01T02:04:55.616Z", 10 | "creatorId": "197dea2a-1044-497d-8f1e-f01d2e15a756", 11 | "updatorId": "197dea2a-1044-497d-8f1e-f01d2e15a756", 12 | "envId": "226b9bf8-4af3-4ffa-9b01-162270e4cd40", 13 | "name": "[updated] returns true", 14 | "key": "returns-true", 15 | "variationType": "boolean", 16 | "variations": [ 17 | { 18 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 19 | "value": "true" 20 | }, 21 | { 22 | "id": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 23 | "value": "false" 24 | } 25 | ], 26 | "targetUsers": [], 27 | "rules": [], 28 | "isEnabled": true, 29 | "disabledVariationId": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 30 | "fallthrough": { 31 | "dispatchKey": null, 32 | "includedInExpt": true, 33 | "variations": [ 34 | { 35 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 36 | "rollout": [ 37 | 0, 38 | 1 39 | ], 40 | "exptRollout": 1 41 | } 42 | ] 43 | }, 44 | "exptIncludeAllTargets": true, 45 | "tags": [], 46 | "isArchived": false 47 | } 48 | ], 49 | "segments": [] 50 | } 51 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Evaluation/ConditionMatcherTests.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | 3 | namespace FeatBit.Sdk.Server.Evaluation; 4 | 5 | public class ConditionMatcherTests 6 | { 7 | // note: 8 | // uv = userValue 9 | // op = operation 10 | // rv = ruleValue 11 | 12 | [Theory] 13 | [InlineData("10", OperatorTypes.BiggerThan, "9", true)] 14 | [InlineData("10", OperatorTypes.BiggerThan, "11", false)] 15 | [InlineData("10", OperatorTypes.BiggerEqualThan, "10", true)] 16 | [InlineData("10", OperatorTypes.BiggerEqualThan, "11", false)] 17 | [InlineData("10", OperatorTypes.LessThan, "11", true)] 18 | [InlineData("10", OperatorTypes.LessThan, "9", false)] 19 | [InlineData("10", OperatorTypes.LessEqualThan, "10", true)] 20 | [InlineData("10", OperatorTypes.LessEqualThan, "9", false)] 21 | public void MatchNumeric(string uv, string op, string rv, bool expected) 22 | { 23 | CheckMatch(uv, op, rv, expected); 24 | } 25 | 26 | [Theory] 27 | [InlineData("v1.0.0", OperatorTypes.Equal, "v1.0.0", true)] 28 | [InlineData("v1.1.0", OperatorTypes.Equal, "v1.0.0", false)] 29 | [InlineData("v1.1.0", OperatorTypes.NotEqual, "v1.1.0", false)] 30 | [InlineData("v1.1.0", OperatorTypes.NotEqual, "v1.0.0", true)] 31 | public void MatchEquality(string uv, string op, string rv, bool expected) 32 | { 33 | CheckMatch(uv, op, rv, expected); 34 | } 35 | 36 | [Theory] 37 | [InlineData("vvip", OperatorTypes.Contains, "vip", true)] 38 | [InlineData("vvip", OperatorTypes.Contains, "sv", false)] 39 | [InlineData("svip", OperatorTypes.NotContain, "vv", true)] 40 | [InlineData("svip", OperatorTypes.NotContain, "vip", false)] 41 | public void MatchContainsOrNot(string uv, string op, string rv, bool expected) 42 | { 43 | CheckMatch(uv, op, rv, expected); 44 | } 45 | 46 | [Theory] 47 | [InlineData("abc", OperatorTypes.StartsWith, "ab", true)] 48 | [InlineData("abc", OperatorTypes.StartsWith, "b", false)] 49 | [InlineData("abc", OperatorTypes.EndsWith, "bc", true)] 50 | [InlineData("abc", OperatorTypes.EndsWith, "cd", false)] 51 | public void MatchStartsOrEndsWith(string uv, string op, string rv, bool expected) 52 | { 53 | CheckMatch(uv, op, rv, expected); 54 | } 55 | 56 | [Theory] 57 | [InlineData("color", OperatorTypes.MatchRegex, "colou?r", true)] 58 | [InlineData("colour", OperatorTypes.MatchRegex, "colorr?", false)] 59 | [InlineData("colouur", OperatorTypes.NotMatchRegex, "colou?r", true)] 60 | [InlineData("color", OperatorTypes.NotMatchRegex, "colou?r", false)] 61 | public void MatchRegexOrNot(string uv, string op, string rv, bool expected) 62 | { 63 | CheckMatch(uv, op, rv, expected); 64 | } 65 | 66 | [Theory] 67 | [InlineData("a", OperatorTypes.IsOneOf, "[\"a\", \"b\"]", true)] 68 | [InlineData("c", OperatorTypes.IsOneOf, "[\"a\", \"b\"]", false)] 69 | [InlineData("c", OperatorTypes.NotOneOf, "[\"a\", \"b\"]", true)] 70 | [InlineData("a", OperatorTypes.NotOneOf, "[\"a\", \"b\"]", false)] 71 | public void MatchIsOneOf(string uv, string op, string rv, bool expected) 72 | { 73 | CheckMatch(uv, op, rv, expected); 74 | } 75 | 76 | [Theory] 77 | [InlineData("true", OperatorTypes.IsTrue, "", true)] 78 | [InlineData("TRue", OperatorTypes.IsTrue, "", true)] 79 | [InlineData("false", OperatorTypes.IsFalse, "", true)] 80 | [InlineData("falSE", OperatorTypes.IsFalse, "", true)] 81 | [InlineData("not-true-string", OperatorTypes.IsTrue, "", false)] 82 | [InlineData("not-false-string", OperatorTypes.IsFalse, "", false)] 83 | public void MatchTrueFalse(string uv, string op, string rv, bool expected) 84 | { 85 | CheckMatch(uv, op, rv, expected); 86 | } 87 | 88 | private static void CheckMatch(string uv, string op, string rv, bool expected) 89 | { 90 | var condition = new Condition 91 | { 92 | Property = "prop", 93 | Op = op, 94 | Value = rv 95 | }; 96 | 97 | var user = FbUser.Builder("nope") 98 | .Custom("prop", uv) 99 | .Build(); 100 | 101 | var isMatch = Evaluator.IsMatchCondition(condition, user); 102 | Assert.Equal(expected, isMatch); 103 | } 104 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Evaluation/DispatchAlgorithmTests.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Evaluation; 2 | 3 | public class DispatchAlgorithmTests 4 | { 5 | [Theory] 6 | [InlineData("test-value", 0.14653629204258323)] 7 | [InlineData("qKPKh1S3FolC", 0.9105919692665339)] 8 | [InlineData("3eacb184-2d79-49df-9ea7-edd4f10e4c6f", 0.08994403155520558)] 9 | public void ReturnSameResultForAnGivenKey(string key, double value) 10 | { 11 | Assert.Equal(value, DispatchAlgorithm.RolloutOfKey(key)); 12 | } 13 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Evaluation/RuleMatcherTests.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | using FeatBit.Sdk.Server.Store; 3 | 4 | namespace FeatBit.Sdk.Server.Evaluation; 5 | 6 | public class RuleMatcherTests 7 | { 8 | [Fact] 9 | public void MatchRule() 10 | { 11 | var segmentId = Guid.NewGuid(); 12 | var segment = new SegmentBuilder() 13 | .Id(segmentId) 14 | .Included("u1") 15 | .Excluded("u2", "u3") 16 | .Build(); 17 | 18 | var store = new DefaultMemoryStore(); 19 | store.Populate(new[] { segment }); 20 | 21 | var evaluator = new Evaluator(store); 22 | 23 | var rule = new TargetRule 24 | { 25 | Name = "test", 26 | Conditions = new List 27 | { 28 | new() 29 | { 30 | Property = Evaluator.IsNotInSegmentProperty, 31 | Op = string.Empty, 32 | Value = $"[\"{segmentId}\"]" 33 | }, 34 | new() 35 | { 36 | Property = "age", 37 | Op = OperatorTypes.Equal, 38 | Value = "10" 39 | } 40 | } 41 | }; 42 | 43 | var u1 = new FbUserBuilder("u1").Build(); 44 | var u2 = new FbUserBuilder("u2") 45 | .Custom("age", "11") 46 | .Build(); 47 | var u3 = new FbUserBuilder("u3") 48 | .Custom("age", "10") 49 | .Build(); 50 | 51 | Assert.False(evaluator.IsMatchRule(rule, u1)); 52 | Assert.False(evaluator.IsMatchRule(rule, u2)); 53 | Assert.True(evaluator.IsMatchRule(rule, u3)); 54 | } 55 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Evaluation/SegmentMatcherTests.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | using FeatBit.Sdk.Server.Store; 3 | 4 | namespace FeatBit.Sdk.Server.Evaluation; 5 | 6 | public class SegmentMatcherTests 7 | { 8 | [Fact] 9 | public void MatchSegment() 10 | { 11 | var rule = new MatchRule 12 | { 13 | Conditions = new List 14 | { 15 | new() 16 | { 17 | Property = "age", 18 | Op = OperatorTypes.Equal, 19 | Value = "10" 20 | }, 21 | new() 22 | { 23 | Property = "country", 24 | Op = OperatorTypes.Equal, 25 | Value = "us" 26 | }, 27 | } 28 | }; 29 | 30 | var segment = new SegmentBuilder() 31 | .Included("u1") 32 | .Excluded("u2") 33 | .Rules(rule) 34 | .Build(); 35 | 36 | var u1 = FbUser.Builder("u1").Build(); 37 | var u2 = FbUser.Builder("u2").Build(); 38 | 39 | var u3 = FbUser.Builder("u3") 40 | .Custom("age", "10") 41 | .Custom("country", "us") 42 | .Build(); 43 | 44 | var u4 = FbUser.Builder("u4") 45 | .Custom("age", "10") 46 | .Custom("country", "eu") 47 | .Build(); 48 | 49 | Assert.True(Evaluator.IsMatchSegment(segment, u1)); 50 | Assert.False(Evaluator.IsMatchSegment(segment, u2)); 51 | Assert.True(Evaluator.IsMatchSegment(segment, u3)); 52 | Assert.False(Evaluator.IsMatchSegment(segment, u4)); 53 | } 54 | 55 | [Fact] 56 | public void MatchAnySegment() 57 | { 58 | var store = new DefaultMemoryStore(); 59 | 60 | var segmentId = Guid.NewGuid(); 61 | var segment = new SegmentBuilder() 62 | .Id(segmentId) 63 | .Included("u1") 64 | .Build(); 65 | store.Populate(new[] { segment }); 66 | 67 | var evaluator = new Evaluator(store); 68 | 69 | var segmentCondition = new Condition 70 | { 71 | Property = Evaluator.IsInSegmentProperty, 72 | Op = string.Empty, 73 | Value = $"[\"{segmentId}\"]", 74 | }; 75 | var user = FbUser.Builder("u1").Build(); 76 | 77 | Assert.True(evaluator.IsMatchAnySegment(segmentCondition, user)); 78 | } 79 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/AsyncEventTests.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Events; 2 | 3 | public class AsyncEventTests 4 | { 5 | [Theory] 6 | [ClassData(typeof(AsyncEvents))] 7 | internal void AsyncEventIsCompleted(AsyncEvent asyncEvent) 8 | { 9 | Assert.False(asyncEvent.IsCompleted); 10 | 11 | asyncEvent.Complete(); 12 | 13 | Assert.True(asyncEvent.IsCompleted); 14 | } 15 | 16 | [Fact] 17 | internal void WaitForCompletion() 18 | { 19 | Assert.True(CompleteInTime(new FlushEvent(), 100, 300)); 20 | Assert.False(CompleteInTime(new ShutdownEvent(), 300, 100)); 21 | } 22 | 23 | [Fact] 24 | internal async Task WaitForCompletionAsync() 25 | { 26 | Assert.True(await CompleteInTimeAsync(new FlushEvent(), 100, 300)); 27 | Assert.False(await CompleteInTimeAsync(new ShutdownEvent(), 300, 100)); 28 | } 29 | 30 | private static bool CompleteInTime(AsyncEvent asyncEvent, int timeToComplete, int timeout) 31 | { 32 | _ = Task.Delay(timeToComplete).ContinueWith(_ => asyncEvent.Complete()); 33 | return asyncEvent.WaitForCompletion(TimeSpan.FromMilliseconds(timeout)); 34 | } 35 | 36 | private static async Task CompleteInTimeAsync(AsyncEvent asyncEvent, int timeToComplete, int timeout) 37 | { 38 | _ = Task.Delay(timeToComplete).ContinueWith(_ => asyncEvent.Complete()); 39 | return await asyncEvent.WaitForCompletionAsync(TimeSpan.FromMilliseconds(timeout)); 40 | } 41 | } 42 | 43 | internal class AsyncEvents : TheoryData 44 | { 45 | public AsyncEvents() 46 | { 47 | Add(new FlushEvent()); 48 | Add(new ShutdownEvent()); 49 | } 50 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventBufferTests.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Events; 2 | 3 | public class DefaultEventBufferTests 4 | { 5 | [Fact] 6 | public void GetEventSnapshot() 7 | { 8 | var buffer = new DefaultEventBuffer(2); 9 | 10 | buffer.AddEvent(new IntEvent(1)); 11 | buffer.AddEvent(new IntEvent(2)); 12 | 13 | var snapshot = buffer.EventsSnapshot; 14 | 15 | buffer.Clear(); 16 | Assert.Equal(0, buffer.Count); 17 | Assert.True(buffer.IsEmpty); 18 | 19 | // after the buffer is cleared, the snapshot should remain unchanged 20 | Assert.Equal(2, snapshot.Length); 21 | 22 | Assert.Equal(1, ((IntEvent)snapshot[0]).Value); 23 | Assert.Equal(2, ((IntEvent)snapshot[1]).Value); 24 | } 25 | 26 | [Fact] 27 | public void IgnoreNewEventAfterBufferIsFull() 28 | { 29 | var buffer = new DefaultEventBuffer(2); 30 | 31 | Assert.True(buffer.AddEvent(new IntEvent(1))); 32 | Assert.True(buffer.AddEvent(new IntEvent(2))); 33 | 34 | // buffer is full, the following events should be ignored 35 | Assert.False(buffer.AddEvent(new IntEvent(3))); 36 | Assert.False(buffer.AddEvent(new IntEvent(4))); 37 | 38 | Assert.Equal(2, buffer.Count); 39 | } 40 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventDispatcherTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using FeatBit.Sdk.Server.Options; 3 | 4 | namespace FeatBit.Sdk.Server.Events; 5 | 6 | public class DefaultEventDispatcherTests 7 | { 8 | [Fact] 9 | public void NewAndDispose() 10 | { 11 | var queue = new BlockingCollection(); 12 | var options = new FbOptionsBuilder("fake-secret").Build(); 13 | 14 | using var dispatcher = new DefaultEventDispatcher(options, queue); 15 | } 16 | 17 | [Fact] 18 | public void AddEvent() 19 | { 20 | var queue = new BlockingCollection(); 21 | var options = new FbOptionsBuilder("fake-secret").Build(); 22 | 23 | var mockBuffer = new Mock(); 24 | 25 | using var dispatcher = new DefaultEventDispatcher(options, queue, buffer: mockBuffer.Object); 26 | 27 | queue.Add(new IntEvent(1)); 28 | queue.Add(new IntEvent(2)); 29 | 30 | Assert.True(SpinWait.SpinUntil(() => mockBuffer.Invocations.Count > 0, 1000)); 31 | mockBuffer.Verify(x => x.AddEvent(It.IsAny()), Times.Exactly(2)); 32 | } 33 | 34 | [Fact] 35 | public void Flush() 36 | { 37 | var queue = new BlockingCollection(); 38 | var options = new FbOptionsBuilder("fake-secret").Build(); 39 | 40 | var mockSender = new Mock(); 41 | 42 | using var dispatcher = 43 | new DefaultEventDispatcher(options, queue, sender: mockSender.Object); 44 | 45 | queue.Add(new IntEvent(1)); 46 | queue.Add(new IntEvent(2)); 47 | 48 | FlushAndWaitComplete(queue); 49 | 50 | mockSender.Verify(x => x.SendAsync(It.IsAny()), Times.Once); 51 | } 52 | 53 | [Fact] 54 | public void FlushEmptyBuffer() 55 | { 56 | var queue = new BlockingCollection(); 57 | var options = new FbOptionsBuilder("fake-secret").Build(); 58 | 59 | using var dispatcher = new DefaultEventDispatcher(options, queue); 60 | 61 | FlushAndWaitComplete(queue); 62 | } 63 | 64 | [Fact] 65 | public void Shutdown() 66 | { 67 | var queue = new BlockingCollection(); 68 | var options = new FbOptionsBuilder("fake-secret").Build(); 69 | 70 | using var dispatcher = new DefaultEventDispatcher(options, queue); 71 | 72 | var shutdownEvent = new ShutdownEvent(); 73 | queue.Add(shutdownEvent); 74 | EnsureAsyncEventComplete(shutdownEvent); 75 | } 76 | 77 | [Fact] 78 | public void EventSenderStopDispatcher() 79 | { 80 | var queue = new BlockingCollection(); 81 | var options = new FbOptionsBuilder("fake-secret").Build(); 82 | 83 | var mockSender = new Mock(); 84 | mockSender.Setup(x => x.SendAsync(It.IsAny())) 85 | .Returns(Task.FromResult(DeliveryStatus.FailedAndMustShutDown)); 86 | 87 | // Create a dispatcher with a mock sender that always fails 88 | using var dispatcher = new DefaultEventDispatcher(options, queue, sender: mockSender.Object); 89 | 90 | // Add an event and flush it to trigger the sender 91 | AddEventThenFlush(); 92 | mockSender.Verify(x => x.SendAsync(It.IsAny()), Times.Once); 93 | 94 | // Clear previous invocations for later verification 95 | mockSender.Invocations.Clear(); 96 | 97 | // Check if dispatcher stopped after event sender return FailedAndMustShutDown 98 | Assert.True(dispatcher.HasStopped); 99 | 100 | // Check if add and flush operations are no-op after dispatcher has stopped 101 | AddEventThenFlush(); 102 | mockSender.Verify(x => x.SendAsync(It.IsAny()), Times.Never); 103 | 104 | void AddEventThenFlush() 105 | { 106 | queue.Add(new IntEvent(1)); 107 | FlushAndWaitComplete(queue); 108 | } 109 | } 110 | 111 | [Theory] 112 | [InlineData(5, 3, 1)] 113 | [InlineData(3, 12, 4)] 114 | [InlineData(5, 12, 3)] 115 | public void SendEventsInMultiBatch(int eventPerRequest, int totalEvents, int expectedBatch) 116 | { 117 | var queue = new BlockingCollection(); 118 | var options = new FbOptionsBuilder("fake-secret") 119 | .MaxEventPerRequest(eventPerRequest) 120 | .Build(); 121 | 122 | var mockSender = new Mock(); 123 | 124 | using var dispatcher = new DefaultEventDispatcher(options, queue, sender: mockSender.Object); 125 | 126 | for (var i = 0; i < totalEvents; i++) 127 | { 128 | queue.Add(new IntEvent(i)); 129 | } 130 | 131 | FlushAndWaitComplete(queue); 132 | mockSender.Verify(x => x.SendAsync(It.IsAny()), Times.Exactly(expectedBatch)); 133 | } 134 | 135 | private static void FlushAndWaitComplete(BlockingCollection queue) 136 | { 137 | var flushEvent = new FlushEvent(); 138 | queue.Add(flushEvent); 139 | EnsureAsyncEventComplete(flushEvent); 140 | } 141 | 142 | private static void EnsureAsyncEventComplete(AsyncEvent asyncEvent) 143 | { 144 | Assert.True(SpinWait.SpinUntil(() => asyncEvent.IsCompleted, 1000)); 145 | } 146 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventProcessorTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using FeatBit.Sdk.Server.Options; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace FeatBit.Sdk.Server.Events; 6 | 7 | public class DefaultEventProcessorTests 8 | { 9 | [Fact] 10 | public void StartAndClose() 11 | { 12 | var options = new FbOptionsBuilder("secret").Build(); 13 | var processor = new DefaultEventProcessor(options); 14 | 15 | // this should complete immediately 16 | processor.FlushAndClose(TimeSpan.FromMilliseconds(1000)); 17 | 18 | Assert.True(processor.HasClosed); 19 | } 20 | 21 | [Fact] 22 | public void CloseAnProcessorMultiTimes() 23 | { 24 | var options = new FbOptionsBuilder("secret").Build(); 25 | var processor = new DefaultEventProcessor(options); 26 | 27 | processor.FlushAndClose(TimeSpan.FromMilliseconds(1000)); 28 | processor.FlushAndClose(TimeSpan.FromMilliseconds(1000)); 29 | } 30 | 31 | [Fact] 32 | public void RecordEvent() 33 | { 34 | var options = new FbOptionsBuilder("secret").Build(); 35 | var processor = new DefaultEventProcessor(options); 36 | 37 | Assert.True(processor.Record(new IntEvent(1))); 38 | } 39 | 40 | [Fact] 41 | public void RecordNullEvent() 42 | { 43 | var options = new FbOptionsBuilder("secret").Build(); 44 | var processor = new DefaultEventProcessor(options); 45 | 46 | Assert.False(processor.Record(null)); 47 | } 48 | 49 | [Fact] 50 | public void ExceedCapacity() 51 | { 52 | var loggerMock = new Mock>(); 53 | 54 | var options = new FbOptionsBuilder("secret") 55 | // set max queue size to 2 56 | .MaxEventsInQueue(2) 57 | .Build(); 58 | 59 | // create a dispatcher that will not consume the processor's message so that processor's queue can be full 60 | var dispatcher = new DefaultEventDispatcher(options, new BlockingCollection()); 61 | var processor = new DefaultEventProcessor(options, loggerMock.Object, (_, _) => dispatcher); 62 | 63 | Assert.True(processor.Record(new IntEvent(1))); 64 | Assert.True(processor.Record(new IntEvent(2))); 65 | 66 | // the processor rejects new events when its queue is full 67 | Assert.False(processor.Record(new IntEvent(3))); 68 | 69 | // the processor will directly complete any flush event that is rejected. 70 | var flushEvent = new FlushEvent(); 71 | Assert.False(processor.Record(flushEvent)); 72 | Assert.True(flushEvent.IsCompleted); 73 | 74 | // verify warning logged once 75 | loggerMock.Verify( 76 | logger => logger.Log( 77 | LogLevel.Warning, 78 | It.IsAny(), 79 | It.IsAny(), 80 | null, 81 | It.IsAny>() 82 | ), 83 | Times.Once 84 | ); 85 | } 86 | 87 | [Fact] 88 | public void WaitFlush() 89 | { 90 | var options = new FbOptionsBuilder("secret").Build(); 91 | var mockedSender = new Mock(); 92 | 93 | var processor = new DefaultEventProcessor( 94 | options, 95 | dispatcherFactory: (opts, queue) => new DefaultEventDispatcher(opts, queue, sender: mockedSender.Object) 96 | ); 97 | 98 | processor.Record(new IntEvent(1)); 99 | var flushedInTime = processor.FlushAndWait(TimeSpan.FromMilliseconds(1000)); 100 | 101 | Assert.True(flushedInTime); 102 | mockedSender.Verify(x => x.SendAsync(It.IsAny()), Times.Once); 103 | } 104 | 105 | [Fact] 106 | public async Task WaitFlushAsync() 107 | { 108 | var options = new FbOptionsBuilder("secret").Build(); 109 | var mockedSender = new Mock(); 110 | 111 | var processor = new DefaultEventProcessor( 112 | options, 113 | dispatcherFactory: (opts, queue) => new DefaultEventDispatcher(opts, queue, sender: mockedSender.Object) 114 | ); 115 | 116 | processor.Record(new IntEvent(1)); 117 | var flushedInTime = await processor.FlushAndWaitAsync(TimeSpan.FromMilliseconds(1000)); 118 | 119 | Assert.True(flushedInTime); 120 | mockedSender.Verify(x => x.SendAsync(It.IsAny()), Times.Once); 121 | } 122 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventSenderTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text; 3 | using FeatBit.Sdk.Server.Options; 4 | 5 | namespace FeatBit.Sdk.Server.Events; 6 | 7 | public class DefaultEventSenderTests 8 | { 9 | [Theory] 10 | // ok 11 | [InlineData(HttpStatusCode.OK, DeliveryStatus.Succeeded)] 12 | 13 | // recoverable error 14 | [InlineData(HttpStatusCode.TooManyRequests, DeliveryStatus.Failed)] 15 | [InlineData(HttpStatusCode.RequestTimeout, DeliveryStatus.Failed)] 16 | 17 | // unrecoverable error 18 | [InlineData(HttpStatusCode.NotFound, DeliveryStatus.FailedAndMustShutDown)] 19 | [InlineData(HttpStatusCode.Unauthorized, DeliveryStatus.FailedAndMustShutDown)] 20 | internal async Task CheckDeliveryStatusBasedOnServerReturns(HttpStatusCode code, DeliveryStatus status) 21 | { 22 | var options = new FbOptionsBuilder("secret").Build(); 23 | 24 | var httpClient = new HttpClient(new EventHttpMessageHandlerMock(SequencedCode)); 25 | var sender = new DefaultEventSender(options, httpClient); 26 | 27 | var payload = Encoding.UTF8.GetBytes("{ \"value\": 1 }"); 28 | var deliveryStatus = await sender.SendAsync(payload); 29 | 30 | Assert.Equal(status, deliveryStatus); 31 | 32 | HttpStatusCode SequencedCode(int _) => code; 33 | } 34 | 35 | [Fact] 36 | public async Task ReturnsOkAfterRetry() 37 | { 38 | var options = new FbOptionsBuilder("secret") 39 | .SendEventRetryInterval(TimeSpan.FromMilliseconds(5)) 40 | .Build(); 41 | 42 | var httpClient = new HttpClient(new EventHttpMessageHandlerMock(SequencedCode)); 43 | var sender = new DefaultEventSender(options, httpClient); 44 | 45 | var payload = Encoding.UTF8.GetBytes("{ \"value\": 1 }"); 46 | var deliveryStatus = await sender.SendAsync(payload); 47 | 48 | Assert.Equal(DeliveryStatus.Succeeded, deliveryStatus); 49 | 50 | HttpStatusCode SequencedCode(int sequence) => 51 | sequence % 2 == 0 ? HttpStatusCode.RequestTimeout : HttpStatusCode.OK; 52 | } 53 | } 54 | 55 | internal sealed class EventHttpMessageHandlerMock : HttpMessageHandler 56 | { 57 | private int _sequence; 58 | private readonly Func _sequencedCode; 59 | 60 | public EventHttpMessageHandlerMock(Func sequencedCode) 61 | { 62 | _sequencedCode = sequencedCode; 63 | _sequence = 0; 64 | } 65 | 66 | protected override Task SendAsync(HttpRequestMessage request, 67 | CancellationToken cancellationToken) 68 | { 69 | Assert.Equal("secret", request.Headers.Authorization?.Scheme); 70 | Assert.Equal(HttpMethod.Post, request.Method); 71 | 72 | var code = _sequencedCode(_sequence++); 73 | 74 | return Task.FromResult(new HttpResponseMessage(code)); 75 | } 76 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventSerializerTests.SerializeCombinedEvents.verified.txt: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | user: { 4 | keyId: u1-Id, 5 | name: u1-name, 6 | customizedProperties: [ 7 | { 8 | name: custom, 9 | value: value 10 | }, 11 | { 12 | name: country, 13 | value: us 14 | } 15 | ] 16 | }, 17 | variations: [ 18 | { 19 | featureFlagKey: hello, 20 | variation: { 21 | id: v1Id, 22 | value: v1 23 | }, 24 | timestamp: {Scrubbed}, 25 | sendToExperiment: true 26 | } 27 | ] 28 | }, 29 | { 30 | user: { 31 | keyId: u1-Id, 32 | name: u1-name, 33 | customizedProperties: [ 34 | { 35 | name: custom, 36 | value: value 37 | }, 38 | { 39 | name: country, 40 | value: us 41 | } 42 | ] 43 | }, 44 | metrics: [ 45 | { 46 | appType: dotnet-server-side, 47 | route: index/metric, 48 | type: CustomEvent, 49 | eventName: click-button, 50 | numericValue: 1.5, 51 | timestamp: {Scrubbed} 52 | } 53 | ] 54 | } 55 | ] -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventSerializerTests.SerializeEvalEvent.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | user: { 3 | keyId: u1-Id, 4 | name: u1-name, 5 | customizedProperties: [ 6 | { 7 | name: custom, 8 | value: value 9 | }, 10 | { 11 | name: country, 12 | value: us 13 | } 14 | ] 15 | }, 16 | variations: [ 17 | { 18 | featureFlagKey: hello, 19 | variation: { 20 | id: v1Id, 21 | value: v1 22 | }, 23 | timestamp: {Scrubbed}, 24 | sendToExperiment: true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventSerializerTests.SerializeEvalEvents.verified.txt: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | user: { 4 | keyId: u2-Id, 5 | name: u2-name, 6 | customizedProperties: [ 7 | { 8 | name: age, 9 | value: 10 10 | } 11 | ] 12 | }, 13 | variations: [ 14 | { 15 | featureFlagKey: hello, 16 | variation: { 17 | id: v2Id, 18 | value: v2 19 | }, 20 | timestamp: {Scrubbed}, 21 | sendToExperiment: false 22 | } 23 | ] 24 | }, 25 | { 26 | user: { 27 | keyId: u3-Id, 28 | name: u3-name, 29 | customizedProperties: [ 30 | { 31 | name: age, 32 | value: 10 33 | } 34 | ] 35 | }, 36 | variations: [ 37 | { 38 | featureFlagKey: hello, 39 | variation: { 40 | id: v3Id, 41 | value: v3 42 | }, 43 | timestamp: {Scrubbed}, 44 | sendToExperiment: true 45 | } 46 | ] 47 | } 48 | ] -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventSerializerTests.SerializeMetricEvent.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | user: { 3 | keyId: u1-Id, 4 | name: u1-name, 5 | customizedProperties: [ 6 | { 7 | name: custom, 8 | value: value 9 | }, 10 | { 11 | name: country, 12 | value: us 13 | } 14 | ] 15 | }, 16 | metrics: [ 17 | { 18 | appType: dotnet-server-side, 19 | route: index/metric, 20 | type: CustomEvent, 21 | eventName: click-button, 22 | numericValue: 1.5, 23 | timestamp: {Scrubbed} 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventSerializerTests.SerializeMetricEvents.verified.txt: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | user: { 4 | keyId: u1-Id, 5 | name: u1-name, 6 | customizedProperties: [ 7 | { 8 | name: custom, 9 | value: value 10 | }, 11 | { 12 | name: country, 13 | value: us 14 | } 15 | ] 16 | }, 17 | metrics: [ 18 | { 19 | appType: dotnet-server-side, 20 | route: index/metric, 21 | type: CustomEvent, 22 | eventName: click-button, 23 | numericValue: 1.5, 24 | timestamp: {Scrubbed} 25 | } 26 | ] 27 | }, 28 | { 29 | user: { 30 | keyId: u2-Id, 31 | name: u2-name, 32 | customizedProperties: [ 33 | { 34 | name: age, 35 | value: 10 36 | } 37 | ] 38 | }, 39 | metrics: [ 40 | { 41 | appType: dotnet-server-side, 42 | route: index/metric, 43 | type: CustomEvent, 44 | eventName: click-button, 45 | numericValue: 32.5, 46 | timestamp: {Scrubbed} 47 | } 48 | ] 49 | } 50 | ] -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/DefaultEventSerializerTests.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using FeatBit.Sdk.Server.Model; 3 | 4 | namespace FeatBit.Sdk.Server.Events; 5 | 6 | [UsesVerify] 7 | public class DefaultEventSerializerTests 8 | { 9 | [Fact] 10 | public async Task SerializeEvalEvent() 11 | { 12 | var serializer = new DefaultEventSerializer(); 13 | 14 | var @event = AllEvalEvents()[0]; 15 | 16 | var jsonBytes = serializer.Serialize(@event); 17 | var json = Encoding.UTF8.GetString(jsonBytes); 18 | 19 | await VerifyJson(json).ScrubMember("timestamp"); 20 | } 21 | 22 | [Fact] 23 | public async Task SerializeMetricEvent() 24 | { 25 | var serializer = new DefaultEventSerializer(); 26 | 27 | var @event = AllMetricEvents()[0]; 28 | 29 | var jsonBytes = serializer.Serialize(@event); 30 | var json = Encoding.UTF8.GetString(jsonBytes); 31 | 32 | await VerifyJson(json).ScrubMember("timestamp"); 33 | } 34 | 35 | [Fact] 36 | public async Task SerializeEvalEvents() 37 | { 38 | var serializer = new DefaultEventSerializer(); 39 | 40 | var events = AllEvalEvents(); 41 | var result = new ReadOnlyMemory(events, 1, 2); 42 | 43 | var jsonBytes = serializer.Serialize(result); 44 | var json = Encoding.UTF8.GetString(jsonBytes); 45 | 46 | await VerifyJson(json).ScrubMember("timestamp"); 47 | } 48 | 49 | [Fact] 50 | public async Task SerializeMetricEvents() 51 | { 52 | var serializer = new DefaultEventSerializer(); 53 | 54 | var events = AllMetricEvents(); 55 | var result = new ReadOnlyMemory(events, 0, 2); 56 | 57 | var jsonBytes = serializer.Serialize(result); 58 | var json = Encoding.UTF8.GetString(jsonBytes); 59 | 60 | await VerifyJson(json).ScrubMember("timestamp"); 61 | } 62 | 63 | [Fact] 64 | public async Task SerializeCombinedEvents() 65 | { 66 | var serializer = new DefaultEventSerializer(); 67 | 68 | var events = new[] { AllEvalEvents()[0], AllMetricEvents()[0] }; 69 | var result = new ReadOnlyMemory(events, 0, 2); 70 | 71 | var jsonBytes = serializer.Serialize(result); 72 | var json = Encoding.UTF8.GetString(jsonBytes); 73 | 74 | await VerifyJson(json).ScrubMember("timestamp"); 75 | } 76 | 77 | private static IEvent[] AllEvalEvents() 78 | { 79 | var user1 = FbUser.Builder("u1-Id") 80 | .Name("u1-name") 81 | .Custom("custom", "value") 82 | .Custom("country", "us") 83 | .Build(); 84 | var v1Variation = new Variation 85 | { 86 | Id = "v1Id", 87 | Value = "v1" 88 | }; 89 | var event1 = new EvalEvent(user1, "hello", v1Variation, true); 90 | 91 | var user2 = FbUser.Builder("u2-Id") 92 | .Name("u2-name") 93 | .Custom("age", "10") 94 | .Build(); 95 | var v2Variation = new Variation 96 | { 97 | Id = "v2Id", 98 | Value = "v2" 99 | }; 100 | var event2 = new EvalEvent(user2, "hello", v2Variation, false); 101 | 102 | var user3 = FbUser.Builder("u3-Id") 103 | .Name("u3-name") 104 | .Custom("age", "10") 105 | .Build(); 106 | var v3Variation = new Variation 107 | { 108 | Id = "v3Id", 109 | Value = "v3" 110 | }; 111 | var event3 = new EvalEvent(user3, "hello", v3Variation, true); 112 | 113 | return new IEvent[] { event1, event2, event3 }; 114 | } 115 | 116 | private static IEvent[] AllMetricEvents() 117 | { 118 | var user1 = FbUser.Builder("u1-Id") 119 | .Name("u1-name") 120 | .Custom("custom", "value") 121 | .Custom("country", "us") 122 | .Build(); 123 | var event1 = new MetricEvent(user1, "click-button", 1.5d); 124 | 125 | var user2 = FbUser.Builder("u2-Id") 126 | .Name("u2-name") 127 | .Custom("age", "10") 128 | .Build(); 129 | var event2 = new MetricEvent(user2, "click-button", 32.5d); 130 | 131 | var user3 = FbUser.Builder("u3-Id") 132 | .Name("u3-name") 133 | .Custom("age", "10") 134 | .Build(); 135 | var event3 = new MetricEvent(user3, "click-button", 26.5d); 136 | 137 | return new IEvent[] { event1, event2, event3 }; 138 | } 139 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Events/IntEvent.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Events; 2 | 3 | internal sealed class IntEvent : PayloadEvent 4 | { 5 | public int Value { get; } 6 | 7 | public IntEvent(int value) 8 | { 9 | Value = value; 10 | } 11 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/FbClientOfflineTests.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.DataSynchronizer; 2 | using FeatBit.Sdk.Server.Evaluation; 3 | using FeatBit.Sdk.Server.Events; 4 | using FeatBit.Sdk.Server.Model; 5 | using FeatBit.Sdk.Server.Options; 6 | 7 | namespace FeatBit.Sdk.Server; 8 | 9 | public class FbClientOfflineTests 10 | { 11 | [Fact] 12 | public async Task CreateAndClose() 13 | { 14 | var options = new FbOptionsBuilder() 15 | .Offline(true) 16 | .Build(); 17 | 18 | var client = new FbClient(options); 19 | await client.CloseAsync(); 20 | } 21 | 22 | [Fact] 23 | public void UseNullDataSource() 24 | { 25 | var options = new FbOptionsBuilder() 26 | .Offline(true) 27 | .Build(); 28 | 29 | var client = new FbClient(options); 30 | 31 | Assert.IsType(client._dataSynchronizer); 32 | } 33 | 34 | [Fact] 35 | public void UseNullEventProcessor() 36 | { 37 | var options = new FbOptionsBuilder() 38 | .Offline(true) 39 | .Build(); 40 | 41 | var client = new FbClient(options); 42 | 43 | Assert.IsType(client._eventProcessor); 44 | } 45 | 46 | [Fact] 47 | public void UseNullEventProcessorWhenEventsAreDisabled() 48 | { 49 | var options = new FbOptionsBuilder() 50 | .Offline(false) 51 | .DisableEvents(true) 52 | .Build(); 53 | 54 | var client = new FbClient(options); 55 | 56 | Assert.IsType(client._eventProcessor); 57 | } 58 | 59 | [Fact] 60 | public void ClientIsInitialized() 61 | { 62 | var options = new FbOptionsBuilder() 63 | .Offline(true) 64 | .Build(); 65 | 66 | var client = new FbClient(options); 67 | 68 | Assert.True(client.Initialized); 69 | } 70 | 71 | [Fact] 72 | public void EvaluationReturnsDefaultValue() 73 | { 74 | var options = new FbOptionsBuilder() 75 | .Offline(true) 76 | .Build(); 77 | 78 | var client = new FbClient(options); 79 | 80 | var user = FbUser.Builder("tester").Build(); 81 | 82 | var variationDetail = client.StringVariationDetail("hello", user, "fallback-value"); 83 | Assert.Equal("fallback-value", variationDetail.Value); 84 | Assert.Equal(ReasonKind.Error, variationDetail.Kind); 85 | Assert.Equal("flag not found", variationDetail.Reason); 86 | } 87 | 88 | [Fact] 89 | public void WithJsonBootstrapProvider() 90 | { 91 | var options = new FbOptionsBuilder() 92 | .Offline(true) 93 | .UseJsonBootstrapProvider(TestData.BootstrapJson) 94 | .Build(); 95 | 96 | var client = new FbClient(options); 97 | 98 | var user = FbUser.Builder("true-1").Build(); 99 | var variationDetail = client.BoolVariationDetail("example-flag", user); 100 | 101 | Assert.True(variationDetail.Value); 102 | Assert.Equal("target match", variationDetail.Reason); 103 | Assert.Equal(ReasonKind.TargetMatch, variationDetail.Kind); 104 | } 105 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/FbClientTests.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.DataSynchronizer; 2 | using FeatBit.Sdk.Server.Evaluation; 3 | using FeatBit.Sdk.Server.Events; 4 | using FeatBit.Sdk.Server.Model; 5 | using FeatBit.Sdk.Server.Options; 6 | using FeatBit.Sdk.Server.Store; 7 | 8 | namespace FeatBit.Sdk.Server; 9 | 10 | [Collection(nameof(TestApp))] 11 | public class FbClientTests 12 | { 13 | private readonly TestApp _app; 14 | 15 | public FbClientTests(TestApp app) 16 | { 17 | _app = app; 18 | } 19 | 20 | [Fact] 21 | public async Task CloseInitializedFbClient() 22 | { 23 | var client = CreateTestFbClient(); 24 | Assert.True(client.Initialized); 25 | 26 | await client.CloseAsync(); 27 | } 28 | 29 | [Fact] 30 | public async Task CloseUninitializedFbClient() 31 | { 32 | var options = new FbOptionsBuilder("fake-secret") 33 | .ConnectTimeout(TimeSpan.FromMilliseconds(50)) 34 | .StartWaitTime(TimeSpan.FromMilliseconds(100)) 35 | .Build(); 36 | var client = new FbClient(options); 37 | Assert.False(client.Initialized); 38 | 39 | await client.CloseAsync(); 40 | } 41 | 42 | [Fact] 43 | public void GetVariation() 44 | { 45 | var eventProcessorMock = new Mock(); 46 | var client = CreateTestFbClient(eventProcessorMock.Object); 47 | 48 | var user = FbUser.Builder("u1").Build(); 49 | var variation = client.BoolVariation("returns-true", user); 50 | Assert.True(variation); 51 | 52 | eventProcessorMock.Verify(x => x.Record(It.IsAny()), Times.Once); 53 | } 54 | 55 | [Fact] 56 | public void GetVariationDetail() 57 | { 58 | var eventProcessorMock = new Mock(); 59 | var client = CreateTestFbClient(eventProcessorMock.Object); 60 | 61 | var user = FbUser.Builder("u1").Build(); 62 | var variationDetail = client.BoolVariationDetail("returns-true", user); 63 | Assert.Equal("returns-true", variationDetail.Key); 64 | Assert.True(variationDetail.Value); 65 | Assert.Equal("3da96792-debf-4878-905a-c9b5f9178cd0", variationDetail.ValueId); 66 | Assert.Equal(ReasonKind.Fallthrough, variationDetail.Kind); 67 | Assert.Equal("fall through targets and rules", variationDetail.Reason); 68 | 69 | eventProcessorMock.Verify(x => x.Record(It.IsAny()), Times.Once); 70 | } 71 | 72 | [Fact] 73 | public void GetAllVariations() 74 | { 75 | var client = CreateTestFbClient(); 76 | var user = FbUser.Builder("u1").Build(); 77 | 78 | var results = client.GetAllVariations(user); 79 | Assert.Single(results); 80 | 81 | var result0 = results[0]; 82 | Assert.Equal("returns-true", result0.Key); 83 | Assert.Equal("true", result0.Value); 84 | Assert.Equal("3da96792-debf-4878-905a-c9b5f9178cd0", result0.ValueId); 85 | Assert.Equal(ReasonKind.Fallthrough, result0.Kind); 86 | Assert.Equal("fall through targets and rules", result0.Reason); 87 | } 88 | 89 | private FbClient CreateTestFbClient(IEventProcessor processor = null) 90 | { 91 | var options = new FbOptionsBuilder("qJHQTVfsZUOu1Q54RLMuIQ-JtrIvNK-k-bARYicOTNQA") 92 | .Streaming(new Uri("ws://localhost/")) 93 | .Build(); 94 | 95 | var store = new DefaultMemoryStore(); 96 | var synchronizer = 97 | new WebSocketDataSynchronizer(options, store, op => _app.CreateFbWebSocket(op)); 98 | var eventProcessor = processor ?? new DefaultEventProcessor(options); 99 | var client = new FbClient(options, store, synchronizer, eventProcessor); 100 | return client; 101 | } 102 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/FeatBit.ServerSdk.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | FeatBit.ServerSdk.Tests 6 | FeatBit.ServerSdk.Tests 7 | false 8 | true 9 | FeatBit.Sdk.Server 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Model/DeserializationTests.DeserializeFeatureFlag.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | StoreKey: ff_example, 3 | Id: Guid_1, 4 | Key: example, 5 | VariationType: boolean, 6 | Variations: [ 7 | { 8 | Id: Guid_2, 9 | Value: true 10 | }, 11 | { 12 | Id: Guid_3, 13 | Value: false 14 | } 15 | ], 16 | TargetUsers: [ 17 | { 18 | KeyIds: [ 19 | true-1, 20 | true-2, 21 | true-3, 22 | true-4, 23 | true-5, 24 | true-6, 25 | true-7, 26 | true-8, 27 | true-9, 28 | true-10 29 | ], 30 | VariationId: Guid_2 31 | }, 32 | { 33 | KeyIds: [ 34 | false-1, 35 | false-2, 36 | false-3, 37 | false-4, 38 | false-5 39 | ], 40 | VariationId: Guid_3 41 | } 42 | ], 43 | Rules: [ 44 | { 45 | Name: Rule 1, 46 | IncludedInExpt: false, 47 | Conditions: [ 48 | { 49 | Property: age, 50 | Op: BiggerThan, 51 | Value: 22 52 | }, 53 | { 54 | Property: name, 55 | Op: EndsWith, 56 | Value: _qa 57 | } 58 | ], 59 | Variations: [ 60 | { 61 | Id: Guid_2, 62 | Rollout: [ 63 | 0.0, 64 | 1.0 65 | ], 66 | ExptRollout: 1.0 67 | } 68 | ] 69 | }, 70 | { 71 | Name: Rule 2, 72 | DispatchKey: keyId, 73 | IncludedInExpt: false, 74 | Conditions: [ 75 | { 76 | Property: country, 77 | Op: IsOneOf, 78 | Value: ["cn","us","jp","gb","es","ss"] 79 | } 80 | ], 81 | Variations: [ 82 | { 83 | Id: Guid_2, 84 | Rollout: [ 85 | 0.0, 86 | 0.2 87 | ], 88 | ExptRollout: 1.0 89 | }, 90 | { 91 | Id: Guid_3, 92 | Rollout: [ 93 | 0.2, 94 | 1.0 95 | ], 96 | ExptRollout: 1.0 97 | } 98 | ] 99 | } 100 | ], 101 | IsEnabled: true, 102 | DisabledVariationId: Guid_3, 103 | Fallthrough: { 104 | IncludedInExpt: true, 105 | Variations: [ 106 | { 107 | Id: Guid_2, 108 | Rollout: [ 109 | 0.0, 110 | 1.0 111 | ], 112 | ExptRollout: 1.0 113 | } 114 | ] 115 | }, 116 | ExptIncludeAllTargets: true, 117 | Version: 1674871495616 118 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Model/DeserializationTests.DeserializeSegment.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | StoreKey: segment_0779d76b-afc6-4886-ab65-af8c004273ad, 3 | Id: Guid_1, 4 | Included: [ 5 | true-6, 6 | true-5, 7 | true-4, 8 | true-3, 9 | true-1, 10 | true-2, 11 | true-7, 12 | true-8, 13 | true-9, 14 | true-10 15 | ], 16 | Excluded: [ 17 | false-1, 18 | false-2, 19 | false-3, 20 | false-4, 21 | false-5 22 | ], 23 | Rules: [ 24 | { 25 | Conditions: [ 26 | { 27 | Property: age, 28 | Op: LessEqualThan, 29 | Value: 22 30 | }, 31 | { 32 | Property: country, 33 | Op: IsOneOf, 34 | Value: ["cn","us","es"] 35 | }, 36 | { 37 | Property: name, 38 | Op: NotEqual, 39 | Value: bob 40 | }, 41 | { 42 | Property: isMember, 43 | Op: IsTrue, 44 | Value: IsTrue 45 | } 46 | ] 47 | } 48 | ], 49 | Version: 1674885283583 50 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Model/DeserializationTests.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using FeatBit.Sdk.Server.Json; 3 | 4 | namespace FeatBit.Sdk.Server.Model; 5 | 6 | [UsesVerify] 7 | public class DeserializationTests 8 | { 9 | [Fact] 10 | public Task DeserializeFeatureFlag() 11 | { 12 | var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Model", "one-flag.json")); 13 | var flag = JsonSerializer.Deserialize(json, ReusableJsonSerializerOptions.Web); 14 | 15 | return Verify(flag); 16 | } 17 | 18 | [Fact] 19 | public Task DeserializeSegment() 20 | { 21 | var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Model", "one-segment.json")); 22 | var segment = JsonSerializer.Deserialize(json, ReusableJsonSerializerOptions.Web); 23 | 24 | return Verify(segment); 25 | } 26 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Model/one-flag.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "174c7138-426d-4434-8a91-af8c00306de3", 3 | "createdAt": "2023-01-16T02:56:19.531Z", 4 | "updatedAt": "2023-01-28T02:04:55.616Z", 5 | "creatorId": "197dea2a-1044-497d-8f1e-f01d2e15a756", 6 | "updatorId": "197dea2a-1044-497d-8f1e-f01d2e15a756", 7 | "envId": "226b9bf8-4af3-4ffa-9b01-162270e4cd40", 8 | "name": "example", 9 | "key": "example", 10 | "variationType": "boolean", 11 | "variations": [ 12 | { 13 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 14 | "value": "true" 15 | }, 16 | { 17 | "id": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 18 | "value": "false" 19 | } 20 | ], 21 | "targetUsers": [ 22 | { 23 | "keyIds": [ 24 | "true-1", 25 | "true-2", 26 | "true-3", 27 | "true-4", 28 | "true-5", 29 | "true-6", 30 | "true-7", 31 | "true-8", 32 | "true-9", 33 | "true-10" 34 | ], 35 | "variationId": "3da96792-debf-4878-905a-c9b5f9178cd0" 36 | }, 37 | { 38 | "keyIds": [ 39 | "false-1", 40 | "false-2", 41 | "false-3", 42 | "false-4", 43 | "false-5" 44 | ], 45 | "variationId": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46" 46 | } 47 | ], 48 | "rules": [ 49 | { 50 | "id": "16273e69-8e1f-4828-bb9b-07576724f6d8", 51 | "name": "Rule 1", 52 | "dispatchKey": null, 53 | "includedInExpt": false, 54 | "conditions": [ 55 | { 56 | "property": "age", 57 | "op": "BiggerThan", 58 | "value": "22" 59 | }, 60 | { 61 | "property": "name", 62 | "op": "EndsWith", 63 | "value": "_qa" 64 | } 65 | ], 66 | "variations": [ 67 | { 68 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 69 | "rollout": [ 70 | 0, 71 | 1 72 | ], 73 | "exptRollout": 1 74 | } 75 | ] 76 | }, 77 | { 78 | "id": "05a6e5fa-6fb5-4bf8-8c2d-d556ed44c143", 79 | "name": "Rule 2", 80 | "dispatchKey": "keyId", 81 | "includedInExpt": false, 82 | "conditions": [ 83 | { 84 | "property": "country", 85 | "op": "IsOneOf", 86 | "value": "[\"cn\",\"us\",\"jp\",\"gb\",\"es\",\"ss\"]" 87 | } 88 | ], 89 | "variations": [ 90 | { 91 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 92 | "rollout": [ 93 | 0, 94 | 0.2 95 | ], 96 | "exptRollout": 1 97 | }, 98 | { 99 | "id": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 100 | "rollout": [ 101 | 0.2, 102 | 1 103 | ], 104 | "exptRollout": 1 105 | } 106 | ] 107 | } 108 | ], 109 | "isEnabled": true, 110 | "disabledVariationId": "19cf9fb6-b790-4877-ab96-b5c5e73e0b46", 111 | "fallthrough": { 112 | "dispatchKey": null, 113 | "includedInExpt": true, 114 | "variations": [ 115 | { 116 | "id": "3da96792-debf-4878-905a-c9b5f9178cd0", 117 | "rollout": [ 118 | 0, 119 | 1 120 | ], 121 | "exptRollout": 1 122 | } 123 | ] 124 | }, 125 | "exptIncludeAllTargets": true, 126 | "tags": [], 127 | "isArchived": false 128 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Model/one-segment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "0779d76b-afc6-4886-ab65-af8c004273ad", 3 | "createdAt": "2023-01-16T04:01:56.63Z", 4 | "updatedAt": "2023-01-28T05:54:43.583Z", 5 | "envId": "226b9bf8-4af3-4ffa-9b01-162270e4cd40", 6 | "name": "segment-01", 7 | "description": "- 10 users in individual targeting return true, 5 returns false\n- Cover types of attribute: Number, String, List, Boolean", 8 | "included": [ 9 | "true-6", 10 | "true-5", 11 | "true-4", 12 | "true-3", 13 | "true-1", 14 | "true-2", 15 | "true-7", 16 | "true-8", 17 | "true-9", 18 | "true-10" 19 | ], 20 | "excluded": [ 21 | "false-1", 22 | "false-2", 23 | "false-3", 24 | "false-4", 25 | "false-5" 26 | ], 27 | "rules": [ 28 | { 29 | "id": "19a77402-3ee7-4ea0-83bc-3e019b982d1b", 30 | "name": "Rule 1", 31 | "conditions": [ 32 | { 33 | "property": "age", 34 | "op": "LessEqualThan", 35 | "value": "22" 36 | }, 37 | { 38 | "property": "country", 39 | "op": "IsOneOf", 40 | "value": "[\"cn\",\"us\",\"es\"]" 41 | }, 42 | { 43 | "property": "name", 44 | "op": "NotEqual", 45 | "value": "bob" 46 | }, 47 | { 48 | "property": "isMember", 49 | "op": "IsTrue", 50 | "value": "IsTrue" 51 | } 52 | ] 53 | } 54 | ], 55 | "isArchived": false 56 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Options/FbOptionsBuilderTests.HasDefaultValues.verified.txt: -------------------------------------------------------------------------------- 1 | { 2 | StartWaitTime: 00:00:05, 3 | Offline: false, 4 | DisableEvents: false, 5 | EnvSecret: secret, 6 | StreamingUri: ws://localhost:5100, 7 | EventUri: http://localhost:5100, 8 | ConnectTimeout: 00:00:03, 9 | CloseTimeout: 00:00:02, 10 | KeepAliveInterval: 00:00:15, 11 | ReconnectRetryDelays: [ 12 | 00:00:00, 13 | 00:00:01, 14 | 00:00:02, 15 | 00:00:03, 16 | 00:00:05, 17 | 00:00:08, 18 | 00:00:13, 19 | 00:00:21, 20 | 00:00:34, 21 | 00:00:55 22 | ], 23 | FlushTimeout: 00:00:05, 24 | MaxFlushWorker: {Scrubbed}, 25 | AutoFlushInterval: 00:00:05, 26 | MaxEventsInQueue: 10000, 27 | MaxEventPerRequest: 50, 28 | MaxSendEventAttempts: 2, 29 | SendEventRetryInterval: 00:00:00.2000000 30 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Options/FbOptionsBuilderTests.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using FeatBit.Sdk.Server.Bootstrapping; 3 | using Microsoft.Extensions.Logging.Abstractions; 4 | 5 | namespace FeatBit.Sdk.Server.Options; 6 | 7 | [UsesVerify] 8 | public class FbOptionsBuilderTests 9 | { 10 | [Fact] 11 | public async Task HasDefaultValues() 12 | { 13 | var builder = new FbOptionsBuilder("secret"); 14 | var options = builder.Build(); 15 | 16 | Assert.IsType(options.LoggerFactory); 17 | Assert.IsType(options.BootstrapProvider); 18 | 19 | await Verify(options) 20 | // max flush worker depends on the number of processors available on the machine 21 | .ScrubMember(x => x.MaxFlushWorker) 22 | .IgnoreMembers(x => x.LoggerFactory, x => x.BootstrapProvider); 23 | } 24 | 25 | [Fact] 26 | public void NoSecretProvided() 27 | { 28 | var options = new FbOptionsBuilder().Build(); 29 | 30 | Assert.Equal(string.Empty, options.EnvSecret); 31 | } 32 | 33 | [Fact] 34 | public void SetBootstrapProvider() 35 | { 36 | var data = new 37 | { 38 | messageType = "data-sync", 39 | data = new 40 | { 41 | eventType = "full", 42 | featureFlags = Array.Empty(), 43 | segments = Array.Empty() 44 | } 45 | }; 46 | 47 | var json = JsonSerializer.Serialize(data); 48 | 49 | _ = new FbOptionsBuilder() 50 | .Offline(true) 51 | .UseJsonBootstrapProvider(json) 52 | .Build(); 53 | 54 | Assert.Throws( 55 | () => new FbOptionsBuilder() 56 | .UseJsonBootstrapProvider(json) 57 | .Build() 58 | ); 59 | } 60 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Retry/BackoffAndJitterRetryPolicyTests.cs: -------------------------------------------------------------------------------- 1 | using Xunit.Abstractions; 2 | 3 | namespace FeatBit.Sdk.Server.Retry; 4 | 5 | public class BackoffAndJitterRetryPolicyTests 6 | { 7 | private readonly ITestOutputHelper _output; 8 | 9 | public BackoffAndJitterRetryPolicyTests(ITestOutputHelper output) 10 | { 11 | _output = output; 12 | } 13 | 14 | [Fact(Skip = "Verify manually")] 15 | public async Task GetNextDelaySeries() 16 | { 17 | var min = TimeSpan.FromSeconds(1); 18 | var max = TimeSpan.FromSeconds(60); 19 | BackoffAndJitterRetryPolicy retryPolicy = new(min, max); 20 | 21 | for (var i = 0; i < 10; i++) 22 | { 23 | var delay = retryPolicy.NextRetryDelay(new RetryContext { RetryAttempt = i }); 24 | await Task.Delay(1000); 25 | _output.WriteLine(delay.ToString()); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Retry/DefaultRetryPolicyTests.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Retry; 2 | 3 | public class DefaultRetryPolicyTests 4 | { 5 | [Theory] 6 | [InlineData(0, 2)] 7 | [InlineData(1, 3)] 8 | [InlineData(2, 5)] 9 | [InlineData(3, 8)] 10 | // "restart" 11 | [InlineData(4, 2)] 12 | [InlineData(5, 3)] 13 | [InlineData(6, 5)] 14 | [InlineData(7, 8)] 15 | public void GetNextRetryDelay(int attempt, int seconds) 16 | { 17 | var delays = new[] { 2, 3, 5, 8 } 18 | .Select(x => TimeSpan.FromSeconds(x)) 19 | .ToList(); 20 | 21 | var policy = new DefaultRetryPolicy(delays); 22 | 23 | var retryContext = new RetryContext { RetryAttempt = attempt }; 24 | Assert.Equal(TimeSpan.FromSeconds(seconds), policy.NextRetryDelay(retryContext)); 25 | } 26 | 27 | [Theory] 28 | [InlineData(1)] 29 | [InlineData(2)] 30 | [InlineData(3)] 31 | [InlineData(5)] 32 | [InlineData(7)] 33 | public void GetConstantRetryDelay(int attempt) 34 | { 35 | var delays = new[] { TimeSpan.FromSeconds(1) }; 36 | 37 | var policy = new DefaultRetryPolicy(delays); 38 | 39 | Assert.Equal(TimeSpan.FromSeconds(1), policy.NextRetryDelay(new RetryContext { RetryAttempt = attempt })); 40 | } 41 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Store/DefaultMemoryStoreTests.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Model; 2 | 3 | namespace FeatBit.Sdk.Server.Store; 4 | 5 | public class DefaultMemoryStoreTests 6 | { 7 | [Fact] 8 | public void CheckPopulated() 9 | { 10 | var store = new DefaultMemoryStore(); 11 | Assert.False(store.Populated); 12 | 13 | store.Populate(Array.Empty()); 14 | 15 | Assert.True(store.Populated); 16 | } 17 | 18 | [Fact] 19 | public void GetFeatureFlag() 20 | { 21 | var store = new DefaultMemoryStore(); 22 | var flag = new FeatureFlagBuilder().Build(); 23 | store.Populate(new[] { flag }); 24 | 25 | var result = store.Get(flag.StoreKey); 26 | Assert.NotNull(result); 27 | Assert.Same(flag, result); 28 | } 29 | 30 | [Fact] 31 | public void GetSegment() 32 | { 33 | var store = new DefaultMemoryStore(); 34 | var segment = new SegmentBuilder().Build(); 35 | store.Populate(new[] { segment }); 36 | 37 | var result = store.Get(segment.StoreKey); 38 | Assert.NotNull(result); 39 | Assert.Same(segment, result); 40 | } 41 | 42 | [Fact] 43 | public void GetNonExistingItem() 44 | { 45 | var store = new DefaultMemoryStore(); 46 | 47 | var result = store.Get("nope"); 48 | Assert.Null(result); 49 | } 50 | 51 | [Fact] 52 | public void FindObjects() 53 | { 54 | var store = new DefaultMemoryStore(); 55 | 56 | StorableObject flag1 = new FeatureFlagBuilder() 57 | .Key("f1") 58 | .Build(); 59 | StorableObject flag2 = new FeatureFlagBuilder() 60 | .Key("f2") 61 | .Build(); 62 | StorableObject segment1 = new SegmentBuilder() 63 | .Id(Guid.NewGuid()) 64 | .Build(); 65 | StorableObject segment2 = new SegmentBuilder() 66 | .Id(Guid.NewGuid()) 67 | .Build(); 68 | 69 | store.Populate(new[] { flag1, flag2, segment1, segment2 }); 70 | 71 | var flags = store.Find(x => x.StoreKey.StartsWith(StoreKeys.FlagPrefix)); 72 | var segments = store.Find(x => x.StoreKey.StartsWith(StoreKeys.SegmentPrefix)); 73 | 74 | Assert.Equal(2, flags.Count); 75 | Assert.Equal(2, segments.Count); 76 | } 77 | 78 | [Fact] 79 | public void UpsertFeatureFlag() 80 | { 81 | const string flagKey = "hello"; 82 | 83 | var store = new DefaultMemoryStore(); 84 | var flag = new FeatureFlagBuilder() 85 | .Key(flagKey) 86 | .VariationType("boolean") 87 | .Version(1) 88 | .Build(); 89 | 90 | // insert 91 | var insertResult = store.Upsert(flag); 92 | Assert.True(insertResult); 93 | 94 | var inserted = store.Get(flag.StoreKey); 95 | Assert.NotNull(inserted); 96 | Assert.Same(flag, inserted); 97 | 98 | // update 99 | var updatedFlag = new FeatureFlagBuilder() 100 | .Key(flagKey) 101 | .VariationType("json") 102 | .Version(2) 103 | .Build(); 104 | var updatedResult = store.Upsert(updatedFlag); 105 | Assert.True(updatedResult); 106 | 107 | var updated = store.Get(flag.StoreKey); 108 | Assert.Equal("json", updated.VariationType); 109 | Assert.Equal(2, updated.Version); 110 | 111 | // update with old version data (store's data won't change) 112 | var oldFlag = new FeatureFlagBuilder() 113 | .Key(flagKey) 114 | .VariationType("string") 115 | .Version(0) 116 | .Build(); 117 | 118 | var updateUsingOldFlag = store.Upsert(oldFlag); 119 | Assert.False(updateUsingOldFlag); 120 | 121 | var origin = store.Get(flag.StoreKey); 122 | Assert.Equal("json", origin.VariationType); 123 | Assert.Equal(2, origin.Version); 124 | } 125 | 126 | [Fact] 127 | public void GetVersion() 128 | { 129 | var store = new DefaultMemoryStore(); 130 | Assert.Equal(0, store.Version()); 131 | 132 | const string flagKey = "hello"; 133 | 134 | // insert 135 | var flag = new FeatureFlagBuilder() 136 | .Key(flagKey) 137 | .VariationType("boolean") 138 | .Version(123) 139 | .Build(); 140 | store.Populate(new[] { flag }); 141 | Assert.Equal(123, store.Version()); 142 | 143 | // update 144 | var updatedFlag = new FeatureFlagBuilder() 145 | .Key(flagKey) 146 | .VariationType("json") 147 | .Version(456) 148 | .Build(); 149 | store.Upsert(updatedFlag); 150 | Assert.Equal(456, store.Version()); 151 | } 152 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/TestApp.cs: -------------------------------------------------------------------------------- 1 | using FeatBit.Sdk.Server.Options; 2 | using FeatBit.Sdk.Server.Transport; 3 | using Microsoft.AspNetCore; 4 | using Microsoft.AspNetCore.Hosting; 5 | using Microsoft.AspNetCore.Mvc.Testing; 6 | using Microsoft.AspNetCore.TestHost; 7 | 8 | namespace FeatBit.Sdk.Server; 9 | 10 | public class TestApp : WebApplicationFactory 11 | { 12 | internal Uri GetWsUri(string op) 13 | { 14 | var serverUrl = Server.BaseAddress; 15 | var wsUri = new UriBuilder(serverUrl) 16 | { 17 | Scheme = "ws", 18 | Path = "ws", 19 | Query = $"?op={op}" 20 | }.Uri; 21 | 22 | return wsUri; 23 | } 24 | 25 | internal WebSocketTransport CreateWebSocketTransport() 26 | { 27 | var client = Server.CreateWebSocketClient(); 28 | 29 | return new WebSocketTransport( 30 | webSocketFactory: (uri, cancellationToken) => client.ConnectAsync(uri, cancellationToken) 31 | ); 32 | } 33 | 34 | internal FbWebSocket CreateFbWebSocket(string op, Func configure = null) 35 | { 36 | var builder = new FbOptionsBuilder("fake-env-secret"); 37 | configure?.Invoke(builder); 38 | var options = builder.Build(); 39 | 40 | Uri WebsocketUriResolver(FbOptions ops) 41 | { 42 | return 43 | // if not default streaming uri, then use user defined uri 44 | ops.StreamingUri.OriginalString != "ws://localhost:5100" 45 | ? ops.StreamingUri 46 | : GetWsUri(op); 47 | } 48 | 49 | return new FbWebSocket(options, CreateWebSocketTransport, WebsocketUriResolver); 50 | } 51 | 52 | internal FbWebSocket CreateFbWebSocket(FbOptions options) 53 | { 54 | return new FbWebSocket(options, CreateWebSocketTransport); 55 | } 56 | 57 | internal FbWebSocket CreateFbWebSocket(FbOptions options, Uri webSocketUri) 58 | { 59 | return new FbWebSocket(options, CreateWebSocketTransport, _ => webSocketUri); 60 | } 61 | 62 | protected override TestServer CreateServer(IWebHostBuilder builder) => 63 | base.CreateServer(builder.UseSolutionRelativeContentRoot("")); 64 | 65 | protected override IWebHostBuilder CreateWebHostBuilder() => 66 | WebHost.CreateDefaultBuilder().UseStartup(); 67 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/TestAppCollection.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server; 2 | 3 | [CollectionDefinition(nameof(TestApp))] 4 | public class TestAppCollection : ICollectionFixture 5 | { 6 | // This class has no code, and is never created. Its purpose is simply 7 | // to be the place to apply [CollectionDefinition] and all the 8 | // ICollectionFixture<> interfaces. 9 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/TestData.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace FeatBit.Sdk.Server; 4 | 5 | public static class TestData 6 | { 7 | public static readonly byte[] FullDataSet = Encoding.UTF8.GetBytes( 8 | File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "DataSynchronizer", "full-data-set.json")) 9 | ); 10 | 11 | public static readonly byte[] PatchDataSet = Encoding.UTF8.GetBytes( 12 | File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "DataSynchronizer", "patch-data-set.json")) 13 | ); 14 | 15 | public static readonly string BootstrapJson = File.ReadAllText( 16 | Path.Combine(AppContext.BaseDirectory, "Bootstrapping", "featbit-bootstrap.json") 17 | ); 18 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Transport/ConnectionTokenTests.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server.Transport; 2 | 3 | public class ConnectionTokenTests 4 | { 5 | [Fact] 6 | public void NewConnectionToken() 7 | { 8 | var token = ConnectionToken.New("qJHQTVfsZUOu1Q54RLMuIQ-JtrIvNK-k-bARYicOTNQA"); 9 | 10 | Assert.False(string.IsNullOrWhiteSpace(token)); 11 | } 12 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Transport/WebSocketsTransportTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net.WebSockets; 2 | using System.Text; 3 | 4 | namespace FeatBit.Sdk.Server.Transport; 5 | 6 | [Collection(nameof(TestApp))] 7 | public class WebSocketsTransportTests 8 | { 9 | private readonly TestApp _app; 10 | 11 | public WebSocketsTransportTests(TestApp app) 12 | { 13 | _app = app; 14 | } 15 | 16 | [Fact] 17 | public async Task StartAndStopAsync() 18 | { 19 | var transport = _app.CreateWebSocketTransport(); 20 | var uri = _app.GetWsUri("echo"); 21 | 22 | await transport.StartAsync(uri); 23 | 24 | Assert.Equal(WebSocketState.Open, transport.State); 25 | 26 | await Task.Delay(100); 27 | 28 | await transport.StopAsync(); 29 | Assert.Equal(WebSocketState.Closed, transport.State); 30 | } 31 | 32 | [Fact] 33 | public async Task SendReceiveAsync() 34 | { 35 | var transport = _app.CreateWebSocketTransport(); 36 | var uri = _app.GetWsUri("echo"); 37 | 38 | await transport.StartAsync(uri); 39 | 40 | var sent = Encoding.UTF8.GetBytes("hello world"); 41 | await transport.Output.WriteAsync(sent); 42 | 43 | var result = await transport.Input.ReadAsync(); 44 | 45 | Assert.False(result.IsCanceled); 46 | Assert.False(result.IsCompleted); 47 | Assert.False(result.Buffer.IsEmpty); 48 | Assert.True(result.Buffer.IsSingleSegment); 49 | 50 | var received = result.Buffer.FirstSpan.ToArray(); 51 | 52 | // received message should end with an RecordSeparator 53 | Assert.Equal(TextMessageFormatter.RecordSeparator, received.Last()); 54 | 55 | var receivedContent = received.SkipLast(1); 56 | Assert.Equal(sent, receivedContent); 57 | 58 | await transport.StopAsync(); 59 | } 60 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/UriTests.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server; 2 | 3 | public class UriTests 4 | { 5 | // According to the documentation: https://learn.microsoft.com/en-us/dotnet/api/system.uri.-ctor?view=net-6.0#system-uri-ctor(system-uri-system-string) 6 | // if the relative part of baseUri is to be preserved in the constructed Uri, 7 | // the baseUri has relative parts (like /api), then the relative part must be terminated with a slash, (like /api/) 8 | [Theory] 9 | [InlineData("https://contoso.com/featbit/", "relative", "https://contoso.com/featbit/relative")] 10 | [InlineData("https://contoso.com/featbit/", "/relative", "https://contoso.com/relative")] 11 | [InlineData("https://contoso.com/featbit/", "relative?type=server", "https://contoso.com/featbit/relative?type=server")] 12 | [InlineData("https://contoso.com", "relative", "https://contoso.com/relative")] 13 | [InlineData("https://contoso.com", "/relative", "https://contoso.com/relative")] 14 | [InlineData("https://contoso.com/", "/relative", "https://contoso.com/relative")] 15 | public void CreateUri(string @base, string relative, string expected) 16 | { 17 | var baseUri = new Uri(@base); 18 | var uri = new Uri(baseUri, relative); 19 | 20 | Assert.Equal(expected, uri.ToString()); 21 | } 22 | } -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using Moq; -------------------------------------------------------------------------------- /tests/FeatBit.ServerSdk.Tests/ValueConverterTests.cs: -------------------------------------------------------------------------------- 1 | namespace FeatBit.Sdk.Server; 2 | 3 | public class ValueConverterTests 4 | { 5 | [Theory] 6 | [InlineData("true", true)] 7 | [InlineData("TRUE", true)] 8 | [InlineData("false", false)] 9 | [InlineData("FALSE", false)] 10 | public void BoolConverter(string value, bool expected) 11 | { 12 | _ = ValueConverters.Bool(value, out var converted); 13 | 14 | Assert.Equal(expected, converted); 15 | } 16 | 17 | [Fact] 18 | public void StringConverter() 19 | { 20 | var success = ValueConverters.String("hello", out var converted); 21 | 22 | Assert.True(success); 23 | Assert.Equal("hello", converted); 24 | } 25 | 26 | [Theory] 27 | [InlineData("123", 123)] 28 | [InlineData("123.4", 0)] 29 | [InlineData("v123", 0)] 30 | public void IntConverter(string value, int expected) 31 | { 32 | _ = ValueConverters.Int(value, out var converted); 33 | 34 | Assert.Equal(expected, converted); 35 | } 36 | 37 | [Theory] 38 | [InlineData("123", 123)] 39 | [InlineData("123.45", 123.45)] 40 | [InlineData("v123.4", 0)] 41 | public void FloatConverter(string value, float expected) 42 | { 43 | _ = ValueConverters.Float(value, out var converted); 44 | 45 | Assert.Equal(expected, converted); 46 | } 47 | 48 | [Theory] 49 | [InlineData("123", 123)] 50 | [InlineData("123.456", 123.456)] 51 | [InlineData("v123.4", 0)] 52 | public void DoubleConverter(string value, float expected) 53 | { 54 | _ = ValueConverters.Double(value, out var converted); 55 | 56 | Assert.Equal(expected, converted, 5); 57 | } 58 | } --------------------------------------------------------------------------------