├── .editorconfig ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── CHANGE_LOG.md ├── LICENSE ├── README.md ├── RimDev.FeatureFlags.sln ├── appveyor.yml ├── build.cmd ├── build.ps1 ├── build.sh ├── nuget.config ├── samples └── FeatureFlags.AspNetCore │ ├── FeatureFlags.AspNetCore.csproj │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Startup.cs │ ├── TestFeature.cs │ ├── TestFeature2.cs │ ├── TestFeature3.cs │ ├── appsettings.Development.json │ └── appsettings.json ├── screenshot.png ├── src ├── RimDev.AspNetCore.FeatureFlags.Core │ ├── RimDev.AspNetCore.FeatureFlags.Core.csproj │ └── StartupExtensions.cs ├── RimDev.AspNetCore.FeatureFlags.UI │ ├── FeatureFlagUISettings.cs │ ├── FeatureFlagsUIBuilder.cs │ ├── FeatureRequest.cs │ ├── FeatureResponse.cs │ ├── HttpResponseExtensions.cs │ ├── RimDev.AspNetCore.FeatureFlags.UI.csproj │ ├── UIStartupExtensions.cs │ ├── index.html │ └── main.js └── RimDev.AspNetCore.FeatureFlags │ ├── Feature.cs │ ├── FeatureFlagsSessionManager.cs │ ├── FeatureFlagsSettings.cs │ ├── IEnumerableExtensions.cs │ ├── JsonBooleanConverter.cs │ ├── RimDev.AspNetCore.FeatureFlags.csproj │ ├── StartupExtensions.cs │ └── TypeExtensions.cs ├── tests └── RimDev.AspNetCore.FeatureFlags.Tests │ ├── FeatureFlagsSessionManagerTests.cs │ ├── FeatureFlagsUIBuilderTests.cs │ ├── FeatureTests.cs │ ├── HttpContentExtensions.cs │ ├── JsonBooleanConverterTests.cs │ ├── Properties │ └── launchSettings.json │ ├── RimDev.AspNetCore.FeatureFlags.Tests.csproj │ ├── TestFeature.cs │ ├── TestFeature2.cs │ └── Testing │ ├── ApplicationFactory │ ├── TestStartup.cs │ ├── TestWebApplicationCollection.cs │ └── TestWebApplicationFactory.cs │ ├── Configuration │ ├── RimDevTestsConfiguration.cs │ ├── RimDevTestsSqlConfiguration.cs │ └── TestConfigurationHelpers.cs │ ├── Database │ ├── EmptyDatabaseCollection.cs │ ├── EmptyDatabaseFixture.cs │ ├── TestSqlClientDatabaseFixture.cs │ └── Tests │ │ └── EmptyDatabaseFixtureTests.cs │ └── Rng.cs └── version.txt /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Default settings: 7 | # A newline ending every file 8 | # Use 2 spaces as indentation 9 | # Trim trailing whitespace 10 | [*] 11 | charset = utf-8 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | trim_trailing_whitespace = true 16 | 17 | [*.cs] 18 | indent_size = 4 19 | 20 | # MSBuild files 21 | [*.csproj] 22 | indent_size = 2 23 | 24 | # XML config files 25 | [*.{msbuild,props,targets,ruleset,config,nuspec}] 26 | indent_size = 2 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/aspnetcore 3 | # Edit at https://www.gitignore.io/?templates=aspnetcore 4 | 5 | ### ASPNETCore ### 6 | ## Ignore Visual Studio temporary files, build results, and 7 | ## files generated by popular Visual Studio add-ons. 8 | 9 | # User-specific files 10 | *.suo 11 | *.user 12 | *.userosscache 13 | *.sln.docstates 14 | 15 | # User-specific files (MonoDevelop/Xamarin Studio) 16 | *.userprefs 17 | 18 | # Build results 19 | [Dd]ebug/ 20 | [Dd]ebugPublic/ 21 | [Rr]elease/ 22 | [Rr]eleases/ 23 | x64/ 24 | x86/ 25 | bld/ 26 | [Bb]in/ 27 | [Oo]bj/ 28 | [Ll]og/ 29 | 30 | # Visual Studio 2015 cache/options directory 31 | .vs/ 32 | # Uncomment if you have tasks that create the project's static files in wwwroot 33 | #wwwroot/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # DNX 49 | project.lock.json 50 | project.fragment.lock.json 51 | artifacts/ 52 | 53 | *_i.c 54 | *_p.c 55 | *_i.h 56 | *.ilk 57 | *.meta 58 | *.obj 59 | *.pch 60 | *.pdb 61 | *.pgc 62 | *.pgd 63 | *.rsp 64 | *.sbr 65 | *.tlb 66 | *.tli 67 | *.tlh 68 | *.tmp 69 | *.tmp_proj 70 | *.log 71 | *.vspscc 72 | *.vssscc 73 | .builds 74 | *.pidb 75 | *.svclog 76 | *.scc 77 | 78 | # Chutzpah Test files 79 | _Chutzpah* 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opendb 86 | *.opensdf 87 | *.sdf 88 | *.cachefile 89 | *.VC.db 90 | *.VC.VC.opendb 91 | 92 | # Visual Studio profiler 93 | *.psess 94 | *.vsp 95 | *.vspx 96 | *.sap 97 | 98 | # TFS 2012 Local Workspace 99 | $tf/ 100 | 101 | # Guidance Automation Toolkit 102 | *.gpState 103 | 104 | # ReSharper is a .NET coding add-in 105 | _ReSharper*/ 106 | *.[Rr]e[Ss]harper 107 | *.DotSettings.user 108 | 109 | # JustCode is a .NET coding add-in 110 | .JustCode 111 | 112 | # TeamCity is a build add-in 113 | _TeamCity* 114 | 115 | # DotCover is a Code Coverage Tool 116 | *.dotCover 117 | 118 | # Visual Studio code coverage results 119 | *.coverage 120 | *.coveragexml 121 | 122 | # NCrunch 123 | _NCrunch_* 124 | .*crunch*.local.xml 125 | nCrunchTemp_* 126 | 127 | # MightyMoose 128 | *.mm.* 129 | AutoTest.Net/ 130 | 131 | # Web workbench (sass) 132 | .sass-cache/ 133 | 134 | # Installshield output folder 135 | [Ee]xpress/ 136 | 137 | # DocProject is a documentation generator add-in 138 | DocProject/buildhelp/ 139 | DocProject/Help/*.HxT 140 | DocProject/Help/*.HxC 141 | DocProject/Help/*.hhc 142 | DocProject/Help/*.hhk 143 | DocProject/Help/*.hhp 144 | DocProject/Help/Html2 145 | DocProject/Help/html 146 | 147 | # Click-Once directory 148 | publish/ 149 | 150 | # Publish Web Output 151 | *.[Pp]ublish.xml 152 | *.azurePubxml 153 | # TODO: Comment the next line if you want to checkin your web deploy settings 154 | # but database connection strings (with potential passwords) will be unencrypted 155 | *.pubxml 156 | *.publishproj 157 | 158 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 159 | # checkin your Azure Web App publish settings, but sensitive information contained 160 | # in these scripts will be unencrypted 161 | PublishScripts/ 162 | 163 | # NuGet Packages 164 | *.nupkg 165 | # The packages folder can be ignored because of Package Restore 166 | **/packages/* 167 | # except build/, which is used as an MSBuild target. 168 | !**/packages/build/ 169 | # Uncomment if necessary however generally it will be regenerated when needed 170 | #!**/packages/repositories.config 171 | # NuGet v3's project.json files produces more ignoreable files 172 | *.nuget.props 173 | *.nuget.targets 174 | 175 | # Microsoft Azure Build Output 176 | csx/ 177 | *.build.csdef 178 | 179 | # Microsoft Azure Emulator 180 | ecf/ 181 | rcf/ 182 | 183 | # Windows Store app package directories and files 184 | AppPackages/ 185 | BundleArtifacts/ 186 | Package.StoreAssociation.xml 187 | _pkginfo.txt 188 | 189 | # Visual Studio cache files 190 | # files ending in .cache can be ignored 191 | *.[Cc]ache 192 | # but keep track of directories ending in .cache 193 | !*.[Cc]ache/ 194 | 195 | # Others 196 | ClientBin/ 197 | ~$* 198 | *~ 199 | *.dbmdl 200 | *.dbproj.schemaview 201 | *.jfm 202 | *.pfx 203 | *.publishsettings 204 | node_modules/ 205 | orleans.codegen.cs 206 | 207 | # Since there are multiple workflows, uncomment next line to ignore bower_components 208 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 209 | #bower_components/ 210 | 211 | # RIA/Silverlight projects 212 | Generated_Code/ 213 | 214 | # Backup & report files from converting an old project file 215 | # to a newer Visual Studio version. Backup files are not needed, 216 | # because we have git ;-) 217 | _UpgradeReport_Files/ 218 | Backup*/ 219 | UpgradeLog*.XML 220 | UpgradeLog*.htm 221 | 222 | # SQL Server files 223 | *.mdf 224 | *.ldf 225 | 226 | # Business Intelligence projects 227 | *.rdl.data 228 | *.bim.layout 229 | *.bim_*.settings 230 | 231 | # Microsoft Fakes 232 | FakesAssemblies/ 233 | 234 | # GhostDoc plugin setting file 235 | *.GhostDoc.xml 236 | 237 | # Node.js Tools for Visual Studio 238 | .ntvs_analysis.dat 239 | 240 | # Visual Studio 6 build log 241 | *.plg 242 | 243 | # Visual Studio 6 workspace options file 244 | *.opt 245 | 246 | # Visual Studio LightSwitch build output 247 | **/*.HTMLClient/GeneratedArtifacts 248 | **/*.DesktopClient/GeneratedArtifacts 249 | **/*.DesktopClient/ModelManifest.xml 250 | **/*.Server/GeneratedArtifacts 251 | **/*.Server/ModelManifest.xml 252 | _Pvt_Extensions 253 | 254 | # Paket dependency manager 255 | .paket/paket.exe 256 | paket-files/ 257 | 258 | # FAKE - F# Make 259 | .fake/ 260 | 261 | # JetBrains Rider 262 | .idea/ 263 | *.sln.iml 264 | 265 | # CodeRush 266 | .cr/ 267 | 268 | # Python Tools for Visual Studio (PTVS) 269 | __pycache__/ 270 | *.pyc 271 | 272 | # Cake - Uncomment if you are using it 273 | # tools/ 274 | 275 | # End of https://www.gitignore.io/api/aspnetcore 276 | 277 | # Extra ignores 278 | 279 | build/ 280 | tools/ 281 | build.cake 282 | .DS_Store 283 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | "program": "${workspaceFolder}/samples/FeatureFlags.AspNetCore/bin/Debug/netcoreapp3.1/FeatureFlags.AspNetCore.dll", 13 | "args": [], 14 | "cwd": "${workspaceFolder}/samples/FeatureFlags.AspNetCore", 15 | "stopAtEntry": false, 16 | "internalConsoleOptions": "openOnSessionStart", 17 | "launchBrowser": { 18 | "enabled": true, 19 | "args": "${auto-detect-url}", 20 | "windows": { 21 | "command": "cmd.exe", 22 | "args": "/C start ${auto-detect-url}" 23 | }, 24 | "osx": { 25 | "command": "open" 26 | }, 27 | "linux": { 28 | "command": "xdg-open" 29 | } 30 | }, 31 | "env": { 32 | "ASPNETCORE_ENVIRONMENT": "Development" 33 | }, 34 | "sourceFileMap": { 35 | "/Views": "${workspaceFolder}/Views" 36 | } 37 | }, 38 | { 39 | "name": ".NET Core Attach", 40 | "type": "coreclr", 41 | "request": "attach", 42 | "processId": "${command:pickProcess}" 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/samples/FeatureFlags.AspNetCore/FeatureFlags.AspNetCore.csproj" 11 | ], 12 | "problemMatcher": "$msCompile" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /CHANGE_LOG.md: -------------------------------------------------------------------------------- 1 | # RimDev.FeatureFlags Change Log 2 | 3 | ## v3.0 May-July 2022 4 | 5 | Major rewrite to move to using the `IFeatureManagerSnapshot` and `ISessionManager` interfaces from [Microsoft.FeatureManagement](https://www.nuget.org/packages/Microsoft.FeatureManagement/). This will allow layering of `ISessionManager` implementations to give flexibility in how the feature value is calculated. 6 | 7 | Under RimDev.FeatureFlags v2 you were limited to only looking at a single database table for values. With RimDev.FeatureFlags v3, you can allow per-user feature flag values to override per-application values; or some other desired layering. You can mix and match any set of value providers which implement the `ISessionManager` interface when constructing the `IFeatureManagerSnapshot` object in a Dependency Injection container. 8 | 9 | You can also choose to use any reasonable implementation of `IFeatureManagerSnapshot` instead of [Microsoft.FeatureManagement](https://www.nuget.org/packages/Microsoft.FeatureManagement/) if you don't need the advanced features that the Microsoft implementation provides. The out of the box experience uses [Lussatite.FeatureManagement](https://www.nuget.org/packages/Lussatite.FeatureManagement) which is a light implementation which allows layering of session managers. 10 | 11 | The main package now targets .NET Standard 2.0, with an additional package (RimDev.AspNetCore.FeatureFlags.UI) added to provide the pre-built .NET Core 3.1+ / .NET 5+ web UI and API. 12 | 13 | ### Additions 14 | 15 | - `FeatureFlagUiSettings`: Is the new settings class for the UI project classes. Some of these properties used to live in `FeatureFlagOptions`. 16 | 17 | ### Changes 18 | 19 | - All UI-related classes / methods have been moved to the UI package. 20 | - The default ServiceLifetime for a Feature is now `Scoped` instead of `Transient`. There is no longer a way to set the service lifetime. 21 | - Building a `Feature` object now looks at the registered `IFeatureManagementSnapshot` to obtain the value. 22 | - `FeatureSetRequest` is now `FeatureRequest` in the UI project. 23 | - The description for a `Feature` now comes from the `[Description(string)]` attribute on the class, not from an overridden property. 24 | - The "Value" property is now named "Enabled". 25 | - Use of LazyCache `IAppCache` where appropriate. 26 | 27 | ### Removed / Obsoleted 28 | 29 | - `Feature.ServiceLifetime` property. All feature objects are constructed as `Scoped` lifetime. 30 | - Some properties in `FeatureFlagOptions` related to the user-interface / API. They have been moved to `FeatureFlagUiSettings`. 31 | - Classes: `CachedSqlFeatureProvider`, `FeatureFlags`, `FeatureFlagsBuilder`, and `InMemoryFeatureProvider` have all been removed. 32 | - Interfaces: `IFeatureProvider` has been removed. 33 | 34 | ## v2.2 May 2022 35 | 36 | - Rework build process. 37 | - Package upgrades to address vulnerable dependencies. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Ritter Insurance Marketing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RimDev.FeatureFlags 2 | 3 | A library for strongly typed feature flags in ASP.NET Core. 4 | 5 | ![Screenshot](https://raw.githubusercontent.com/ritterim/RimDev.FeatureFlags/master/screenshot.png) 6 | 7 | | Package | Version | 8 | |-------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------| 9 | | [RimDev.AspNetCore.FeatureFlags](https://www.nuget.org/packages/RimDev.AspNetCore.FeatureFlags) | ![RimDev.AspNetCore.FeatureFlags NuGet Version](https://img.shields.io/nuget/v/RimDev.AspNetCore.FeatureFlags.svg) | 10 | | [RimDev.AspNetCore.FeatureFlags.UI](https://www.nuget.org/packages/RimDev.AspNetCore.FeatureFlags.UI) | ![RimDev.AspNetCore.FeatureFlags.UI NuGet Version](https://img.shields.io/nuget/v/RimDev.AspNetCore.FeatureFlags.UI.svg) | 11 | 12 | ## Installation 13 | 14 | Install the [RimDev.AspNetCore.FeatureFlags](https://www.nuget.org/packages/RimDev.AspNetCore.FeatureFlags) and (optional) [RimDev.AspNetCore.FeatureFlags.UI](https://www.nuget.org/packages/RimDev.AspNetCore.FeatureFlags.UI) NuGet packages. 15 | 16 | ``` 17 | > dotnet add package RimDev.AspNetCore.FeatureFlags 18 | > dotnet add package RimDev.AspNetCore.FeatureFlags.UI 19 | ``` 20 | 21 | or 22 | 23 | ``` 24 | PM> Install-Package RimDev.AspNetCore.FeatureFlags 25 | PM> Install-Package RimDev.AspNetCore.FeatureFlags.UI 26 | ``` 27 | 28 | ## Usage 29 | 30 | You'll need to wire up `Startup.cs` as follows: 31 | 32 | ```csharp 33 | using Microsoft.AspNetCore.Builder; 34 | using Microsoft.AspNetCore.Hosting; 35 | using Microsoft.Extensions.Configuration; 36 | using Microsoft.Extensions.DependencyInjection; 37 | using RimDev.AspNetCore.FeatureFlags; 38 | 39 | namespace MyApplication 40 | { 41 | public class Startup 42 | { 43 | public IConfiguration Configuration { get; } 44 | 45 | public Startup(IConfiguration configuration) 46 | { 47 | Configuration = configuration; 48 | } 49 | 50 | public void ConfigureServices(IServiceCollection services) 51 | { 52 | var featureFlagsConnectionString 53 | = configuration.GetConnectionString("featureFlags"); 54 | var featureFlagsInitializationConnectionString 55 | = configuration.GetConnectionString("featureFlagsInitialization"); 56 | 57 | services.AddRimDevFeatureFlags( 58 | configuration, 59 | new[] { typeof(Startup).Assembly }, 60 | connectionString: featureFlagsConnectionString, 61 | initializationConnectionString: featureFlagsInitializationConnectionString 62 | ); 63 | 64 | // IFeatureManagerSnapshot should always be scoped / per-request lifetime 65 | services.AddScoped(serviceProvider => 66 | { 67 | var featureFlagSessionManager = serviceProvider.GetRequiredService(); 68 | var featureFlagsSettings = serviceProvider.GetRequiredService(); 69 | return new LussatiteLazyCacheFeatureManager( 70 | featureFlagsSettings.FeatureFlagTypes.Select(x => x.Name).ToList(), 71 | new [] 72 | { 73 | // in other use cases, you might list multiple ISessionManager objects to have layers 74 | featureFlagSessionManager 75 | }); 76 | }); 77 | 78 | services.AddRimDevFeatureFlagsUi(); 79 | } 80 | 81 | public void Configure(IApplicationBuilder app, IHostingEnvironment env) 82 | { 83 | app.UseFeatureFlags(options); 84 | 85 | app.UseRouting(); 86 | app.UseEndpoints(endpoints => 87 | { 88 | // IMPORTANT: Controlling access of the UI / API of this library is the responsibility of the user. 89 | // Apply authentication / authorization around the `UseFeatureFlagsUI` method as needed, 90 | // as this method wires up the various endpoints. 91 | endpoints.MapFeatureFlagsUI(options); 92 | }); 93 | } 94 | } 95 | } 96 | ``` 97 | 98 | Next, create feature flags like this in the assemblies passed to `AddRimDevFeatureFlags()`: 99 | 100 | ```csharp 101 | using RimDev.AspNetCore.FeatureFlags; 102 | 103 | namespace MyApplication 104 | { 105 | [Description("My feature description.")] // Optional displays on the UI 106 | public class MyFeature : Feature 107 | { 108 | // Feature classes could include other static information if desired by your application. 109 | } 110 | } 111 | ``` 112 | 113 | **Now you can dependency inject any of your feature flags using the standard ASP.NET Core IoC!** 114 | 115 | ```csharp 116 | public class MyController : Controller 117 | { 118 | private readonly MyFeature myFeature; 119 | 120 | public MyController(MyFeature myFeature) 121 | { 122 | this.myFeature = myFeature; 123 | } 124 | 125 | // Use myFeature instance here, using myFeature.Value for the on/off toggle value. 126 | } 127 | ``` 128 | 129 | ## UI 130 | 131 | The UI wired up by `UseFeatureFlagsUI` is available by default at `/_features`. The UI and API endpoints can be modified in `FeatureFlagUiSettings` if you'd like, too. 132 | 133 | ## License 134 | 135 | MIT License 136 | -------------------------------------------------------------------------------- /RimDev.FeatureFlags.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C5D6AB17-075D-488D-9AC6-7B20DB92193F}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RimDev.AspNetCore.FeatureFlags", "src\RimDev.AspNetCore.FeatureFlags\RimDev.AspNetCore.FeatureFlags.csproj", "{B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{AFCED846-6A9F-42B6-AB9D-E9A4C0ABF315}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RimDev.AspNetCore.FeatureFlags.Tests", "tests\RimDev.AspNetCore.FeatureFlags.Tests\RimDev.AspNetCore.FeatureFlags.Tests.csproj", "{7DAC896A-0FF4-4161-AB55-978ECA963BB4}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{FB9B175B-F005-40BB-8880-B03F1BF7EB12}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FeatureFlags.AspNetCore", "samples\FeatureFlags.AspNetCore\FeatureFlags.AspNetCore.csproj", "{8577A608-3E1C-43A2-9196-A273A0E973CD}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RimDev.AspNetCore.FeatureFlags.UI", "src\RimDev.AspNetCore.FeatureFlags.UI\RimDev.AspNetCore.FeatureFlags.UI.csproj", "{0C7FB596-F30C-4F96-8739-0AD05BA36C92}" 19 | EndProject 20 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RimDev.AspNetCore.FeatureFlags.Core", "src\RimDev.AspNetCore.FeatureFlags.Core\RimDev.AspNetCore.FeatureFlags.Core.csproj", "{1544C16F-9E6A-47C5-98FB-D27B98271D6C}" 21 | EndProject 22 | Global 23 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 24 | Debug|Any CPU = Debug|Any CPU 25 | Debug|x64 = Debug|x64 26 | Debug|x86 = Debug|x86 27 | Release|Any CPU = Release|Any CPU 28 | Release|x64 = Release|x64 29 | Release|x86 = Release|x86 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 35 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Debug|x64.ActiveCfg = Debug|Any CPU 38 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Debug|x64.Build.0 = Debug|Any CPU 39 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Debug|x86.ActiveCfg = Debug|Any CPU 40 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Debug|x86.Build.0 = Debug|Any CPU 41 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Release|x64.ActiveCfg = Release|Any CPU 44 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Release|x64.Build.0 = Release|Any CPU 45 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Release|x86.ActiveCfg = Release|Any CPU 46 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5}.Release|x86.Build.0 = Release|Any CPU 47 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Debug|x64.ActiveCfg = Debug|Any CPU 50 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Debug|x64.Build.0 = Debug|Any CPU 51 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Debug|x86.ActiveCfg = Debug|Any CPU 52 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Debug|x86.Build.0 = Debug|Any CPU 53 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Release|Any CPU.ActiveCfg = Release|Any CPU 54 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Release|Any CPU.Build.0 = Release|Any CPU 55 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Release|x64.ActiveCfg = Release|Any CPU 56 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Release|x64.Build.0 = Release|Any CPU 57 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Release|x86.ActiveCfg = Release|Any CPU 58 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4}.Release|x86.Build.0 = Release|Any CPU 59 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 60 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Debug|Any CPU.Build.0 = Debug|Any CPU 61 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Debug|x64.ActiveCfg = Debug|Any CPU 62 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Debug|x64.Build.0 = Debug|Any CPU 63 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Debug|x86.ActiveCfg = Debug|Any CPU 64 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Debug|x86.Build.0 = Debug|Any CPU 65 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Release|Any CPU.ActiveCfg = Release|Any CPU 66 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Release|Any CPU.Build.0 = Release|Any CPU 67 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Release|x64.ActiveCfg = Release|Any CPU 68 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Release|x64.Build.0 = Release|Any CPU 69 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Release|x86.ActiveCfg = Release|Any CPU 70 | {8577A608-3E1C-43A2-9196-A273A0E973CD}.Release|x86.Build.0 = Release|Any CPU 71 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 72 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Debug|Any CPU.Build.0 = Debug|Any CPU 73 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Debug|x64.ActiveCfg = Debug|Any CPU 74 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Debug|x64.Build.0 = Debug|Any CPU 75 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Debug|x86.ActiveCfg = Debug|Any CPU 76 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Debug|x86.Build.0 = Debug|Any CPU 77 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Release|Any CPU.ActiveCfg = Release|Any CPU 78 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Release|Any CPU.Build.0 = Release|Any CPU 79 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Release|x64.ActiveCfg = Release|Any CPU 80 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Release|x64.Build.0 = Release|Any CPU 81 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Release|x86.ActiveCfg = Release|Any CPU 82 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92}.Release|x86.Build.0 = Release|Any CPU 83 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 84 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Debug|Any CPU.Build.0 = Debug|Any CPU 85 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Debug|x64.ActiveCfg = Debug|Any CPU 86 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Debug|x64.Build.0 = Debug|Any CPU 87 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Debug|x86.ActiveCfg = Debug|Any CPU 88 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Debug|x86.Build.0 = Debug|Any CPU 89 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Release|Any CPU.ActiveCfg = Release|Any CPU 90 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Release|Any CPU.Build.0 = Release|Any CPU 91 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Release|x64.ActiveCfg = Release|Any CPU 92 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Release|x64.Build.0 = Release|Any CPU 93 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Release|x86.ActiveCfg = Release|Any CPU 94 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C}.Release|x86.Build.0 = Release|Any CPU 95 | EndGlobalSection 96 | GlobalSection(NestedProjects) = preSolution 97 | {B1EE0CBE-EA15-4EF4-A581-266BC7ECE7E5} = {C5D6AB17-075D-488D-9AC6-7B20DB92193F} 98 | {7DAC896A-0FF4-4161-AB55-978ECA963BB4} = {AFCED846-6A9F-42B6-AB9D-E9A4C0ABF315} 99 | {8577A608-3E1C-43A2-9196-A273A0E973CD} = {FB9B175B-F005-40BB-8880-B03F1BF7EB12} 100 | {0C7FB596-F30C-4F96-8739-0AD05BA36C92} = {C5D6AB17-075D-488D-9AC6-7B20DB92193F} 101 | {1544C16F-9E6A-47C5-98FB-D27B98271D6C} = {C5D6AB17-075D-488D-9AC6-7B20DB92193F} 102 | EndGlobalSection 103 | EndGlobal 104 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | image: Ubuntu2004 2 | 3 | build_script: 4 | - ps: ./build.ps1 5 | 6 | artifacts: 7 | - path: ./msbuild.log 8 | - path: ./artifacts/*.*nupkg 9 | 10 | skip_tags: true 11 | 12 | deploy: 13 | - provider: Environment 14 | name: NuGet 15 | on: 16 | branch: master 17 | -------------------------------------------------------------------------------- /build.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | powershell -ExecutionPolicy RemoteSigned -File ./build.ps1 4 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | New-Item -ItemType directory -Path "build" -Force | Out-Null 2 | 3 | # Release whenever a commit is merged to master 4 | $ENV:UseMasterReleaseStrategy = "true" 5 | 6 | # The following variables should be set if unit tests need the Azurite (Azure Storage) Docker container created 7 | # Only do this if running under APPVEYOR 8 | #if (Test-Path 'env:APPVEYOR') { 9 | # $ENV:RIMDEV_CREATE_TEST_DOCKER_AZURITE = "true" 10 | #} 11 | 12 | # The following variables should be set if unit tests need the Elasticsearch Docker container created 13 | #$ENV:RIMDEV_CREATE_TEST_DOCKER_ES = "true" 14 | #$ENV:RIMDEVTESTS__ELASTICSEARCH__BASEURI = "http://localhost" 15 | #$ENV:RIMDEVTESTS__ELASTICSEARCH__PORT = "9206" 16 | #$ENV:RIMDEVTESTS__ELASTICSEARCH__TRANSPORTPORT = "9306" 17 | 18 | # The following variables should be set if unit tests need the SQL Docker container created 19 | $ENV:RIMDEV_CREATE_TEST_DOCKER_SQL = "true" 20 | $ENV:RIMDEVTESTS__SQL__HOSTNAME = "localhost" 21 | $ENV:RIMDEVTESTS__SQL__PORT = "11439" 22 | $ENV:RIMDEVTESTS__SQL__PASSWORD = "HbXCXv4qJAWhliA" 23 | 24 | try { 25 | Invoke-WebRequest https://raw.githubusercontent.com/ritterim/build-scripts/master/bootstrap-cake.ps1 -OutFile build\bootstrap-cake.ps1 26 | Invoke-WebRequest https://raw.githubusercontent.com/ritterim/build-scripts/master/build-net5.cake -OutFile build.cake 27 | } 28 | catch { 29 | Write-Output $_.Exception.Message 30 | Write-Output "Error while downloading shared build script, attempting to use previously downloaded scripts..." 31 | } 32 | 33 | #.\build\bootstrap-cake.ps1 -Verbose --verbosity=Normal 34 | .\build\bootstrap-cake.ps1 -Verbose --verbosity=Diagnostic 35 | 36 | Exit $LastExitCode 37 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | pwsh -ExecutionPolicy RemoteSigned -File ./build.ps1 3 | 4 | -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /samples/FeatureFlags.AspNetCore/FeatureFlags.AspNetCore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /samples/FeatureFlags.AspNetCore/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace FeatureFlags.AspNetCore 5 | { 6 | public class Program 7 | { 8 | public static void Main(string[] args) 9 | { 10 | CreateHostBuilder(args).Build().Run(); 11 | } 12 | 13 | public static IHostBuilder CreateHostBuilder(string[] args) => 14 | Host.CreateDefaultBuilder(args) 15 | .ConfigureWebHostDefaults(webBuilder => 16 | { 17 | webBuilder.UseStartup(); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /samples/FeatureFlags.AspNetCore/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:41981", 7 | "sslPort": 44374 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "FeatureFlags.AspNetCore": { 19 | "commandName": "Project", 20 | "launchBrowser": true, 21 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 22 | "environmentVariables": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /samples/FeatureFlags.AspNetCore/Startup.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using Lussatite.FeatureManagement; 4 | using Lussatite.FeatureManagement.SessionManagers; 5 | using Lussatite.FeatureManagement.SessionManagers.SqlClient; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.Extensions.Configuration; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Hosting; 12 | using Microsoft.FeatureManagement; 13 | using RimDev.AspNetCore.FeatureFlags; 14 | using RimDev.AspNetCore.FeatureFlags.Core; 15 | using RimDev.AspNetCore.FeatureFlags.UI; 16 | 17 | namespace FeatureFlags.AspNetCore 18 | { 19 | public class Startup 20 | { 21 | private readonly IConfiguration configuration; 22 | 23 | public Startup(IConfiguration configuration) 24 | { 25 | this.configuration = configuration; 26 | } 27 | 28 | public void ConfigureServices(IServiceCollection services) 29 | { 30 | var featureFlagsConnectionString 31 | = configuration.GetConnectionString("featureFlags"); 32 | var featureFlagsInitializationConnectionString 33 | = configuration.GetConnectionString("featureFlagsInitialization"); 34 | 35 | var sqlSessionManagerSettings = new SQLServerSessionManagerSettings 36 | { 37 | FeatureSchemaName = "features", 38 | }; 39 | 40 | services.AddRimDevFeatureFlags( 41 | configuration, 42 | new[] { typeof(Startup).Assembly }, 43 | connectionString: featureFlagsConnectionString, 44 | initializationConnectionString: featureFlagsInitializationConnectionString, 45 | sqlSessionManagerSettings: sqlSessionManagerSettings 46 | ); 47 | 48 | // IFeatureManagerSnapshot should always be scoped / per-request lifetime 49 | services.AddScoped(serviceProvider => 50 | { 51 | var featureFlagSessionManager = serviceProvider.GetRequiredService(); 52 | var featureFlagsSettings = serviceProvider.GetRequiredService(); 53 | return new LussatiteLazyCacheFeatureManager( 54 | featureFlagsSettings.FeatureFlagTypes.Select(x => x.Name).ToList(), 55 | new [] 56 | { 57 | // in other use cases, you might list multiple ISessionManager objects to have layers 58 | featureFlagSessionManager 59 | }); 60 | }); 61 | 62 | services.AddRimDevFeatureFlagsUI(); 63 | } 64 | 65 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 66 | { 67 | if (env.IsDevelopment()) 68 | { 69 | app.UseDeveloperExceptionPage(); 70 | } 71 | 72 | app.UseRimDevFeatureFlags(); 73 | app.UseRimDevFeatureFlagsUI(); 74 | 75 | app.UseRouting(); 76 | 77 | app.UseEndpoints(endpoints => 78 | { 79 | var featureFlagUISettings = app.ApplicationServices.GetService(); 80 | 81 | endpoints.Map("/test-features", async context => 82 | { 83 | var testFeature = context.RequestServices.GetService(); 84 | var testFeature2 = context.RequestServices.GetService(); 85 | var testFeature3 = context.RequestServices.GetService(); 86 | 87 | context.Response.ContentType = "text/html"; 88 | await context.Response.WriteAsync($@" 89 | {testFeature.GetType().Name}: {testFeature.Enabled}
90 | {testFeature2.GetType().Name}: {testFeature2.Enabled}
91 | {testFeature3.GetType().Name}: {testFeature3.Enabled}
92 | View UI"); 93 | }); 94 | 95 | endpoints.Map("", context => 96 | { 97 | context.Response.Redirect("/test-features"); 98 | 99 | return Task.CompletedTask; 100 | }); 101 | 102 | var featureFlagsSettings = app.ApplicationServices.GetRequiredService(); 103 | endpoints.MapFeatureFlagsUI( 104 | uiSettings: featureFlagUISettings, 105 | settings: featureFlagsSettings 106 | ); 107 | }); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /samples/FeatureFlags.AspNetCore/TestFeature.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using RimDev.AspNetCore.FeatureFlags; 3 | 4 | namespace FeatureFlags.AspNetCore 5 | { 6 | [Description("A sample test feature.")] 7 | public class TestFeature : Feature 8 | { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /samples/FeatureFlags.AspNetCore/TestFeature2.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using RimDev.AspNetCore.FeatureFlags; 3 | 4 | namespace FeatureFlags.AspNetCore 5 | { 6 | [Description(null)] 7 | public class TestFeature2 : Feature 8 | { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /samples/FeatureFlags.AspNetCore/TestFeature3.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using RimDev.AspNetCore.FeatureFlags; 3 | 4 | namespace FeatureFlags.AspNetCore 5 | { 6 | [Description( "Another 3rd test feature.")] 7 | public class TestFeature3 : Feature 8 | { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /samples/FeatureFlags.AspNetCore/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /samples/FeatureFlags.AspNetCore/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "connectionStrings": { 3 | "featureFlags": "server=localhost,11433;database=master;user=sa;password=Pass123!", 4 | "featureFlagsInitialization": "server=localhost,11433;database=master;user=sa;password=Pass123!" 5 | }, 6 | "Logging": { 7 | "LogLevel": { 8 | "Default": "Warning" 9 | } 10 | }, 11 | "AllowedHosts": "*" 12 | } 13 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritterim/RimDev.FeatureFlags/c4b6d6349ff51b16543df3139a800fbef8fb26a7/screenshot.png -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.Core/RimDev.AspNetCore.FeatureFlags.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | 6 | Startup extension methods for .NET Core 3.1 and .NET 5/6. 7 | Ritter Insurance Marketing 8 | Copyright 2022 Ritter Insurance Marketing 9 | MIT 10 | 11 | RimDev.AspNetCore.FeatureFlags.Core 12 | https://github.com/ritterim/RimDev.FeatureFlags 13 | feature flags FeatureFlag FeatureFlags 14 | 15 | true 16 | true 17 | $(NoWarn);1591 18 | true 19 | snupkg 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.Core/StartupExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Data.SqlClient; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace RimDev.AspNetCore.FeatureFlags.Core 6 | { 7 | public static class StartupExtensions 8 | { 9 | public static IApplicationBuilder UseRimDevFeatureFlags( 10 | this IApplicationBuilder app 11 | ) 12 | { 13 | if (_sessionManagerInitialized) return app; 14 | 15 | app.CreateFeatureFlagsSchema(); 16 | app.CreateFeatureFlagsTable(); 17 | _sessionManagerInitialized = true; 18 | 19 | return app; 20 | } 21 | 22 | private static bool _sessionManagerInitialized; 23 | 24 | /// Create the feature flags schema (must be done via a defensive SQL script). 25 | public static IApplicationBuilder CreateFeatureFlagsSchema( 26 | this IApplicationBuilder app 27 | ) 28 | { 29 | var featureFlagsSettings = app 30 | .ApplicationServices 31 | .GetRequiredService(); 32 | 33 | if (!string.IsNullOrEmpty(featureFlagsSettings?.SqlSessionManagerSettings?.FeatureSchemaName)) 34 | { 35 | using var conn = new SqlConnection(featureFlagsSettings.InitializationConnectionString); 36 | conn.Open(); 37 | using var cmd = conn.CreateCommand(); 38 | var schema = featureFlagsSettings.SqlSessionManagerSettings.FeatureSchemaName; 39 | cmd.CommandText = @$" 40 | IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = N'{schema}') 41 | EXEC('CREATE SCHEMA [{schema}];');"; 42 | cmd.ExecuteNonQuery(); 43 | conn.Close(); 44 | } 45 | 46 | return app; 47 | } 48 | 49 | /// Create the feature flags table (must be done via a defensive SQL script). 50 | public static IApplicationBuilder CreateFeatureFlagsTable( 51 | this IApplicationBuilder app 52 | ) 53 | { 54 | var featureFlagsSettings = app 55 | .ApplicationServices 56 | .GetRequiredService(); 57 | 58 | featureFlagsSettings.SqlSessionManagerSettings.CreateDatabaseTable( 59 | featureFlagsSettings.InitializationConnectionString 60 | ); 61 | 62 | return app; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.UI/FeatureFlagUISettings.cs: -------------------------------------------------------------------------------- 1 | namespace RimDev.AspNetCore.FeatureFlags.UI 2 | { 3 | public class FeatureFlagUISettings 4 | { 5 | public string ApiGetAllPath => UIPath + "/get_all"; 6 | 7 | public string ApiGetPath => UIPath + "/get"; 8 | 9 | public string ApiSetPath => UIPath + "/set"; 10 | 11 | public string UIPath => "/_features"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.UI/FeatureFlagsUIBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Newtonsoft.Json; 8 | 9 | namespace RimDev.AspNetCore.FeatureFlags.UI 10 | { 11 | internal class FeatureFlagsUIBuilder 12 | { 13 | internal async Task ApiGetPath( 14 | HttpContext context, 15 | FeatureFlagsSettings settings 16 | ) 17 | { 18 | if (context.Request.Method != HttpMethods.Get) 19 | { 20 | context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; 21 | return; 22 | } 23 | 24 | var featureFlagsSessionManager = context.RequestServices.GetRequiredService(); 25 | 26 | var featureName = context.Request.Query["feature"]; 27 | if (string.IsNullOrEmpty(featureName)) 28 | { 29 | context.Response.StatusCode = StatusCodes.Status400BadRequest; 30 | return; 31 | } 32 | 33 | var featureType = settings.GetFeatureType(featureName); 34 | 35 | if (featureType is null) 36 | { 37 | context.Response.StatusCode = StatusCodes.Status404NotFound; 38 | return; 39 | } 40 | 41 | var value = await featureFlagsSessionManager.GetAsync(featureName); 42 | 43 | var response = new FeatureResponse 44 | { 45 | Name = featureName, 46 | Description = featureType.GetDescription(), 47 | Enabled = value, 48 | }; 49 | 50 | var json = JsonConvert.SerializeObject(response); 51 | 52 | await context.Response.WriteAsync(json).ConfigureAwait(false); 53 | } 54 | 55 | internal async Task ApiGetAllPath( 56 | HttpContext context, 57 | FeatureFlagsSettings settings 58 | ) 59 | { 60 | if (context.Request.Method != HttpMethods.Get) 61 | { 62 | context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; 63 | return; 64 | } 65 | 66 | var sessionManager = context.RequestServices.GetService(); 67 | 68 | if (sessionManager == null) 69 | throw new InvalidOperationException( 70 | $"{nameof(FeatureFlagsSessionManager)} must be registered via {nameof(UIStartupExtensions.UseRimDevFeatureFlagsUI)}()"); 71 | 72 | var features = new List(); 73 | foreach (var featureType in settings.FeatureFlagTypes) 74 | { 75 | var featureName = featureType.Name; 76 | var enabled = await sessionManager.GetAsync(featureName); 77 | var featureResponse = new FeatureResponse 78 | { 79 | Name = featureName, 80 | Description = featureType.GetDescription(), 81 | Enabled = enabled, 82 | }; 83 | 84 | features.Add(featureResponse); 85 | } 86 | 87 | var json = JsonConvert.SerializeObject(features); 88 | 89 | await context.Response.WriteAsync(json).ConfigureAwait(false); 90 | } 91 | 92 | internal async Task ApiSetPath( 93 | HttpContext context, 94 | FeatureFlagsSettings settings 95 | ) 96 | { 97 | if (context.Request.Method != HttpMethods.Post) 98 | { 99 | context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; 100 | return; 101 | } 102 | 103 | var featureFlagsSessionManager = context.RequestServices.GetRequiredService(); 104 | 105 | string requestString; 106 | using (var streamReader = new StreamReader(context.Request.Body)) 107 | { 108 | requestString = await streamReader 109 | .ReadToEndAsync() 110 | .ConfigureAwait(false); 111 | } 112 | 113 | var setRequest = (FeatureRequest) JsonConvert.DeserializeObject( 114 | requestString, 115 | typeof(FeatureRequest) 116 | ); 117 | 118 | if (setRequest is null) 119 | { 120 | context.Response.StatusCode = StatusCodes.Status400BadRequest; 121 | return; 122 | } 123 | 124 | var featureType = settings.GetFeatureType(setRequest.Name); 125 | 126 | if (featureType is null) 127 | { 128 | context.Response.StatusCode = StatusCodes.Status404NotFound; 129 | return; 130 | } 131 | 132 | await featureFlagsSessionManager 133 | .SetNullableAsync(setRequest.Name, setRequest.Enabled) 134 | .ConfigureAwait(false); 135 | 136 | context.Response.StatusCode = StatusCodes.Status204NoContent; 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.UI/FeatureRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace RimDev.AspNetCore.FeatureFlags.UI 5 | { 6 | public class FeatureRequest 7 | { 8 | [JsonProperty("name")] 9 | public string Name { get; set; } 10 | 11 | [Obsolete("Use Name property.")] 12 | [JsonProperty("feature")] 13 | public string Feature { get; set; } 14 | 15 | [Obsolete("Use the Enabled property.")] 16 | [JsonProperty("value")] 17 | public bool Value { get; set; } 18 | 19 | [JsonProperty("enabled")] 20 | [JsonConverter(typeof(JsonBooleanConverter))] 21 | public bool? Enabled { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.UI/FeatureResponse.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace RimDev.AspNetCore.FeatureFlags.UI 4 | { 5 | public class FeatureResponse 6 | { 7 | [JsonProperty("name")] 8 | public string Name { get; set; } 9 | 10 | [JsonProperty("description")] 11 | public virtual string Description { get; set; } 12 | 13 | [JsonProperty("enabled")] 14 | public bool? Enabled { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.UI/HttpResponseExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace RimDev.AspNetCore.FeatureFlags.UI 6 | { 7 | internal static class HttpResponseExtensions 8 | { 9 | internal static async Task WriteManifestResource(this HttpResponse response, Type type, string contentType, string name) 10 | { 11 | using (var stream = type.Assembly.GetManifestResourceStream(type, name)) 12 | { 13 | if (stream == null) 14 | { 15 | response.StatusCode = StatusCodes.Status404NotFound; 16 | return; 17 | } 18 | response.ContentType = contentType; 19 | await stream.CopyToAsync(response.Body).ConfigureAwait(false); 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.UI/RimDev.AspNetCore.FeatureFlags.UI.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | 6 | A user interface for strongly typed feature flags in ASP.NET Core 3.1. 7 | Ritter Insurance Marketing 8 | Copyright 2019-2022 Ritter Insurance Marketing 9 | MIT 10 | 11 | RimDev.AspNetCore.FeatureFlags.UI 12 | https://github.com/ritterim/RimDev.FeatureFlags 13 | feature flags FeatureFlag FeatureFlags 14 | 15 | true 16 | true 17 | $(NoWarn);1591 18 | true 19 | snupkg 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.UI/UIStartupExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.AspNetCore.Routing; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.DependencyInjection.Extensions; 6 | 7 | namespace RimDev.AspNetCore.FeatureFlags.UI 8 | { 9 | public static class UIStartupExtensions 10 | { 11 | public static IServiceCollection AddRimDevFeatureFlagsUI( 12 | this IServiceCollection service 13 | ) 14 | { 15 | service.TryAddSingleton(); 16 | return service; 17 | } 18 | 19 | /// 20 | /// Uses to construct the default UI and API endpoints for feature 21 | /// flags toggles. 22 | /// Note that if you layer session managers, the value retrieved for a particular feature 23 | /// may not match what is shown in this UI. This UI/API only displays/updates the values for the 24 | /// session manager. 25 | /// IMPORTANT: Controlling access of the UI / API of this library is the responsibility of the user. 26 | /// Apply authentication / authorization around the `UseFeatureFlagsUI` method as needed, as this method 27 | /// simply wires up the various endpoints. 28 | /// 29 | public static IApplicationBuilder UseRimDevFeatureFlagsUI( 30 | this IApplicationBuilder app 31 | ) 32 | { 33 | var settings = app.ApplicationServices.GetRequiredService(); 34 | var uiSettings = app.ApplicationServices.GetRequiredService(); 35 | 36 | var featureFlagsUIBuilder = new FeatureFlagsUIBuilder(); 37 | 38 | app.Map(uiSettings.ApiGetPath, appBuilder => 39 | { 40 | appBuilder.Run(context => featureFlagsUIBuilder.ApiGetPath(context, settings)); 41 | }); 42 | 43 | app.Map(uiSettings.ApiGetAllPath, appBuilder => 44 | { 45 | appBuilder.Run(context => featureFlagsUIBuilder.ApiGetAllPath(context, settings)); 46 | }); 47 | 48 | app.Map(uiSettings.ApiSetPath, appBuilder => 49 | { 50 | appBuilder.Run(context => featureFlagsUIBuilder.ApiSetPath(context, settings)); 51 | }); 52 | 53 | app.Map(uiSettings.UIPath, x => 54 | { 55 | x.Map($"/main.js", y => y.Run(context => context.Response.WriteManifestResource(typeof(UIStartupExtensions), "application/javascript", "main.js"))); 56 | x.Run(context => context.Response.WriteManifestResource(typeof(UIStartupExtensions), "text/html", "index.html")); 57 | }); 58 | 59 | return app; 60 | } 61 | 62 | /// 63 | /// Uses to map the UI/API endpoints. 64 | /// Note that if you layer session managers, the value retrieved for a particular feature 65 | /// may not match what is shown in this UI. This UI/API only displays/updates the values for the 66 | /// session manager. 67 | /// IMPORTANT: Controlling access of the UI / API of this library is the responsibility of the user. 68 | /// Apply authentication / authorization around the `UseFeatureFlagsUI` method as needed, as this method 69 | /// simply wires up the various endpoints. 70 | /// 71 | public static IEndpointConventionBuilder MapFeatureFlagsUI( 72 | this IEndpointRouteBuilder builder, 73 | FeatureFlagsSettings settings, 74 | FeatureFlagUISettings uiSettings = default(FeatureFlagUISettings) 75 | ) 76 | { 77 | if (settings is null) throw new ArgumentNullException(nameof(settings)); 78 | 79 | var featureFlagsUIBuilder = new FeatureFlagsUIBuilder(); 80 | 81 | return builder.Map( 82 | uiSettings.UIPath + "/{**path}", 83 | async context => 84 | { 85 | var path = context.Request.Path; 86 | 87 | if (path == uiSettings.ApiGetPath) 88 | { 89 | await featureFlagsUIBuilder.ApiGetPath(context, settings); 90 | return; 91 | } 92 | 93 | if (path == uiSettings.ApiGetAllPath) 94 | { 95 | await featureFlagsUIBuilder.ApiGetAllPath(context, settings); 96 | return; 97 | } 98 | 99 | if (path == uiSettings.ApiSetPath) 100 | { 101 | await featureFlagsUIBuilder.ApiSetPath(context, settings); 102 | return; 103 | } 104 | 105 | if (path == $"{uiSettings.UIPath}/main.js") 106 | { 107 | await context.Response.WriteManifestResource(typeof(UIStartupExtensions), "application/javascript", "main.js"); 108 | return; 109 | } 110 | 111 | if (path == uiSettings.UIPath) 112 | { 113 | await context.Response.WriteManifestResource(typeof(UIStartupExtensions), "text/html", "index.html"); 114 | return; 115 | } 116 | }); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.UI/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Feature Flags 7 | 8 | 9 | 10 | 11 | 22 | 23 | 24 | 28 |
29 |
30 |
    31 |
32 |
33 |
34 |
35 |
36 |
37 |

38 |
39 |
40 |
41 | 42 | 43 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags.UI/main.js: -------------------------------------------------------------------------------- 1 | const featuresContainer = document.querySelector('#features-list'); 2 | const messageContainer = document.querySelector('.js-message-container'); 3 | const message = messageContainer.querySelector('.js-message'); 4 | const messageText = message.querySelector('.js-message-text'); 5 | 6 | const fetchOptions = { 7 | credentials: 'same-origin' 8 | }; 9 | 10 | var hideMessageContainer = () => { 11 | messageContainer.classList.remove('pin-bottom'); 12 | } 13 | 14 | var removeMessage = (type) => { 15 | message.classList.remove('message--'+type+''); 16 | messageText.textContent = ""; 17 | } 18 | 19 | let clearMessage; 20 | 21 | var showMessage = (text, type) => { 22 | messageContainer.classList.add('pin-bottom'); 23 | message.classList.add('message--'+type+''); 24 | messageText.textContent = text; 25 | } 26 | 27 | var handleMessage = (text, type) => { 28 | showMessage(text, type); 29 | clearMessage = setTimeout(() => { 30 | hideMessageContainer(); 31 | setTimeout(removeMessage, 300); 32 | }, 3000); 33 | } 34 | 35 | var fireMessage = (text, type) => { 36 | if(messageContainer.classList.contains('pin-bottom')) { 37 | hideMessageContainer(); 38 | 39 | clearTimeout(clearMessage); 40 | 41 | setTimeout(() => { 42 | removeMessage(type); 43 | handleMessage(text, type); 44 | }, 300); 45 | } else { 46 | handleMessage(text, type); 47 | } 48 | } 49 | 50 | fetch('/_features/get_all', fetchOptions) 51 | .then(res => res.json()) 52 | .then(json => { 53 | const features = json.map(feature => `
  • 54 |
    55 |
    56 |
    57 | 58 |
    59 |
    60 | ${feature.name} 61 |

    ${feature.description ? '' + feature.description + '' : ''}

    62 |
    63 |
    64 |
    65 |
    66 |
    67 | Set the flag 68 | 69 | 76 | 77 | 84 | 85 | 92 |
    93 |
    94 |
  • `); 95 | 96 | featuresContainer.innerHTML = DOMPurify.sanitize(features.join('')); 97 | 98 | document.querySelectorAll('input[type="radio"]').forEach(radio => { 99 | radio.addEventListener('change', evt => { 100 | const feature = evt.currentTarget.getAttribute('data-feature'); 101 | const checked = evt.currentTarget.getAttribute('data-checked'); 102 | const label = evt.currentTarget.getAttribute('data-label'); 103 | 104 | fetch('/_features/set', { 105 | method: 'POST', 106 | body: JSON.stringify({ 107 | name: feature, 108 | enabled: checked 109 | }), 110 | headers: { 'Content-Type': 'application/json' }, 111 | ...fetchOptions 112 | }).then(() => { 113 | let message = `${feature} set to ${label}`; 114 | 115 | fireMessage(message, 'success'); 116 | }).catch(err => { 117 | let message = `ERROR: ${err}` 118 | 119 | fireMessage(message, 'error'); 120 | }); 121 | }); 122 | }); 123 | }) 124 | .catch(err => { 125 | let message = `ERROR: ${err}` 126 | fireMessage(message, 'error'); 127 | }); 128 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags/Feature.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Newtonsoft.Json; 4 | 5 | namespace RimDev.AspNetCore.FeatureFlags 6 | { 7 | /// 8 | /// The base class for a strongly typed feature. 9 | /// 10 | public abstract class Feature 11 | { 12 | [JsonProperty("name")] 13 | public string Name => GetType().Name; 14 | 15 | [JsonProperty("description")] 16 | public string Description => GetType().GetDescription(); 17 | 18 | [Obsolete("All Feature objects are now registered as Scoped.")] 19 | [JsonProperty("serviceLifetime")] 20 | public virtual ServiceLifetime ServiceLifetime { get; } 21 | = ServiceLifetime.Transient; 22 | 23 | [Obsolete("Use the Enabled property.")] 24 | [JsonProperty("value")] 25 | public bool Value { get; set; } 26 | 27 | [JsonProperty("enabled")] 28 | public bool Enabled { get; set; } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags/FeatureFlagsSessionManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using LazyCache; 4 | using Lussatite.FeatureManagement; 5 | using Lussatite.FeatureManagement.SessionManagers; 6 | 7 | namespace RimDev.AspNetCore.FeatureFlags 8 | { 9 | public class FeatureFlagsSessionManager : ILussatiteSessionManager 10 | { 11 | private readonly FeatureFlagsSettings _featureFlagsSettings; 12 | private readonly CachedSqlSessionManager _cachedSqlSessionManager; 13 | 14 | public FeatureFlagsSessionManager( 15 | FeatureFlagsSettings featureFlagsSettings, 16 | IAppCache appCache = null 17 | ) 18 | { 19 | _featureFlagsSettings = featureFlagsSettings 20 | ?? throw new ArgumentNullException(nameof(featureFlagsSettings)); 21 | 22 | var cachedSqlSessionManagerSettings = new CachedSqlSessionManagerSettings 23 | { 24 | CacheTime = featureFlagsSettings.CacheTime, 25 | }; 26 | 27 | _cachedSqlSessionManager = new CachedSqlSessionManager( 28 | cacheSettings: cachedSqlSessionManagerSettings, 29 | settings: featureFlagsSettings.SqlSessionManagerSettings, 30 | appCache: appCache 31 | ); 32 | } 33 | 34 | public async Task SetAsync(string featureName, bool enabled) => 35 | await _cachedSqlSessionManager.SetAsync(featureName, enabled).ConfigureAwait(false); 36 | 37 | public async Task GetAsync(string featureName) => 38 | await _cachedSqlSessionManager.GetAsync(featureName); 39 | 40 | public async Task SetNullableAsync(string featureName, bool? enabled) => 41 | await _cachedSqlSessionManager.SetNullableAsync(featureName, enabled); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags/FeatureFlagsSettings.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using Lussatite.FeatureManagement.SessionManagers; 6 | 7 | namespace RimDev.AspNetCore.FeatureFlags 8 | { 9 | public class FeatureFlagsSettings 10 | { 11 | public FeatureFlagsSettings(IEnumerable featureFlagAssemblies) 12 | { 13 | FeatureFlagTypes = featureFlagAssemblies.GetFeatureTypesInAssemblies().ToList(); 14 | } 15 | 16 | /// A SQL connection string which can be used to SELECT/INSERT/UPDATE 17 | /// from the feature flag values table. 18 | public string ConnectionString { get; set; } 19 | 20 | /// A SQL connection string which can be used to create a missing feature 21 | /// flag values table. 22 | public string InitializationConnectionString { get; set; } 23 | 24 | /// The list of types found by initial assembly scanning. 25 | public IReadOnlyCollection FeatureFlagTypes { get; } 26 | 27 | public Type GetFeatureType( 28 | string featureName 29 | ) 30 | { 31 | return FeatureFlagTypes.SingleOrDefault(x => 32 | x.Name.Equals(featureName, StringComparison.OrdinalIgnoreCase)); 33 | } 34 | 35 | /// How long a cache entry will be valid until it is forced to 36 | /// refresh from the database. Defaults to 60 seconds. 37 | public TimeSpan CacheTime { get; set; } = TimeSpan.FromSeconds(60.0); 38 | 39 | /// The object which lets the 40 | /// communicate with a database backend. 41 | /// 42 | public SqlSessionManagerSettings SqlSessionManagerSettings { get; set; } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags/IEnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | 6 | namespace RimDev.AspNetCore.FeatureFlags 7 | { 8 | public static class IEnumerableExtensions 9 | { 10 | public static IEnumerable GetFeatureTypesInAssemblies( 11 | this IEnumerable featureFlagAssemblies 12 | ) 13 | { 14 | return featureFlagAssemblies 15 | .SelectMany(assembly => assembly.GetTypes()) 16 | .Where(type => type.IsClass && !type.IsAbstract && type.IsSubclassOf(typeof(Feature))); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags/JsonBooleanConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Newtonsoft.Json; 5 | using Newtonsoft.Json.Linq; 6 | 7 | namespace RimDev.AspNetCore.FeatureFlags 8 | { 9 | public class JsonBooleanConverter : JsonConverter 10 | { 11 | public override void WriteJson( 12 | JsonWriter writer, 13 | object value, 14 | JsonSerializer serializer 15 | ) 16 | { 17 | var t = JToken.FromObject(value); 18 | 19 | if (t.Type != JTokenType.Object) 20 | { 21 | t.WriteTo(writer); 22 | } 23 | else 24 | { 25 | var o = (JObject)t; 26 | IList propertyNames = o.Properties().Select(p => p.Name).ToList(); 27 | o.AddFirst(new JProperty("Keys", new JArray(propertyNames))); 28 | o.WriteTo(writer); 29 | } 30 | } 31 | 32 | public override object ReadJson( 33 | JsonReader reader, 34 | Type objectType, 35 | object existingValue, 36 | JsonSerializer serializer 37 | ) => ReadJson(reader.Value); 38 | 39 | public override bool CanConvert(Type objectType) 40 | { 41 | return objectType == typeof(string) || objectType == typeof(bool); 42 | } 43 | 44 | public object ReadJson(object value) 45 | { 46 | switch (value?.ToString()?.ToLower().Trim()) 47 | { 48 | case "true": 49 | case "yes": 50 | case "y": 51 | case "1": 52 | return true; 53 | case "false": 54 | case "no": 55 | case "n": 56 | case "0": 57 | return false; 58 | default: 59 | return null; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags/RimDev.AspNetCore.FeatureFlags.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 6 | A library for strongly typed feature flags in .NET Standard 2.0. 7 | Ritter Insurance Marketing 8 | Copyright 2019-2022 Ritter Insurance Marketing 9 | MIT 10 | 11 | RimDev.AspNetCore.FeatureFlags 12 | https://github.com/ritterim/RimDev.FeatureFlags 13 | feature flags FeatureFlag FeatureFlags 14 | 15 | true 16 | true 17 | $(NoWarn);1591 18 | true 19 | snupkg 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags/StartupExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using LazyCache; 6 | using Lussatite.FeatureManagement.SessionManagers; 7 | using Lussatite.FeatureManagement.SessionManagers.SqlClient; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.DependencyInjection.Extensions; 11 | using Microsoft.FeatureManagement; 12 | 13 | namespace RimDev.AspNetCore.FeatureFlags 14 | { 15 | public static class StartupExtensions 16 | { 17 | /// Register RimDev Feature Flags in the dependency system. 18 | /// The implementation must be registered in order to 19 | /// construct individual instances. 20 | /// 21 | /// 22 | /// 23 | /// A list of assemblies to scan for classes inheriting from 24 | /// . 25 | /// Connection string for SELECT/INSERT/DELETE operations. 26 | /// Connection string for the CREATE TABLE operation. 27 | /// Optional 28 | /// 29 | /// When the connection strings are missing/empty. 30 | public static IServiceCollection AddRimDevFeatureFlags( 31 | this IServiceCollection services, 32 | IConfiguration configuration, 33 | ICollection featureFlagAssemblies, 34 | string connectionString, 35 | string initializationConnectionString, 36 | SqlSessionManagerSettings sqlSessionManagerSettings = null 37 | ) 38 | { 39 | services.AddFeatureFlagSettings( 40 | configuration, 41 | featureFlagAssemblies: featureFlagAssemblies, 42 | connectionString: connectionString, 43 | initializationConnectionString: initializationConnectionString, 44 | sqlSessionManagerSettings: sqlSessionManagerSettings 45 | ); 46 | services.AddStronglyTypedFeatureFlags( 47 | featureFlagAssemblies: featureFlagAssemblies 48 | ); 49 | services.AddFeatureFlagsSessionManager(); 50 | 51 | return services; 52 | } 53 | 54 | /// Add to the DI container. The two 55 | /// connection strings are hardcoded as "featureFlags" and "featureFlagsInitialization". 56 | /// 57 | /// 58 | /// 59 | /// A list of assemblies to scan for classes inheriting from 60 | /// . 61 | /// Connection string for SELECT/INSERT/DELETE operations. 62 | /// Connection string for the CREATE TABLE operation. 63 | /// Optional 64 | /// 65 | /// When the connection strings are missing/empty. 66 | public static IServiceCollection AddFeatureFlagSettings( 67 | this IServiceCollection services, 68 | IConfiguration configuration, 69 | IEnumerable featureFlagAssemblies, 70 | string connectionString, 71 | string initializationConnectionString, 72 | SqlSessionManagerSettings sqlSessionManagerSettings = null 73 | ) 74 | { 75 | services.TryAddSingleton(serviceProvider => 76 | { 77 | if (string.IsNullOrEmpty(connectionString)) 78 | throw new ArgumentNullException(nameof(connectionString)); 79 | 80 | if (string.IsNullOrEmpty(initializationConnectionString)) 81 | throw new ArgumentNullException(nameof(initializationConnectionString)); 82 | 83 | sqlSessionManagerSettings = sqlSessionManagerSettings 84 | ?? new SQLServerSessionManagerSettings 85 | { 86 | FeatureSchemaName = "dbo", 87 | FeatureTableName = "RimDevAspNetCoreFeatureFlags", 88 | FeatureNameColumn = "FeatureName", 89 | FeatureValueColumn = "Enabled", 90 | ConnectionString = connectionString, 91 | EnableSetValueCommand = false, 92 | }; 93 | if (string.IsNullOrEmpty(sqlSessionManagerSettings.ConnectionString)) 94 | sqlSessionManagerSettings.ConnectionString = connectionString; 95 | 96 | return new FeatureFlagsSettings(featureFlagAssemblies) 97 | { 98 | ConnectionString = connectionString, 99 | InitializationConnectionString = initializationConnectionString, 100 | SqlSessionManagerSettings = sqlSessionManagerSettings, 101 | }; 102 | }); 103 | 104 | return services; 105 | } 106 | 107 | /// 108 | /// Define the DI recipes for construction of strongly-typed instances. 109 | /// A implementation must be registered in order to 110 | /// construct individual instances. 111 | /// 112 | public static IServiceCollection AddStronglyTypedFeatureFlags( 113 | this IServiceCollection services, 114 | IEnumerable featureFlagAssemblies = null 115 | ) 116 | { 117 | featureFlagAssemblies = featureFlagAssemblies ?? new List(); 118 | var featureTypes = featureFlagAssemblies.GetFeatureTypesInAssemblies().ToList(); 119 | foreach (var featureType in featureTypes) 120 | { 121 | services.AddScoped(featureType, serviceProvider 122 | => serviceProvider.GetFeatureFromFeatureManager(featureType)); 123 | } 124 | 125 | return services; 126 | } 127 | 128 | public static IServiceCollection AddFeatureFlagsSessionManager( 129 | this IServiceCollection services 130 | ) 131 | { 132 | services.TryAddSingleton(serviceProvider => 133 | { 134 | var featureFlagsSettings = serviceProvider.GetRequiredService(); 135 | var appCache = serviceProvider.GetService(); 136 | 137 | return new FeatureFlagsSessionManager 138 | ( 139 | featureFlagsSettings: featureFlagsSettings, 140 | appCache: appCache 141 | ); 142 | }); 143 | 144 | return services; 145 | } 146 | 147 | private static Feature GetFeatureFromFeatureManager( 148 | this IServiceProvider serviceProvider, 149 | Type featureType 150 | ) 151 | { 152 | var featureManager = serviceProvider.GetRequiredService(); 153 | var value = featureManager.IsEnabledAsync(featureType.Name) 154 | .ConfigureAwait(false) 155 | .GetAwaiter() 156 | .GetResult(); 157 | var feature = (Feature)Activator.CreateInstance(featureType) 158 | ?? throw new Exception($"Unable to create instance of {featureType.Name}."); 159 | feature.Enabled = value; 160 | return feature; 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/RimDev.AspNetCore.FeatureFlags/TypeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Reflection; 4 | 5 | namespace RimDev.AspNetCore.FeatureFlags 6 | { 7 | public static class TypeExtensions 8 | { 9 | public static string GetDescription(this Type type) 10 | { 11 | var firstDescription = type.GetCustomAttribute(); 12 | return firstDescription?.Description; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/FeatureFlagsSessionManagerTests.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Lussatite.FeatureManagement.SessionManagers.SqlClient; 3 | using RimDev.AspNetCore.FeatureFlags.Tests.Testing; 4 | using RimDev.AspNetCore.FeatureFlags.Tests.Testing.Database; 5 | using Xunit; 6 | 7 | namespace RimDev.AspNetCore.FeatureFlags.Tests 8 | { 9 | [Collection(nameof(EmptyDatabaseCollection))] 10 | public class FeatureFlagsSessionManagerTests 11 | { 12 | private readonly EmptyDatabaseFixture databaseFixture; 13 | 14 | public FeatureFlagsSessionManagerTests( 15 | EmptyDatabaseFixture databaseFixture 16 | ) 17 | { 18 | this.databaseFixture = databaseFixture; 19 | } 20 | 21 | private async Task CreateSut() 22 | { 23 | var sqlSessionManagerSettings = new SQLServerSessionManagerSettings 24 | { 25 | ConnectionString = databaseFixture.ConnectionString 26 | }; 27 | 28 | var featureFlagsSettings = new FeatureFlagsSettings( 29 | featureFlagAssemblies: new[] { typeof(FeatureFlagsSessionManagerTests).Assembly } 30 | ) 31 | { 32 | ConnectionString = databaseFixture.ConnectionString, 33 | InitializationConnectionString = databaseFixture.ConnectionString, 34 | SqlSessionManagerSettings = sqlSessionManagerSettings, 35 | }; 36 | 37 | var sut = new FeatureFlagsSessionManager( 38 | featureFlagsSettings: featureFlagsSettings 39 | ); 40 | 41 | await sqlSessionManagerSettings.CreateDatabaseTableAsync( 42 | featureFlagsSettings.InitializationConnectionString 43 | ); 44 | 45 | return sut; 46 | } 47 | 48 | [Fact] 49 | public async Task Can_create_sut() 50 | { 51 | var sut = await CreateSut(); 52 | Assert.NotNull(sut); 53 | } 54 | 55 | [Theory] 56 | [InlineData(false)] 57 | [InlineData(true)] 58 | public async Task SetAsync_returns_expected(bool expected) 59 | { 60 | var sut = await CreateSut(); 61 | const string baseFeatureName = "RDANCFF_SetAsync_"; 62 | var featureName = $"{baseFeatureName}{expected}"; 63 | await sut.SetAsync(featureName, expected); 64 | var result = await sut.GetAsync(featureName); 65 | Assert.Equal(expected, result); 66 | } 67 | 68 | [Theory] 69 | [InlineData(null)] 70 | [InlineData(false)] 71 | [InlineData(true)] 72 | public async Task SetNullableAsync_returns_expected(bool? expected) 73 | { 74 | var sut = await CreateSut(); 75 | const string baseFeatureName = "RDANCFF_SetNullableAsync_"; 76 | var expectedName = expected.HasValue ? expected.ToString() : "Null"; 77 | var featureName = $"{baseFeatureName}{expectedName}"; 78 | await sut.SetNullableAsync(featureName, expected); 79 | var result = await sut.GetAsync(featureName); 80 | Assert.Equal(expected, result); 81 | } 82 | 83 | /// Exercise the SetAsync method a bunch of times to ensure there are no latent 84 | /// bugs with race conditions, values not being set properly, etc. 85 | [Fact] 86 | public async Task Exercise_SetAsync() 87 | { 88 | var sut = await CreateSut(); 89 | const int iterations = 8000; 90 | const string baseFeatureName = "RDANCFF_SetAsync_Exercise_"; 91 | for (var i = 0; i < iterations; i++) 92 | { 93 | var doUpdate = Rng.GetInt(0, 25) == 0; // only call SetAsync() some of the time 94 | var featureName = $"{baseFeatureName}{Rng.GetInt(0, 10)}"; 95 | var enabled = Rng.GetBool(); 96 | if (doUpdate) await sut.SetAsync(featureName, enabled); 97 | var result = await sut.GetAsync(featureName); 98 | if (doUpdate) Assert.Equal(enabled, result); 99 | } 100 | } 101 | 102 | /// Exercise the SetNullableAsync method a bunch of times to ensure there are no latent 103 | /// bugs with race conditions, values not being set properly, etc. 104 | [Fact] 105 | public async Task Exercise_SetNullableAsync() 106 | { 107 | var sut = await CreateSut(); 108 | const int iterations = 8000; 109 | const string baseFeatureName = "RDANCFF_SetNullableAsync_Exercise_"; 110 | for (var i = 0; i < iterations; i++) 111 | { 112 | var doUpdate = Rng.GetInt(0, 25) == 0; // only call SetNullableAsync() some of the time 113 | var featureName = $"{baseFeatureName}{Rng.GetInt(0, 10)}"; 114 | var enabled = Rng.GetNullableBool(); 115 | if (doUpdate) await sut.SetNullableAsync(featureName, enabled); 116 | var result = await sut.GetAsync(featureName); 117 | if (doUpdate) Assert.Equal(enabled, result); 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/FeatureFlagsUIBuilderTests.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using RimDev.AspNetCore.FeatureFlags.Tests.Testing.ApplicationFactory; 8 | using RimDev.AspNetCore.FeatureFlags.UI; 9 | using Xunit; 10 | 11 | namespace RimDev.AspNetCore.FeatureFlags.Tests 12 | { 13 | [Collection(nameof(TestWebApplicationCollection))] 14 | public class FeatureFlagsUIBuilderTests 15 | { 16 | private readonly TestWebApplicationFactory fixture; 17 | 18 | public FeatureFlagsUIBuilderTests(TestWebApplicationFactory fixture) 19 | { 20 | this.fixture = fixture; 21 | } 22 | 23 | [Fact] 24 | public async Task Get_ReturnsExpectedFeature() 25 | { 26 | var client = fixture.CreateClient(); 27 | var uiSettings = fixture.Services.GetRequiredService(); 28 | 29 | var request = new FeatureRequest 30 | { 31 | Name = nameof(TestFeature), 32 | Enabled = true 33 | }; 34 | 35 | await SetValueViaApiAsync(request); 36 | 37 | var response = await client.GetAsync( 38 | $"{uiSettings.ApiGetPath}?feature={request.Name}"); 39 | 40 | response.EnsureSuccessStatusCode(); 41 | 42 | var feature = await response.Content.ReadAsJson(); 43 | 44 | Assert.True(feature.Enabled); 45 | Assert.Equal(nameof(TestFeature), feature.Name); 46 | Assert.Equal("Test feature description.", feature.Description); 47 | } 48 | 49 | [Fact] 50 | public async Task GetAll_ReturnsExpectedFeatures() 51 | { 52 | var client = fixture.CreateClient(); 53 | 54 | var testFeature = new FeatureRequest 55 | { 56 | Name = nameof(TestFeature), 57 | Enabled = true 58 | }; 59 | 60 | var testFeature2 = new FeatureRequest 61 | { 62 | Name = nameof(TestFeature2), 63 | Enabled = true 64 | }; 65 | 66 | await SetValueViaApiAsync(testFeature); 67 | await SetValueViaApiAsync(testFeature2); 68 | 69 | var uiSettings = fixture.Services.GetRequiredService(); 70 | var response = await client.GetAsync(uiSettings.ApiGetAllPath); 71 | 72 | response.EnsureSuccessStatusCode(); 73 | 74 | var features = (await response.Content.ReadAsJson>()).ToList(); 75 | 76 | Assert.Equal(2, features.Count); 77 | 78 | Assert.All(features, feature => Assert.True(feature.Enabled)); 79 | } 80 | 81 | private async Task SetValueViaApiAsync(FeatureRequest featureRequest) 82 | { 83 | var client = fixture.CreateClient(); 84 | var uiSettings = fixture.Services.GetRequiredService(); 85 | var response = await client.PostAsync( 86 | uiSettings.ApiSetPath, 87 | new StringContent(JsonConvert.SerializeObject(featureRequest)) 88 | ); 89 | response.EnsureSuccessStatusCode(); 90 | } 91 | 92 | private async Task GetFeatureFromApiAsync(string featureName) 93 | { 94 | var client = fixture.CreateClient(); 95 | var uiSettings = fixture.Services.GetRequiredService(); 96 | var httpResponse = await client.GetAsync( 97 | $"{uiSettings.ApiGetPath}?feature={featureName}" 98 | ); 99 | httpResponse.EnsureSuccessStatusCode(); 100 | return await httpResponse.Content.ReadAsJson(); 101 | } 102 | 103 | [Theory] 104 | [InlineData(null)] 105 | [InlineData(false)] 106 | [InlineData(true)] 107 | public async Task Set_SetsExpectedFeature(bool? expected) 108 | { 109 | var request = new FeatureRequest 110 | { 111 | Name = nameof(TestFeature2), 112 | Enabled = expected 113 | }; 114 | 115 | await SetValueViaApiAsync(request); 116 | 117 | var result = await GetFeatureFromApiAsync(nameof(TestFeature2)); 118 | 119 | Assert.Equal(expected, result.Enabled); 120 | Assert.Equal(nameof(TestFeature2), result.Name); 121 | Assert.Equal("Test feature 2 description.", result.Description); 122 | } 123 | 124 | [Fact] 125 | public async Task UIPath_ReturnsExpectedHtml() 126 | { 127 | var client = fixture.CreateClient(); 128 | var uiSettings = fixture.Services.GetRequiredService(); 129 | var response = await client.GetAsync(uiSettings.UIPath); 130 | 131 | response.EnsureSuccessStatusCode(); 132 | 133 | var responseString = await response.Content.ReadAsStringAsync(); 134 | 135 | Assert.StartsWith("", responseString); 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/FeatureTests.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace RimDev.AspNetCore.FeatureFlags.Tests 4 | { 5 | public class FeatureTests 6 | { 7 | [Fact] 8 | public void TestFeature_has_correct_name() 9 | { 10 | var feature = new TestFeature(); 11 | Assert.Equal("TestFeature", feature.Name); 12 | } 13 | 14 | [Fact] 15 | public void TestFeature_has_correct_description() 16 | { 17 | var feature = new TestFeature(); 18 | Assert.Equal("Test feature description.", feature.Description); 19 | } 20 | 21 | [Fact] 22 | public void TestFeature2_has_correct_name() 23 | { 24 | var feature = new TestFeature2(); 25 | Assert.Equal("TestFeature2", feature.Name); 26 | } 27 | 28 | [Fact] 29 | public void TestFeature2_has_correct_description() 30 | { 31 | var feature = new TestFeature2(); 32 | Assert.Equal("Test feature 2 description.", feature.Description); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/HttpContentExtensions.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System.Net.Http; 3 | using System.Threading.Tasks; 4 | 5 | namespace RimDev.AspNetCore.FeatureFlags.Tests 6 | { 7 | public static class HttpContentExtensions 8 | { 9 | public static async Task ReadAsJson(this HttpContent content) 10 | { 11 | var str = await content.ReadAsStringAsync().ConfigureAwait(false); 12 | 13 | return (T)JsonConvert.DeserializeObject(str, typeof(T)); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/JsonBooleanConverterTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Xunit; 3 | 4 | namespace RimDev.AspNetCore.FeatureFlags.Tests; 5 | 6 | public class JsonBooleanConverterTests 7 | { 8 | private readonly JsonBooleanConverter sut = new(); 9 | 10 | [Theory] 11 | [InlineData(false, typeof(DateTime))] 12 | [InlineData(true, typeof(string))] 13 | [InlineData(true, typeof(bool))] 14 | public void CanConvert_returns_expected(bool expected, Type objectType) 15 | { 16 | var result = sut.CanConvert(objectType); 17 | Assert.Equal(expected, result); 18 | } 19 | 20 | [Theory] 21 | [InlineData(null, null)] 22 | [InlineData(null, "")] 23 | [InlineData(null, "null")] 24 | [InlineData(false, "false")] 25 | [InlineData(false, "False")] 26 | [InlineData(false, "FALSE")] 27 | [InlineData(true, "true")] 28 | [InlineData(true, "True")] 29 | [InlineData(true, "TRUE")] 30 | public void ReadJson_returns_expected_for_string(bool? expected, string input) 31 | { 32 | var result = sut.ReadJson(input); 33 | Assert.Equal(expected, result); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:64616/", 7 | "sslPort": 44360 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "RimDev.AspNetCore.FeatureFlags.Tests": { 19 | "commandName": "Project", 20 | "launchBrowser": true, 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development" 23 | }, 24 | "applicationUrl": "https://localhost:5001;http://localhost:5000" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/RimDev.AspNetCore.FeatureFlags.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | all 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/TestFeature.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace RimDev.AspNetCore.FeatureFlags.Tests 4 | { 5 | [Description("Test feature description.")] 6 | public class TestFeature : Feature 7 | { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/TestFeature2.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace RimDev.AspNetCore.FeatureFlags.Tests 4 | { 5 | [Description("Test feature 2 description.")] 6 | public class TestFeature2 : Feature 7 | { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/ApplicationFactory/TestStartup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using RimDev.AspNetCore.FeatureFlags.Core; 6 | using RimDev.AspNetCore.FeatureFlags.UI; 7 | 8 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.ApplicationFactory 9 | { 10 | public class TestStartup 11 | { 12 | private readonly IConfiguration configuration; 13 | 14 | public TestStartup(IConfiguration configuration) 15 | { 16 | this.configuration = configuration; 17 | } 18 | 19 | public void ConfigureServices(IServiceCollection services) 20 | { 21 | var featureFlagsConnectionString 22 | = configuration.GetConnectionString("featureFlags"); 23 | var featureFlagsInitializationConnectionString 24 | = configuration.GetConnectionString("featureFlagsInitialization"); 25 | 26 | services.AddRimDevFeatureFlags( 27 | configuration, 28 | new[] { typeof(TestStartup).Assembly }, 29 | connectionString: featureFlagsConnectionString, 30 | initializationConnectionString: featureFlagsInitializationConnectionString 31 | ); 32 | 33 | services.AddRimDevFeatureFlagsUI(); 34 | } 35 | 36 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 37 | { 38 | app.UseRimDevFeatureFlags(); 39 | app.UseRimDevFeatureFlagsUI(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/ApplicationFactory/TestWebApplicationCollection.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.ApplicationFactory 4 | { 5 | [CollectionDefinition(nameof(TestWebApplicationCollection))] 6 | public class TestWebApplicationCollection : ICollectionFixture 7 | { 8 | // This class has no code, and is never created. Its purpose is simply 9 | // to be the place to apply [CollectionDefinition] and all the 10 | // ICollectionFixture<> interfaces. 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/ApplicationFactory/TestWebApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Microsoft.AspNetCore; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.AspNetCore.Mvc.Testing; 5 | using Microsoft.Extensions.Configuration; 6 | using RimDev.AspNetCore.FeatureFlags.Tests.Testing.Database; 7 | 8 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.ApplicationFactory 9 | { 10 | public class TestWebApplicationFactory : WebApplicationFactory 11 | { 12 | private readonly EmptyDatabaseFixture databaseFixture = new EmptyDatabaseFixture(); 13 | 14 | protected override IWebHostBuilder CreateWebHostBuilder() 15 | { 16 | return WebHost 17 | .CreateDefaultBuilder() 18 | .UseStartup(); 19 | } 20 | 21 | protected override void ConfigureWebHost(IWebHostBuilder builder) 22 | { 23 | builder.UseContentRoot("."); 24 | 25 | builder.ConfigureAppConfiguration(configuration => 26 | { 27 | configuration.AddInMemoryCollection(new Dictionary 28 | { 29 | { "connectionStrings:featureFlags", databaseFixture.ConnectionString }, 30 | { "connectionStrings:featureFlagsInitialization", databaseFixture.ConnectionString }, 31 | }); 32 | }); 33 | 34 | base.ConfigureWebHost(builder); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/Configuration/RimDevTestsConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.Configuration 2 | { 3 | public class RimDevTestsConfiguration 4 | { 5 | public RimDevTestsSqlConfiguration Sql { get; set; } 6 | = new RimDevTestsSqlConfiguration(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/Configuration/RimDevTestsSqlConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.Configuration 2 | { 3 | public class RimDevTestsSqlConfiguration 4 | { 5 | /// This is usually "localhost", but in the case of SQL Server Developer 2019 6 | /// edition on AppVeyor, it would be set as "(local)\SQL2019". 7 | public string Hostname { get; set; } = "localhost"; 8 | 9 | /// Port that SQL Server will be listening on. This is kept separate for ease of 10 | /// use in the build scripts if Docker SQL is used. The default assumption is that the 11 | /// developer is running SQL for Docker on port 11433. 12 | public int Port { get; set; } = 11433; 13 | 14 | public string InitialCatalog { get; set; } = "master"; 15 | public string UserId { get; set; } = "sa"; 16 | 17 | /// See: https://github.com/ritterim/hub/blob/master/docker-compose.yml 18 | /// The assumption is that the developer is running a 'hub' Docker container. 19 | public string Password { get; set; } = "Pass123!"; 20 | 21 | private bool HostnameOnlyWithoutPort => Hostname?.Contains("\\") == true; 22 | 23 | /// If the Hostname contains a "\" (backslash), we output only the hostname (without the port) 24 | /// because that's probably SQL Server Developer that is not running on a specific TCP port. 25 | /// 26 | public string DataSource => HostnameOnlyWithoutPort 27 | ? $"{Hostname}" 28 | : $"{Hostname},{Port}"; 29 | 30 | public string MasterConnectionString => 31 | $@"server={DataSource};database={InitialCatalog};user={UserId};password={Password};"; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/Configuration/TestConfigurationHelpers.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.Hosting; 5 | 6 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.Configuration 7 | { 8 | public static class TestConfigurationHelpers 9 | { 10 | public const string AppSettingsJsonFileName = "appsettings"; 11 | public const string TestSettingsJsonFileName = "testsettings"; 12 | 13 | public static string TestSettingsFileName(string environmentName = null) 14 | { 15 | if (string.IsNullOrEmpty(environmentName)) return $"{TestSettingsJsonFileName}.json"; 16 | return $"{TestSettingsJsonFileName}.{environmentName}.json"; 17 | } 18 | 19 | public static string TestSettingsFilePath( 20 | string baseDirectory, 21 | string environmentName = null 22 | ) 23 | { 24 | if (string.IsNullOrEmpty(baseDirectory)) throw new ArgumentNullException(nameof(baseDirectory)); 25 | var testSettingsEnvironmentFileName = TestSettingsFileName(environmentName); 26 | return Path.Combine(baseDirectory, testSettingsEnvironmentFileName); 27 | } 28 | 29 | public static bool IsRunningOnAppVeyor() => 30 | // https://www.appveyor.com/docs/environment-variables/ 31 | bool.TryParse( 32 | Environment.GetEnvironmentVariable("APPVEYOR"), 33 | out var parsedAppVeyorVariable 34 | ) && parsedAppVeyorVariable; 35 | 36 | private const string RimDevTestsSectionName = "RimDevTests"; 37 | 38 | /// 39 | /// Returns a instance that is built up by 40 | /// looking at appsettings/testsettings JSON files in the output directory. 41 | /// It is recommended that you keep your test configuration in testsettings.json 42 | /// (or testsettings.Development.json or testsettings.AppVeyor.json) instead of putting it 43 | /// into the appsettings(.ENVIRONMENT).json file(s). That avoids any weirdness where your 44 | /// appsettings.json under "project.Tests" overwrites the appsettings.json under "project". 45 | /// 46 | public static RimDevTestsConfiguration GetRimDevTestsConfiguration( 47 | string baseDirectory = null 48 | ) 49 | { 50 | Console.WriteLine("GetRimDevTestsConfiguration:"); 51 | if (string.IsNullOrEmpty(baseDirectory)) baseDirectory = AppContext.BaseDirectory; 52 | Console.WriteLine($" baseDirectory: {baseDirectory}"); 53 | var testEnvironmentName = GetRimDevTestEnvironmentName(); 54 | Console.WriteLine($" testEnvironmentName: {testEnvironmentName}"); 55 | 56 | var appSettingsFileName = $"{AppSettingsJsonFileName}.json"; 57 | var appSettingsFilePath = Path.Combine(baseDirectory, appSettingsFileName); 58 | Console.WriteLine($" Loading ({File.Exists(appSettingsFilePath)}): {appSettingsFileName}"); 59 | 60 | var appSettingsEnvironmentFileName = $"{AppSettingsJsonFileName}.{testEnvironmentName}.json"; 61 | var appSettingsEnvironmentFilePath = Path.Combine(baseDirectory, appSettingsEnvironmentFileName); 62 | Console.WriteLine($" Loading ({File.Exists(appSettingsEnvironmentFilePath)}): {appSettingsEnvironmentFileName}"); 63 | 64 | var testSettingsFileName = TestSettingsFileName(); 65 | var testSettingsFilePath = TestSettingsFilePath(baseDirectory); 66 | Console.WriteLine($" Loading ({File.Exists(testSettingsFilePath)}): {testSettingsFileName}"); 67 | 68 | var testSettingsEnvironmentFileName = TestSettingsFileName(testEnvironmentName); 69 | var testSettingsEnvironmentFilePath = TestSettingsFilePath(baseDirectory, testEnvironmentName); 70 | Console.WriteLine($" Loading ({File.Exists(testSettingsEnvironmentFilePath)}): {testSettingsEnvironmentFileName}"); 71 | 72 | var configurationRoot = new ConfigurationBuilder() 73 | .SetBasePath(baseDirectory) 74 | // support the old approach where we recommended appsettings(.ENV).json 75 | .AddJsonFile($"appsettings.json", optional: true) 76 | .AddJsonFile($"appsettings.{testEnvironmentName}.json", optional: true) 77 | // this is the better approach, so allow this to override the old approach 78 | .AddJsonFile(testSettingsFilePath, optional: true) 79 | .AddJsonFile(testSettingsEnvironmentFilePath, optional: true) 80 | .AddEnvironmentVariables() 81 | .Build(); 82 | 83 | var configuration = new RimDevTestsConfiguration(); 84 | configurationRoot 85 | .GetSection(RimDevTestsSectionName) 86 | .Bind(configuration); 87 | 88 | Console.WriteLine(" Finished GetRimDevTestsConfiguration()"); 89 | return configuration; 90 | } 91 | 92 | /// Figure out which additional environment-specific JSON configuration files 93 | /// to load over top of the base file settings. 94 | /// Returns "AppVeyor" if running in CI/CD, otherwise it looks at RIMDEVTEST_ENVIRONMENT, 95 | /// with a fallback to "Development". 96 | /// We specifically do *not* look at the DOTNET_ENVIRONMENT variable or the ASPNETCORE_ENVIRONMENT 97 | /// variable because the environment needed to setup your test harness can differ from 98 | /// how the application needs to be configured for tests. 99 | /// 100 | public static string GetRimDevTestEnvironmentName() 101 | { 102 | if (IsRunningOnAppVeyor()) return "AppVeyor"; 103 | 104 | var rimDevTestEnvironment = Environment.GetEnvironmentVariable("RIMDEVTEST_ENVIRONMENT"); 105 | return string.IsNullOrWhiteSpace(rimDevTestEnvironment) 106 | ? Environments.Development 107 | : rimDevTestEnvironment; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/Database/EmptyDatabaseCollection.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.Database 4 | { 5 | /// This xUnit collection (and the fixture 6 | /// should only be used for tests where you want a fresh blank test database, prior to 7 | /// any migrations being executed. 8 | [CollectionDefinition(nameof(EmptyDatabaseCollection))] 9 | public class EmptyDatabaseCollection : ICollectionFixture 10 | { 11 | // This class has no code, and is never created. Its purpose is simply 12 | // to be the place to apply [CollectionDefinition] and all the 13 | // ICollectionFixture<> interfaces. 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/Database/EmptyDatabaseFixture.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.Database 4 | { 5 | /// Creates up a blank, freshly created, randomly named, test database without any migrations. 6 | /// Creation of database stubs is optional. 7 | public class EmptyDatabaseFixture : TestSqlClientDatabaseFixture 8 | { 9 | public EmptyDatabaseFixture() 10 | { 11 | Console.WriteLine($"Creating {nameof(EmptyDatabaseFixture)} instance..."); 12 | Console.WriteLine($"Created {nameof(EmptyDatabaseFixture)}."); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/Database/TestSqlClientDatabaseFixture.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data.SqlClient; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Threading; 6 | using RimDev.AspNetCore.FeatureFlags.Tests.Testing.Configuration; 7 | 8 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.Database 9 | { 10 | /// Base class for any System.Data.SqlClient database fixture. This fixture only 11 | /// takes care of database creation/disposal and not initialization (migrations, etc.). 12 | /// If a connection string is not provided, it will make use of 13 | /// methods to figure it out. 14 | public abstract class TestSqlClientDatabaseFixture : IDisposable 15 | { 16 | private readonly bool deleteBefore; 17 | private readonly bool deleteAfter; 18 | 19 | /// The PRNG is (somewhat) expensive to created and the default seed is the current 20 | /// instant. So it's best to create one and only one instance of the Random class. 21 | private static readonly Random Random = new Random(); 22 | 23 | /// 24 | /// In order for the fixture to create other databases, it must be able to connect 25 | /// to an existing database within the SQL server. By custom, this is usually the 'master' 26 | /// database. The connection string is then mutated to replace 'master' with the 27 | /// dbName parameter. 28 | /// 29 | /// SQL server connection string to 'master' database. 30 | /// Optional database name to be created and used for tests. If a database 31 | /// name is not provided, it will be randomly generated and it is strongly recommended that 32 | /// the deleteBefore and deleteAfter arguments be set to true. 33 | /// Whether the database should be dropped prior to tests. 34 | /// Whether the database should be dropped after tests. 35 | /// Optional suffix to be tacked onto the randomly generated database 36 | /// name. This can be used in multi-database situations to keep track of which is which. 37 | /// 38 | protected TestSqlClientDatabaseFixture( 39 | string masterConnectionString = null, 40 | string dbName = null, 41 | bool deleteBefore = true, 42 | bool deleteAfter = true, 43 | string dbNameSuffix = null 44 | ) 45 | { 46 | Console.WriteLine($"Creating {nameof(TestSqlClientDatabaseFixture)} instance..."); 47 | 48 | if (string.IsNullOrEmpty(masterConnectionString)) 49 | { 50 | var testConfiguration = TestConfigurationHelpers.GetRimDevTestsConfiguration(); 51 | masterConnectionString = testConfiguration?.Sql?.MasterConnectionString; 52 | } 53 | 54 | if (string.IsNullOrEmpty(dbName)) 55 | { 56 | var now = DateTimeOffset.UtcNow; 57 | dbName = $"test-{now:yyyyMMdd}-{now:HHmm}-{UpperCaseAlphanumeric(6)}"; 58 | } 59 | 60 | if (!string.IsNullOrEmpty(dbNameSuffix)) 61 | { 62 | dbName = $"{dbName}-{dbNameSuffix}"; 63 | } 64 | 65 | MasterConnectionString = masterConnectionString; 66 | DbName = dbName; 67 | ConnectionString = SwitchMasterToDbNameInConnectionString(masterConnectionString, dbName); 68 | this.deleteBefore = deleteBefore; 69 | this.deleteAfter = deleteAfter; 70 | 71 | Console.WriteLine(MasterConnectionStringDebug); 72 | Console.WriteLine(ConnectionStringDebug); 73 | RecreateDatabase(); 74 | 75 | // Databases do not always wake up right away after the CREATE DATABASE call 76 | WaitUntilDatabaseIsHealthy(); 77 | 78 | Console.WriteLine($"{nameof(TestSqlClientDatabaseFixture)} instance created."); 79 | } 80 | 81 | /// Test fixtures need to point at the "master" database initially, because the database 82 | /// to be used in the tests may not exist yet. 83 | private string MasterConnectionString { get; } 84 | 85 | /// Returns a parsed connection string, listing key details about it. 86 | /// This debug string will not output the database password, which makes it safe-ish 87 | /// for output in console logs / build logs. 88 | public string MasterConnectionStringDebug 89 | { 90 | get 91 | { 92 | try 93 | { 94 | var b = new SqlConnectionStringBuilder(MasterConnectionString); 95 | return $"MASTER-DB: DataSource={b.DataSource}, InitialCatalog={b.InitialCatalog}, UserID={b.UserID}"; 96 | } 97 | catch 98 | { 99 | return "MASTER-DB: Bad connection string, unable to parse!"; 100 | } 101 | } 102 | } 103 | 104 | /// The temporary database name that was created by the fixture. 105 | public string DbName { get; } 106 | 107 | /// The connection string to get to the database which will be used for tests. 108 | public string ConnectionString { get; } 109 | 110 | /// Returns a parsed connection string, listing key details about it. 111 | /// This debug string will not output the database password, which makes it safe-ish 112 | /// for output in console logs / build logs. 113 | public string ConnectionStringDebug 114 | { 115 | get 116 | { 117 | try 118 | { 119 | var b = new SqlConnectionStringBuilder(ConnectionString); 120 | return $"TEST-DB: DataSource={b.DataSource}, InitialCatalog={b.InitialCatalog}, UserID={b.UserID}"; 121 | } 122 | catch 123 | { 124 | return "TEST-DB: Bad connection string, unable to parse!"; 125 | } 126 | } 127 | } 128 | 129 | /// Returns a SqlConnection object to the test database for the fixture. 130 | /// Since the SqlConnection implements IDisposable, it should be paired with "using". 131 | /// 132 | public SqlConnection CreateSqlConnection() 133 | { 134 | return new SqlConnection(ConnectionString); 135 | } 136 | 137 | /// Convert from the "master" database SQL connection string over to the specific database name 138 | /// that is being used for a particular database fixture. This relies on the connectionString being 139 | /// something that can be parsed by System.Data.SqlClient. 140 | private string SwitchMasterToDbNameInConnectionString(string connectionString, string dbName) 141 | { 142 | if (string.IsNullOrWhiteSpace(connectionString)) 143 | throw new ArgumentNullException(nameof(connectionString)); 144 | 145 | if (string.IsNullOrWhiteSpace(dbName)) 146 | throw new ArgumentNullException(nameof(dbName)); 147 | 148 | var builder = new SqlConnectionStringBuilder(connectionString) 149 | { 150 | InitialCatalog = dbName 151 | }; 152 | 153 | return builder.ConnectionString; 154 | } 155 | 156 | private static string UpperCaseAlphanumeric(int size) 157 | { 158 | string input = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; 159 | var chars = Enumerable.Range(0, size) 160 | .Select(x => input[Random.Next(0, input.Length)]); 161 | return new string(chars.ToArray()); 162 | } 163 | 164 | /// Tracks whether the fixture thinks that it has already initialized 165 | /// (created) the database, or decided that the database already exists. 166 | private bool databaseInitialized; 167 | 168 | private readonly object @lock = new object(); 169 | 170 | private void RecreateDatabase() 171 | { 172 | // https://en.wikipedia.org/wiki/Double-checked_locking#Usage_in_C# 173 | if (databaseInitialized) return; 174 | lock (@lock) 175 | { 176 | if (databaseInitialized) return; 177 | 178 | if (deleteBefore) 179 | { 180 | try 181 | { 182 | if (DatabaseExists()) DropDatabase(); 183 | } 184 | catch (Exception e) 185 | { 186 | Console.WriteLine($"ERROR: Failed to drop database {DbName} before the run!"); 187 | Console.WriteLine(e.Message); 188 | throw; 189 | } 190 | } 191 | 192 | try 193 | { 194 | if (!DatabaseExists()) CreateDatabase(); 195 | } 196 | catch (Exception e) 197 | { 198 | Console.WriteLine($"ERROR: Failed to create database {DbName}!"); 199 | Console.WriteLine(e.Message); 200 | throw; 201 | } 202 | 203 | databaseInitialized = true; 204 | } 205 | } 206 | 207 | public void Dispose() 208 | { 209 | // https://en.wikipedia.org/wiki/Double-checked_locking#Usage_in_C# 210 | if (!databaseInitialized) return; 211 | lock (@lock) 212 | { 213 | if (!databaseInitialized) return; 214 | 215 | if (deleteAfter) 216 | { 217 | if (DatabaseExists()) DropDatabase(); 218 | } 219 | 220 | databaseInitialized = false; 221 | } 222 | } 223 | 224 | private bool DatabaseExists() 225 | { 226 | //TODO: Consider querying the standard SQL metadata tables instead, but this works okay 227 | using var connection = CreateSqlConnection(); 228 | var command = new SqlCommand("select 1;", connection); 229 | try 230 | { 231 | connection.Open(); 232 | var result = (int)command.ExecuteScalar(); 233 | return (result == 1); 234 | } 235 | catch (SqlException) 236 | { 237 | return false; 238 | } 239 | } 240 | 241 | /// Is the database accepting connections and will it run queries? 242 | private bool DatabaseIsHealthy() 243 | { 244 | using var connection = CreateSqlConnection(); 245 | var command = new SqlCommand("select 1;", connection); 246 | try 247 | { 248 | connection.Open(); 249 | var result = (int)command.ExecuteScalar(); 250 | return (result == 1); 251 | } 252 | catch (SqlException) 253 | { 254 | return false; 255 | } 256 | } 257 | 258 | private void CreateDatabase() 259 | { 260 | Console.WriteLine($"Creating database '{DbName}'..."); 261 | 262 | using (var connection = new SqlConnection(MasterConnectionString)) 263 | { 264 | connection.Open(); 265 | 266 | using var command = connection.CreateCommand(); 267 | command.CommandText = FormattableString.Invariant( 268 | $"CREATE DATABASE [{DbName}];"); 269 | command.ExecuteNonQuery(); 270 | 271 | Console.WriteLine($"Created database '{DbName}'."); 272 | } 273 | } 274 | 275 | private void WaitUntilDatabaseIsHealthy() 276 | { 277 | Console.WriteLine($"Begin waiting for '{DbName}' to accept queries."); 278 | var timer = new Stopwatch(); 279 | timer.Start(); 280 | var attempt = 1; 281 | while (!DatabaseIsHealthy()) 282 | { 283 | attempt++; 284 | var sleepMilliseconds = Math.Min((int) (Math.Pow(1.1, attempt) * 150), 1000); 285 | Thread.Sleep(sleepMilliseconds); 286 | if (attempt > 60) 287 | throw new Exception( 288 | $"Database '{DbName}' refused to execute queries!" 289 | ); 290 | } 291 | 292 | timer.Stop(); 293 | Console.WriteLine($"The '{DbName}' is healthy after {timer.ElapsedMilliseconds}ms and {attempt} attempts."); 294 | } 295 | 296 | private void DropDatabase() 297 | { 298 | Console.WriteLine($"Dropping database '{DbName}'..."); 299 | 300 | using var connection = new SqlConnection(MasterConnectionString); 301 | 302 | connection.Open(); 303 | 304 | using var command = connection.CreateCommand(); 305 | 306 | command.CommandText = FormattableString.Invariant( 307 | $"ALTER DATABASE [{DbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{DbName}];"); 308 | 309 | command.ExecuteNonQuery(); 310 | 311 | Console.WriteLine($"Dropped database '{DbName}'."); 312 | } 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/Database/Tests/EmptyDatabaseFixtureTests.cs: -------------------------------------------------------------------------------- 1 | using System.Data.SqlClient; 2 | using System.Threading.Tasks; 3 | using Xunit; 4 | 5 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing.Database.Tests 6 | { 7 | [Collection(nameof(EmptyDatabaseCollection))] 8 | public class EmptyDatabaseFixtureTests 9 | { 10 | private readonly EmptyDatabaseFixture _fixture; 11 | 12 | public EmptyDatabaseFixtureTests(EmptyDatabaseFixture fixture) 13 | { 14 | _fixture = fixture; 15 | } 16 | 17 | [Fact] 18 | public void MasterConnectionStringDebug_not_empty() 19 | { 20 | Assert.NotEmpty(_fixture.MasterConnectionStringDebug); 21 | } 22 | 23 | [Fact] 24 | public void ConnectionStringDebug_not_empty() 25 | { 26 | Assert.NotEmpty(_fixture.ConnectionStringDebug); 27 | } 28 | 29 | [Fact] 30 | public async Task Can_access_database_at_ConnectionString() 31 | { 32 | await using var conn = new SqlConnection(_fixture.ConnectionString); 33 | await conn.OpenAsync(); 34 | 35 | var cmd = conn.CreateCommand(); 36 | cmd.CommandText = "select count(*) from INFORMATION_SCHEMA.TABLES;"; 37 | var queryResult = (int) await cmd.ExecuteScalarAsync(); 38 | Assert.True(queryResult >= 0); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/RimDev.AspNetCore.FeatureFlags.Tests/Testing/Rng.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace RimDev.AspNetCore.FeatureFlags.Tests.Testing 4 | { 5 | public static class Rng 6 | { 7 | private static readonly Random Random = new Random(); 8 | 9 | public static int GetInt(int minValue, int maxValue) => Random.Next(minValue, maxValue); 10 | 11 | public static bool GetBool() => Random.Next(0, 2) == 1; 12 | 13 | public static bool? GetNullableBool() 14 | { 15 | var value = Random.Next(-1, 2); 16 | return value switch 17 | { 18 | 0 => false, 19 | 1 => true, 20 | _ => null 21 | }; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 3.0 2 | --------------------------------------------------------------------------------