diff --git a/Microsoft.FeatureManagement.sln b/Microsoft.FeatureManagement.sln index c7fcfcc2..b7b5dc97 100644 --- a/Microsoft.FeatureManagement.sln +++ b/Microsoft.FeatureManagement.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TargetingConsoleApp", "exam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorServerApp", "examples\BlazorServerApp\BlazorServerApp.csproj", "{12BAB5A6-4EEB-4917-B5D9-4AFB6253008E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GettingFeaturesFromDatabase", "examples\GettingFeaturesFromDatabase\GettingFeaturesFromDatabase.csproj", "{C58C3CF1-756A-4A3B-9591-99F0D372B208}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +69,10 @@ Global {12BAB5A6-4EEB-4917-B5D9-4AFB6253008E}.Debug|Any CPU.Build.0 = Debug|Any CPU {12BAB5A6-4EEB-4917-B5D9-4AFB6253008E}.Release|Any CPU.ActiveCfg = Release|Any CPU {12BAB5A6-4EEB-4917-B5D9-4AFB6253008E}.Release|Any CPU.Build.0 = Release|Any CPU + {C58C3CF1-756A-4A3B-9591-99F0D372B208}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C58C3CF1-756A-4A3B-9591-99F0D372B208}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C58C3CF1-756A-4A3B-9591-99F0D372B208}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C58C3CF1-756A-4A3B-9591-99F0D372B208}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -79,6 +85,7 @@ Global {DACAB624-4611-42E8-844C-529F93A54980} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} {283D3EBB-4716-4F1D-BA51-A435F7E2AB82} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} {12BAB5A6-4EEB-4917-B5D9-4AFB6253008E} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} + {C58C3CF1-756A-4A3B-9591-99F0D372B208} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {84DA6C54-F140-4518-A1B4-E4CF42117FBD} diff --git a/examples/GettingFeaturesFromDatabase/Constants/FeatureConstants.cs b/examples/GettingFeaturesFromDatabase/Constants/FeatureConstants.cs new file mode 100644 index 00000000..ed7774ce --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Constants/FeatureConstants.cs @@ -0,0 +1,6 @@ +namespace GettingFeaturesFromDatabase.Constants; + +public static class FeatureConstants +{ + public const string Weather = "Weather"; +} diff --git a/examples/GettingFeaturesFromDatabase/Controllers/FeatureController.cs b/examples/GettingFeaturesFromDatabase/Controllers/FeatureController.cs new file mode 100644 index 00000000..43874956 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Controllers/FeatureController.cs @@ -0,0 +1,29 @@ +using GettingFeaturesFromDatabase.Database; +using GettingFeaturesFromDatabase.Database.Services; +using Microsoft.AspNetCore.Mvc; + +namespace GettingFeaturesFromDatabase.Controllers; + +[ApiController] +[Route("feature")] +public class FeatureController : ControllerBase +{ + private readonly IFeatureService _featureService; + + public FeatureController(IFeatureService featureService) + { + _featureService = featureService; + } + + [HttpGet] + public async Task> GetFeatures() + { + return await _featureService.GetFeatureAsync(); + } + + [HttpPut] + public async Task UpdateFeature(string featureName, bool isEnabled) + { + await _featureService.UpdateFeatureAsync(featureName, isEnabled); + } +} diff --git a/examples/GettingFeaturesFromDatabase/Controllers/WeatherForecastController.cs b/examples/GettingFeaturesFromDatabase/Controllers/WeatherForecastController.cs new file mode 100644 index 00000000..fa752ae7 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Controllers/WeatherForecastController.cs @@ -0,0 +1,35 @@ +using GettingFeaturesFromDatabase.Constants; +using Microsoft.AspNetCore.Mvc; +using Microsoft.FeatureManagement.Mvc; + +namespace GettingFeaturesFromDatabase.Controllers; + +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase +{ + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + private readonly ILogger _logger; + + public WeatherForecastController(ILogger logger) + { + _logger = logger; + } + + [HttpGet(Name = "GetWeatherForecast")] + [FeatureGate(FeatureConstants.Weather)] + public IEnumerable Get() + { + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + }) + .ToArray(); + } +} diff --git a/examples/GettingFeaturesFromDatabase/CustomFeatureDefinitionProvider.cs b/examples/GettingFeaturesFromDatabase/CustomFeatureDefinitionProvider.cs new file mode 100644 index 00000000..6a48cee1 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/CustomFeatureDefinitionProvider.cs @@ -0,0 +1,60 @@ +using GettingFeaturesFromDatabase.Database; +using GettingFeaturesFromDatabase.Database.Services; +using Microsoft.FeatureManagement; + +namespace GettingFeaturesFromDatabase; + +public class CustomFeatureDefinitionProvider : IFeatureDefinitionProvider +{ + private readonly IFeatureService _featureService; + + public CustomFeatureDefinitionProvider(IFeatureService featureService) + { + _featureService = featureService; + } + + public async Task GetFeatureDefinitionAsync(string featureName) + { + var feature = await _featureService.GetFeatureAsync(featureName); + + return GenerateFeatureDefinition(feature); + } + + public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() + { + var features = await _featureService.GetFeatureAsync(); + + foreach (var feature in features) + { + yield return GenerateFeatureDefinition(feature); + } + } + + private FeatureDefinition GenerateFeatureDefinition(Feature? feature) + { + if (feature is null) + { + return new FeatureDefinition(); + } + + if (feature.IsEnabled) + { + return new FeatureDefinition + { + Name = feature.Name, + EnabledFor = new[] + { + new FeatureFilterConfiguration + { + Name = "AlwaysOn", + }, + }, + }; + } + + return new FeatureDefinition + { + Name = feature.Name, + }; + } +} diff --git a/examples/GettingFeaturesFromDatabase/Database/Feature.cs b/examples/GettingFeaturesFromDatabase/Database/Feature.cs new file mode 100644 index 00000000..bb7ae350 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Database/Feature.cs @@ -0,0 +1,8 @@ +namespace GettingFeaturesFromDatabase.Database; + +public class Feature +{ + public string Name { get; set; } + + public bool IsEnabled { get; set; } +} diff --git a/examples/GettingFeaturesFromDatabase/Database/FeatureConfiguration.cs b/examples/GettingFeaturesFromDatabase/Database/FeatureConfiguration.cs new file mode 100644 index 00000000..7068ba6c --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Database/FeatureConfiguration.cs @@ -0,0 +1,22 @@ +using GettingFeaturesFromDatabase.Constants; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GettingFeaturesFromDatabase.Database; + +internal sealed class FeatureConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name).IsRequired(); + builder.Property(x => x.IsEnabled).IsRequired(); + + /* Populate with feature data. */ + builder.HasData(new List() + { + new Feature { Name = FeatureConstants.Weather, IsEnabled = true }, + }); + + builder.ToTable("Features").HasKey(x => x.Name); + } +} diff --git a/examples/GettingFeaturesFromDatabase/Database/ServiceCollectionExtensions.cs b/examples/GettingFeaturesFromDatabase/Database/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..0608da95 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Database/ServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using GettingFeaturesFromDatabase.Database.Services; +using Microsoft.EntityFrameworkCore; + +namespace GettingFeaturesFromDatabase.Database; + +public static class ServiceCollectionExtensions +{ + public static void AddDatabase(this IServiceCollection services) + { + services.AddDbContext(options => + { + options.UseSqlite("Data Source=example.db"); + }); + + /* Create Database On Start */ + var databaseContext = services.BuildServiceProvider().GetRequiredService(); + databaseContext.Database.EnsureDeleted(); + databaseContext.Database.EnsureCreated(); + } + + public static void AddFeatureService(this IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/examples/GettingFeaturesFromDatabase/Database/Services/FeatureService.cs b/examples/GettingFeaturesFromDatabase/Database/Services/FeatureService.cs new file mode 100644 index 00000000..dd37ff05 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Database/Services/FeatureService.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; + +namespace GettingFeaturesFromDatabase.Database.Services; + +public class FeatureService : IFeatureService +{ + private readonly SqliteDbContext _sqliteDbContext; + + public FeatureService(SqliteDbContext sqliteDbContext) + { + _sqliteDbContext = sqliteDbContext; + } + + public async Task GetFeatureAsync(string featureName) + { + var feature = await _sqliteDbContext.Set().FindAsync(featureName); + + return feature; + } + + public async Task> GetFeatureAsync() + { + var features = await _sqliteDbContext.Set().ToListAsync(); + + return features; + } + + public async Task UpdateFeatureAsync(string featureName, bool isEnabled) + { + var feature = await _sqliteDbContext.Set().FindAsync(featureName); + if (feature != null) feature.IsEnabled = isEnabled; + + await _sqliteDbContext.SaveChangesAsync(); + } +} diff --git a/examples/GettingFeaturesFromDatabase/Database/Services/IFeatureService.cs b/examples/GettingFeaturesFromDatabase/Database/Services/IFeatureService.cs new file mode 100644 index 00000000..3d264a9d --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Database/Services/IFeatureService.cs @@ -0,0 +1,10 @@ +namespace GettingFeaturesFromDatabase.Database.Services; + +public interface IFeatureService +{ + Task GetFeatureAsync(string featureName); + + Task> GetFeatureAsync(); + + Task UpdateFeatureAsync(string featureName, bool isEnabled); +} diff --git a/examples/GettingFeaturesFromDatabase/Database/SqliteDbContext.cs b/examples/GettingFeaturesFromDatabase/Database/SqliteDbContext.cs new file mode 100644 index 00000000..24a45fb9 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Database/SqliteDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +namespace GettingFeaturesFromDatabase.Database; + +public class SqliteDbContext : DbContext +{ + public DbSet Features { get; set; } = null!; + + public SqliteDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.ApplyConfiguration(new FeatureConfiguration()); + } +} diff --git a/examples/GettingFeaturesFromDatabase/GettingFeaturesFromDatabase.csproj b/examples/GettingFeaturesFromDatabase/GettingFeaturesFromDatabase.csproj new file mode 100644 index 00000000..c1afa6c6 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/GettingFeaturesFromDatabase.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + diff --git a/examples/GettingFeaturesFromDatabase/Program.cs b/examples/GettingFeaturesFromDatabase/Program.cs new file mode 100644 index 00000000..649a22c8 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/Program.cs @@ -0,0 +1,30 @@ +using GettingFeaturesFromDatabase; +using GettingFeaturesFromDatabase.Database; +using Microsoft.FeatureManagement; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddDatabase(); +builder.Services.AddFeatureService(); + +/* Add feature management */ +builder.Services.AddScoped().AddScopedFeatureManagement(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/examples/GettingFeaturesFromDatabase/README.md b/examples/GettingFeaturesFromDatabase/README.md new file mode 100644 index 00000000..865c2cd7 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/README.md @@ -0,0 +1,47 @@ +# Getting features from database (Web API Example) + +This example demonstrates how to get features from a database and use them with Microsoft Feature Management. + +## Quickstart + +To get started, + +1. Simply run the project `GettingFeaturesFromDatabase` and you should see **Swagger UI** open in a new browser window. +2. Try out the `GET /WeatherForecast` endpoint and see the response. It should return a successful response (200 OK). +3. Now, try out `PUT /feature` endpoint to set the state of 'Weather' feature flag to 'false'. +4. Try out the `GET /WeatherForecast` endpoint again and see the response. It should return not found response (404 NotFound). + +_Note: You can get the list of features using `GET /feature` endpoint._ + +## About the example project + +This example is a simple Web API project with Entity Framework Core and SQLite database. + +The endpoints are, + +1. `GET /WeatherForecast` - This is the endpoint where `FeatureGate` attribute is used and access to this endpoint is controlled by the state of 'Weather' feature flag. +2. `GET /feature` - This is the endpoint to get all the feature flags from the database. +3. `PUT /feature` - This is the endpoint to update the state of a feature flag in the database. The query parameters are 'featureName' and 'isEnabled'. + +## Seeding the database + +During the initial run of the project, an SQLite database file named `example.db` will be created in the root of `GettingFeaturesFromDatabase` directory. +Initial run would also create a table named 'Features' and populate it with a feature flag named 'Weather'. + +You can find the database seeding logic inside `/Database/FeatureConfiguration.cs` file. + +```csharp + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Name).IsRequired(); + builder.Property(x => x.IsEnabled).IsRequired(); + + /* Populate with feature data. */ + builder.HasData(new List() + { + new Feature { Name = FeatureConstants.Weather, IsEnabled = true }, + }); + + builder.ToTable("Features").HasKey(x => x.Name); + } +``` diff --git a/examples/GettingFeaturesFromDatabase/WeatherForecast.cs b/examples/GettingFeaturesFromDatabase/WeatherForecast.cs new file mode 100644 index 00000000..afdfc728 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/WeatherForecast.cs @@ -0,0 +1,12 @@ +namespace GettingFeaturesFromDatabase; + +public class WeatherForecast +{ + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } +} diff --git a/examples/GettingFeaturesFromDatabase/appsettings.Development.json b/examples/GettingFeaturesFromDatabase/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/GettingFeaturesFromDatabase/appsettings.json b/examples/GettingFeaturesFromDatabase/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/examples/GettingFeaturesFromDatabase/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}