diff --git a/CommunityToolkit.Aspire.sln b/CommunityToolkit.Aspire.sln index 014aa37c..ca519b7a 100644 --- a/CommunityToolkit.Aspire.sln +++ b/CommunityToolkit.Aspire.sln @@ -249,6 +249,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hos EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.Dapr.ServiceDefaults", "examples\dapr\CommunityToolkit.Aspire.Hosting.Dapr.ServiceDefaults\CommunityToolkit.Aspire.Hosting.Dapr.ServiceDefaults.csproj", "{99441705-4BFA-499F-9897-371238665E38}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.GoFeatureFlag", "src\CommunityToolkit.Aspire.Hosting.GoFeatureFlag\CommunityToolkit.Aspire.Hosting.GoFeatureFlag.csproj", "{F926FB8A-77C6-4D39-AE44-9A7C11A3202D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "goff", "goff", "{002E2F54-BB99-41F2-98D5-D6CC3B6A2845}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost", "examples\goff\CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost\CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost.csproj", "{FE9F4F1A-1115-4F18-96D8-C8A4AD50CDA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults", "examples\goff\CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults\CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults.csproj", "{C4C113D8-7E40-4962-BBD8-735B478933D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService", "examples\goff\CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService\CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService.csproj", "{3CC919A7-3550-4046-99CF-BC164F817E63}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.GoFeatureFlag", "src\CommunityToolkit.Aspire.GoFeatureFlag\CommunityToolkit.Aspire.GoFeatureFlag.csproj", "{C40D5274-DACF-4962-A58A-F08BDA80D0E2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.GoFeatureFlag.Tests", "tests\CommunityToolkit.Aspire.GoFeatureFlag.Tests\CommunityToolkit.Aspire.GoFeatureFlag.Tests.csproj", "{F4891827-BE07-4547-AF89-2734FB8DE6D5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests", "tests\CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests\CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests.csproj", "{96458513-CAA8-498B-B4EE-3BF994140741}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -655,6 +671,34 @@ Global {99441705-4BFA-499F-9897-371238665E38}.Debug|Any CPU.Build.0 = Debug|Any CPU {99441705-4BFA-499F-9897-371238665E38}.Release|Any CPU.ActiveCfg = Release|Any CPU {99441705-4BFA-499F-9897-371238665E38}.Release|Any CPU.Build.0 = Release|Any CPU + {F926FB8A-77C6-4D39-AE44-9A7C11A3202D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F926FB8A-77C6-4D39-AE44-9A7C11A3202D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F926FB8A-77C6-4D39-AE44-9A7C11A3202D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F926FB8A-77C6-4D39-AE44-9A7C11A3202D}.Release|Any CPU.Build.0 = Release|Any CPU + {FE9F4F1A-1115-4F18-96D8-C8A4AD50CDA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE9F4F1A-1115-4F18-96D8-C8A4AD50CDA5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE9F4F1A-1115-4F18-96D8-C8A4AD50CDA5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE9F4F1A-1115-4F18-96D8-C8A4AD50CDA5}.Release|Any CPU.Build.0 = Release|Any CPU + {C4C113D8-7E40-4962-BBD8-735B478933D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4C113D8-7E40-4962-BBD8-735B478933D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4C113D8-7E40-4962-BBD8-735B478933D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4C113D8-7E40-4962-BBD8-735B478933D6}.Release|Any CPU.Build.0 = Release|Any CPU + {3CC919A7-3550-4046-99CF-BC164F817E63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CC919A7-3550-4046-99CF-BC164F817E63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CC919A7-3550-4046-99CF-BC164F817E63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CC919A7-3550-4046-99CF-BC164F817E63}.Release|Any CPU.Build.0 = Release|Any CPU + {C40D5274-DACF-4962-A58A-F08BDA80D0E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C40D5274-DACF-4962-A58A-F08BDA80D0E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C40D5274-DACF-4962-A58A-F08BDA80D0E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C40D5274-DACF-4962-A58A-F08BDA80D0E2}.Release|Any CPU.Build.0 = Release|Any CPU + {F4891827-BE07-4547-AF89-2734FB8DE6D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4891827-BE07-4547-AF89-2734FB8DE6D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4891827-BE07-4547-AF89-2734FB8DE6D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4891827-BE07-4547-AF89-2734FB8DE6D5}.Release|Any CPU.Build.0 = Release|Any CPU + {96458513-CAA8-498B-B4EE-3BF994140741}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96458513-CAA8-498B-B4EE-3BF994140741}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96458513-CAA8-498B-B4EE-3BF994140741}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96458513-CAA8-498B-B4EE-3BF994140741}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -780,6 +824,24 @@ Global {D2DDEA96-4A7E-496B-AFBE-69A133156C5F} = {E3C2B4B7-B3B0-4E7F-A975-A6C7FD926792} {5ADBE907-7E0B-4AD7-9073-C032C4183914} = {E3C2B4B7-B3B0-4E7F-A975-A6C7FD926792} {99441705-4BFA-499F-9897-371238665E38} = {E3C2B4B7-B3B0-4E7F-A975-A6C7FD926792} + {BEA41234-DFF9-49AE-AD6C-42A9D54202E7} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} + {6782F1C1-5146-549F-82A8-60C82F1C7F16} = {8519CC01-1370-47C8-AD94-B0F326B1563F} + {97E455C1-C914-4C51-87A9-2C213CE2ED5B} = {6782F1C1-5146-549F-82A8-60C82F1C7F16} + {5DF8F833-F6F8-4C9C-ABEC-80EC0C734A88} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} + {E48F6DDD-D62D-4723-810D-0F178C35E8B8} = {6782F1C1-5146-549F-82A8-60C82F1C7F16} + {DD7042A1-8E44-40A8-B338-DC2F7B755702} = {6782F1C1-5146-549F-82A8-60C82F1C7F16} + {E54E9DCA-1420-4306-83B6-D45D6EC49DBF} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} + {0E6EBCFB-DEF5-496C-95AF-00884826CFC8} = {899F0713-7FC6-4750-BAFC-AC650B35B453} + {861FE61C-90EE-49B0-BCC8-8417C293CC21} = {899F0713-7FC6-4750-BAFC-AC650B35B453} + {52846E18-99D1-4040-AF5F-17FC69198BCE} = {899F0713-7FC6-4750-BAFC-AC650B35B453} + {F926FB8A-77C6-4D39-AE44-9A7C11A3202D} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} + {002E2F54-BB99-41F2-98D5-D6CC3B6A2845} = {8519CC01-1370-47C8-AD94-B0F326B1563F} + {FE9F4F1A-1115-4F18-96D8-C8A4AD50CDA5} = {002E2F54-BB99-41F2-98D5-D6CC3B6A2845} + {C4C113D8-7E40-4962-BBD8-735B478933D6} = {002E2F54-BB99-41F2-98D5-D6CC3B6A2845} + {3CC919A7-3550-4046-99CF-BC164F817E63} = {002E2F54-BB99-41F2-98D5-D6CC3B6A2845} + {C40D5274-DACF-4962-A58A-F08BDA80D0E2} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} + {F4891827-BE07-4547-AF89-2734FB8DE6D5} = {899F0713-7FC6-4750-BAFC-AC650B35B453} + {96458513-CAA8-498B-B4EE-3BF994140741} = {899F0713-7FC6-4750-BAFC-AC650B35B453} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {08B1D4B8-D2C5-4A64-BB8B-E1A2B29525F0} diff --git a/Directory.Packages.props b/Directory.Packages.props index bfc3262e..69070d3a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + @@ -53,6 +54,7 @@ + diff --git a/README.md b/README.md index 7e08344b..34421955 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ This repository contains the source code for the .NET Aspire Community Toolkit, | - **Learn More**: [`Microsoft.EntityFrameworkCore.Sqlite`][sqlite-ef-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Microsoft.EntityFrameworkCore.Sqlite][sqlite-ef-shields]][sqlite-ef-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Microsoft.EntityFrameworkCore.Sqlite][sqlite-ef-shields-preview]][sqlite-ef-nuget-preview] | An Aspire client integration for the Microsoft.EntityFrameworkCore.Sqlite NuGet package. | | - **Learn More**: [`Hosting.Dapr`][dapr-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Dapr][dapr-shields]][dapr-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Dapr][dapr-shields-preview]][dapr-nuget-preview] | An Aspire hosting integration for Dapr. | | - **Learn More**: [`Hosting.Dapr.AzureRedis`][dapr-azureredis-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Dapr.AzureRedis][dapr-azureredis-shields]][dapr-azureredis-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Dapr.AzureRedis][dapr-azureredis-shields-preview]][dapr-azureredis-nuget-preview] | An extension for the Dapr hosting integration for using Dapr with Azure Redis cache. | +| - **Learn More**: [`Hosting.GoFeatureFlag`][go-feature-flag-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.GoFeatureFlag][go-feature-flag-shields]][go-feature-flag-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.GoFeatureFlag][go-feature-flag-shields-preview]][go-feature-flag-nuget-preview] | An Aspire hosting integration leveraging the [GoFeatureFlag](https://gofeatureflag.org/) container. | +| - **Learn More**: [`GoFeatureFlag`][go-feature-flag-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.GoFeatureFlag][go-feature-flag-client-shields]][go-feature-flag-client-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.GoFeatureFlag][go-feature-flag-client-shields-preview]][go-feature-flag-client-nuget-preview] | An Aspire client integration for the [GoFeatureFlag](https://github.com/open-feature/dotnet-sdk-contrib/tree/main/src/OpenFeature.Contrib.Providers.GOFeatureFlag) package. | ## 🙌 Getting Started @@ -182,3 +184,12 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) [dapr-azureredis-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Dapr.AzureRedis/ [dapr-azureredis-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Dapr.AzureRedis?label=nuget%20(preview) [dapr-azureredis-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Dapr.AzureRedis/absoluteLatest +[go-feature-flag-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-go-feature-flag +[go-feature-flag-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.GoFeatureFlag +[go-feature-flag-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/ +[go-feature-flag-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.GoFeatureFlag?label=nuget%20(preview) +[go-feature-flag-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/absoluteLatest +[go-feature-flag-client-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.GoFeatureFlag +[go-feature-flag-client-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.GoFeatureFlag/ +[go-feature-flag-client-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.GoFeatureFlag?label=nuget%20(preview) +[go-feature-flag-client-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.GoFeatureFlag/absoluteLatest diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService.csproj b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService.csproj new file mode 100644 index 00000000..30868a73 --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService.csproj @@ -0,0 +1,13 @@ + + + + enable + enable + + + + + + + + diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService.http b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService.http new file mode 100644 index 00000000..df3db550 --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService.http @@ -0,0 +1,6 @@ +@CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService_HostAddress = http://localhost:5100 + +GET {{CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/Program.cs b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/Program.cs new file mode 100644 index 00000000..3e1ad1a4 --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/Program.cs @@ -0,0 +1,30 @@ +using OpenFeature.Contrib.Providers.GOFeatureFlag; +using OpenFeature.Model; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddGoFeatureFlagClient("goff"); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Currently supported flags are: +// - `display-banner` +app.MapGet( + "/features/{featureName}", + async (string featureName, GoFeatureFlagProvider provider, CancellationToken cancellationToken) => + { + var userContext = EvaluationContext.Builder() + .Set("targetingKey", Guid.NewGuid().ToString()) + .Set("anonymous", true) + .Build(); + var flag = await provider.ResolveBooleanValueAsync(featureName, false, userContext, cancellationToken); + + return Results.Ok(flag); + }) + .WithName("GetFeature"); + +app.Run(); \ No newline at end of file diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/Properties/launchSettings.json b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000..8c119a07 --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7101;http://localhost:5100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/appsettings.json b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost.csproj b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost.csproj new file mode 100644 index 00000000..cb322efb --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost.csproj @@ -0,0 +1,21 @@ + + + + + Exe + enable + enable + true + 6f09f62a-98fa-44a3-a1ac-a3ae097e367f + + + + + + + + + + + + diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/Program.cs b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/Program.cs new file mode 100644 index 00000000..911ec40d --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/Program.cs @@ -0,0 +1,13 @@ +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +var goff = builder.AddGoFeatureFlag("goff") + .WithGoffBindMount("./goff"); + +builder.AddProject("apiservice") + .WithReference(goff) + .WaitFor(goff) + .WithHttpHealthCheck("/health"); + +builder.Build().Run(); \ No newline at end of file diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/Properties/launchSettings.json b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..f6132e4c --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17114;http://localhost:15226", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21156", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22092" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15226", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19237", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20145" + } + } + } +} diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/appsettings.json b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/goff/flags.yaml b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/goff/flags.yaml new file mode 100644 index 00000000..b3efd1f2 --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/goff/flags.yaml @@ -0,0 +1,6 @@ +display-banner: + variations: + enabled: true + disabled: false + defaultRule: + variation: enabled diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/goff/goff-proxy.yaml b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/goff/goff-proxy.yaml new file mode 100644 index 00000000..4a373a32 --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.AppHost/goff/goff-proxy.yaml @@ -0,0 +1,3 @@ +retrievers: + - kind: file + path: /goff/flags.yaml diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults.csproj b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults.csproj new file mode 100644 index 00000000..caa6344d --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults.csproj @@ -0,0 +1,21 @@ + + + + enable + enable + true + + + + + + + + + + + + + + + diff --git a/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults/Extensions.cs b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..359eaa17 --- /dev/null +++ b/examples/goff/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.ServiceDefaults/Extensions.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); + } + + return app; + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.GoFeatureFlag/AspireGoFeatureFlagExtensions.cs b/src/CommunityToolkit.Aspire.GoFeatureFlag/AspireGoFeatureFlagExtensions.cs new file mode 100644 index 00000000..d0974931 --- /dev/null +++ b/src/CommunityToolkit.Aspire.GoFeatureFlag/AspireGoFeatureFlagExtensions.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using CommunityToolkit.Aspire.GoFeatureFlag; +using HealthChecks.Uris; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using OpenFeature.Contrib.Providers.GOFeatureFlag; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for registering GO Feature Flag-related services in an . +/// +public static class AspireGoFeatureFlagExtensions +{ + private const string DefaultConfigSectionName = "Aspire:GoFeatureFlag:Client"; + + /// + /// Registers as a singleton in the services provided by the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// Reads the configuration from "Aspire:GoFeatureFlag:Client" section. + /// If required ConnectionString is not provided in configuration section + public static void AddGoFeatureFlagClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNullOrEmpty(connectionName); + AddGoFeatureFlagClient(builder, DefaultConfigSectionName, configureSettings, connectionName, serviceKey: null); + } + + /// + /// Registers as a keyed singleton for the given in the services provided by the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// Reads the configuration from "Aspire:GoFeatureFlag:Client" section. + /// If required ConnectionString is not provided in configuration section + public static void AddKeyedGoFeatureFlagClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNullOrEmpty(name); + AddGoFeatureFlagClient(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, connectionName: name, serviceKey: name); + } + + private static void AddGoFeatureFlagClient( + this IHostApplicationBuilder builder, + string configurationSectionName, + Action? configureSettings, + string connectionName, + string? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + + var settings = new GoFeatureFlagClientSettings(); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ParseConnectionString(connectionString); + } + + configureSettings?.Invoke(settings); + + if (settings.Endpoint is not null && string.IsNullOrEmpty(settings.ProviderOptions.Endpoint)) + { + settings.ProviderOptions.Endpoint = settings.Endpoint.ToString(); + } + + if (serviceKey is null) + { + builder.Services.AddSingleton((sp) => ConfigureGoFeatureFlagClient(settings.ProviderOptions)); + } + else + { + builder.Services.AddKeyedSingleton(serviceKey, (sp, key) => ConfigureGoFeatureFlagClient(settings.ProviderOptions)); + } + + if (settings is { DisableHealthChecks: false, Endpoint: not null }) + { + var healthCheckName = serviceKey is null ? "Goff" : $"Goff_{connectionName}"; + + var healthEndpoint = new Uri(settings.Endpoint, "/health"); + var uriHealthCheck = new UriHealthCheck( + new UriHealthCheckOptions().AddUri(healthEndpoint), + () => new HttpClient() + ); + + builder.TryAddHealthCheck(new HealthCheckRegistration( + healthCheckName, + sp => uriHealthCheck, + failureStatus: null, + tags: null, + timeout: settings.HealthCheckTimeout > 0 ? TimeSpan.FromMilliseconds(settings.HealthCheckTimeout.Value) : null + )); + } + + GoFeatureFlagProvider ConfigureGoFeatureFlagClient(GoFeatureFlagProviderOptions options) + { + if (settings.Endpoint is not null) + { + return new GoFeatureFlagProvider(options); + } + + throw new InvalidOperationException( + $"A GoFeatureFlagProvider could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or either " + + $"{nameof(settings.Endpoint)} must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } + } +} diff --git a/src/CommunityToolkit.Aspire.GoFeatureFlag/CommunityToolkit.Aspire.GoFeatureFlag.csproj b/src/CommunityToolkit.Aspire.GoFeatureFlag/CommunityToolkit.Aspire.GoFeatureFlag.csproj new file mode 100644 index 00000000..50727d32 --- /dev/null +++ b/src/CommunityToolkit.Aspire.GoFeatureFlag/CommunityToolkit.Aspire.GoFeatureFlag.csproj @@ -0,0 +1,21 @@ + + + + GoFeatureFlag client + A GO Feature Flag client that integrates with Aspire, including health checks, logging, and telemetry. + + + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.GoFeatureFlag/GoFeatureFlagClientSettings.cs b/src/CommunityToolkit.Aspire.GoFeatureFlag/GoFeatureFlagClientSettings.cs new file mode 100644 index 00000000..a99abe87 --- /dev/null +++ b/src/CommunityToolkit.Aspire.GoFeatureFlag/GoFeatureFlagClientSettings.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using OpenFeature.Contrib.Providers.GOFeatureFlag; +using System.Data.Common; + +namespace CommunityToolkit.Aspire.GoFeatureFlag; + +/// +/// Provides the client configuration settings for connecting to a GO Feature Flag server. +/// +public sealed class GoFeatureFlagClientSettings +{ + private const string ConnectionStringEndpoint = "Endpoint"; + + /// + /// The endpoint URI string of the GO Feature Flag server to connect to. + /// + public Uri? Endpoint { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the GO Feature Flag health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } + + /// + /// Gets or sets a integer value that indicates the GO Feature Flag health check timeout in milliseconds. + /// + public int? HealthCheckTimeout { get; set; } + + /// + /// Gets or sets the provider options that will be used to configure the GO Feature Flag client. + /// + public GoFeatureFlagProviderOptions ProviderOptions { get; set; } = new(); + + internal void ParseConnectionString(string? connectionString) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + Endpoint = uri; + } + else + { + var connectionBuilder = new DbConnectionStringBuilder + { + ConnectionString = connectionString + }; + + if (connectionBuilder.TryGetValue(ConnectionStringEndpoint, out var endpoint) && Uri.TryCreate(endpoint.ToString(), UriKind.Absolute, out var serviceUri)) + { + Endpoint = serviceUri; + } + } + } +} diff --git a/src/CommunityToolkit.Aspire.GoFeatureFlag/PublicAPI.Shipped.txt b/src/CommunityToolkit.Aspire.GoFeatureFlag/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/CommunityToolkit.Aspire.GoFeatureFlag/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/CommunityToolkit.Aspire.GoFeatureFlag/PublicAPI.Unshipped.txt b/src/CommunityToolkit.Aspire.GoFeatureFlag/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..e9538fa0 --- /dev/null +++ b/src/CommunityToolkit.Aspire.GoFeatureFlag/PublicAPI.Unshipped.txt @@ -0,0 +1,14 @@ +#nullable enable +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings.DisableHealthChecks.get -> bool +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings.DisableHealthChecks.set -> void +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings.Endpoint.get -> System.Uri? +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings.Endpoint.set -> void +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings.GoFeatureFlagClientSettings() -> void +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings.HealthCheckTimeout.get -> int? +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings.HealthCheckTimeout.set -> void +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings.ProviderOptions.get -> OpenFeature.Contrib.Providers.GOFeatureFlag.GoFeatureFlagProviderOptions! +CommunityToolkit.Aspire.GoFeatureFlag.GoFeatureFlagClientSettings.ProviderOptions.set -> void +Microsoft.Extensions.Hosting.AspireGoFeatureFlagExtensions +static Microsoft.Extensions.Hosting.AspireGoFeatureFlagExtensions.AddGoFeatureFlagClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! connectionName, System.Action? configureSettings = null) -> void +static Microsoft.Extensions.Hosting.AspireGoFeatureFlagExtensions.AddKeyedGoFeatureFlagClient(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, string! name, System.Action? configureSettings = null) -> void diff --git a/src/CommunityToolkit.Aspire.GoFeatureFlag/README.md b/src/CommunityToolkit.Aspire.GoFeatureFlag/README.md new file mode 100644 index 00000000..43d2d841 --- /dev/null +++ b/src/CommunityToolkit.Aspire.GoFeatureFlag/README.md @@ -0,0 +1,109 @@ +# CommunityToolkit.Aspire.GoFeatureFlag + +Registers a [GoFeatureFlagProvider](https://github.com/open-feature/dotnet-sdk-contrib/tree/main/src/OpenFeature.Contrib.Providers.GOFeatureFlag) in the DI container for connecting to a GO Feature Flag instance. + +## Getting started + +### Install the package + +Install the .NET Aspire GO Feature Flag Client library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.GoFeatureFlag +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddGoFeatureFlagClient` extension method to register a `GoFeatureFlagProvider` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddGoFeatureFlagClient("goff"); +``` + +## Configuration + +The .NET Aspire GO Feature Flag Client integration provides multiple options to configure the server connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddGoFeatureFlagClient()`: + +```csharp +builder.AddGoFeatureFlagClient("goff"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "goff": "Endpoint=http://localhost:19530/" + } +} +``` + +### Use configuration providers + +The .NET Aspire GO Feature Flag Client integration supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `GoFeatureFlagClientSettings` from configuration by using the `Aspire:GoFeatureFlag:Client` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "GoFeatureFlag": { + "Client": { + "Endpoint": "http://localhost:19530/", + "MasterKey": "123456!@#$%" + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to set the API key from code: + +```csharp +builder.AddGoFeatureFlagClient("goff", settings => settings.ProviderOptions.ApiKey = "123456!@#$%"); +``` + +## AppHost extensions + +In your AppHost project, install the `CommunityToolkit.Aspire.Hosting.GoFeatureFlag` library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.GoFeatureFlag +``` + +Then, in the _Program.cs_ file of `AppHost`, register a GO Feature Flag instance and consume the connection using the following methods: + +```csharp +var goff = builder.AddGoFeatureFlag("goff"); + +var myService = builder.AddProject() + .WithReference(goff); +``` + +The `WithReference` method configures a connection in the `MyService` project named `goff`. In the _Program.cs_ file of `MyService`, the GO Feature Flag connection can be consumed using: + +```csharp +builder.AddGoFeatureFlagClient("goff"); +``` + +Then, in your service, inject `GoFeatureFlagProvider` and use it to interact with the GO Feature Flag API: + +```csharp +public class MyService(GoFeatureFlagProvider goFeatureFlagProvider) +{ + // ... +} +``` + +## Additional documentation + +- https://github.com/thomaspoignant/go-feature-flag +- https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-go-feature-flag + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.csproj b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.csproj new file mode 100644 index 00000000..03f723c9 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.csproj @@ -0,0 +1,16 @@ + + + + hosting gofeatureflag + GO Feature Flag support for .NET Aspire. + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/GoFeatureFlagBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/GoFeatureFlagBuilderExtensions.cs new file mode 100644 index 00000000..ed6caa75 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/GoFeatureFlagBuilderExtensions.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; +using CommunityToolkit.Aspire.Hosting.GoFeatureFlag; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding GO Feature Flag resources to the application model. +/// +public static class GoFeatureFlagBuilderExtensions +{ + private const int GoFeatureFlagPort = 1031; + + /// + /// Adds an GO Feature Flag container resource to the application model. + /// The default image is and the tag is . + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The path set to find the configuration file (https://gofeatureflag.org/docs/relay-proxy/configure-relay-proxy#configuration-file). + /// The host port to bind the underlying container to. + /// A reference to the . + /// + /// + /// Add an GO Feature Flag container to the application model and reference it in a .NET project. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var goff = builder.AddGoFeatureFlag("goff"); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(goff); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder AddGoFeatureFlag( + this IDistributedApplicationBuilder builder, + string name, + string? pathToConfigFile = null, + int? port = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + + var goFeatureFlagResource = new GoFeatureFlagResource(name); + + string[] args = string.IsNullOrWhiteSpace(pathToConfigFile) + ? [] + : [$"--config={pathToConfigFile}"]; + + return builder.AddResource(goFeatureFlagResource) + .WithImage(GoFeatureFlagContainerImageTags.Image, GoFeatureFlagContainerImageTags.Tag) + .WithImageRegistry(GoFeatureFlagContainerImageTags.Registry) + .WithHttpEndpoint(targetPort: GoFeatureFlagPort, port: port, name: GoFeatureFlagResource.PrimaryEndpointName) + .WithHttpHealthCheck("/health") + .WithEntrypoint("/go-feature-flag") + .WithArgs(args) + .WithOtlpExporter(); + } + + /// + /// Adds a named volume for the data folder to a GO Feature flag container resource. + /// + /// The resource builder. + /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. + /// The . + /// + /// + /// Add a GO Feature flag container to the application model and reference it in a .NET project. Additionally, in this + /// example a data volume is added to the container to allow data to be persisted across container restarts. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var goff = builder.AddGoFeatureFlag("goff") + /// .WithDataVolume(); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(goff); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null) + { + ArgumentNullException.ThrowIfNull(builder); + +#pragma warning disable CTASPIRE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + return builder.WithVolume(name ?? VolumeNameGenerator.CreateVolumeName(builder, "data"), "/goff_data"); +#pragma warning restore CTASPIRE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + } + + /// + /// Adds a bind mount for the goff configuration folder to a GO Feature flag container resource. + /// + /// The resource builder. + /// The source directory on the host to mount into the container. + /// The . + /// + /// + /// Add a GO Feature flag container to the application model and reference it in a .NET project. Additionally, in this + /// example a bind mount is added to the container to allow reading goff configuration. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var goff = builder.AddGoFeatureFlag("goff") + /// .WithGoffBindMount("./goff"); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(goff); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithGoffBindMount(this IResourceBuilder builder, string source) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + + return builder.WithBindMount(source, "/goff"); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/GoFeatureFlagContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/GoFeatureFlagContainerImageTags.cs new file mode 100644 index 00000000..6c196b0f --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/GoFeatureFlagContainerImageTags.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CommunityToolkit.Aspire.Hosting.GoFeatureFlag; + +internal static class GoFeatureFlagContainerImageTags +{ + /// docker.io + public const string Registry = "docker.io"; + /// gofeatureflag/go-feature-flag + public const string Image = "gofeatureflag/go-feature-flag"; + /// v1.41 + public const string Tag = "v1.41"; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/GoFeatureFlagResource.cs b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/GoFeatureFlagResource.cs new file mode 100644 index 00000000..5d994ddb --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/GoFeatureFlagResource.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a GO Feature Flag instance +/// +public class GoFeatureFlagResource(string name) : ContainerResource(name), IResourceWithConnectionString +{ + internal const string PrimaryEndpointName = "http"; + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the primary endpoint for the GO Feature Flag instance. + /// This endpoint is used for all API calls over HTTP. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); + + /// + /// Gets the connection string expression for the GO Feature Flag instance. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"Endpoint=http://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}"); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/PublicAPI.Shipped.txt b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/PublicAPI.Unshipped.txt b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..f04bd19d --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/PublicAPI.Unshipped.txt @@ -0,0 +1,9 @@ +#nullable enable +Aspire.Hosting.ApplicationModel.GoFeatureFlagResource +Aspire.Hosting.ApplicationModel.GoFeatureFlagResource.ConnectionStringExpression.get -> Aspire.Hosting.ApplicationModel.ReferenceExpression! +Aspire.Hosting.ApplicationModel.GoFeatureFlagResource.GoFeatureFlagResource(string! name) -> void +Aspire.Hosting.ApplicationModel.GoFeatureFlagResource.PrimaryEndpoint.get -> Aspire.Hosting.ApplicationModel.EndpointReference! +Aspire.Hosting.GoFeatureFlagBuilderExtensions +static Aspire.Hosting.GoFeatureFlagBuilderExtensions.AddGoFeatureFlag(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string? pathToConfigFile = null, int? port = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.GoFeatureFlagBuilderExtensions.WithDataVolume(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string? name = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.GoFeatureFlagBuilderExtensions.WithGoffBindMount(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! source) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/README.md b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/README.md new file mode 100644 index 00000000..c7c6fdc0 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.GoFeatureFlag/README.md @@ -0,0 +1,36 @@ +# CommunityToolkit.Aspire.Hosting.GoFeatureFlag library + +Provides extension methods and resource definitions for the .NET Aspire AppHost to support running [GO Feature Flag](https://gofeatureflag.org/) containers. + +## Getting Started + +### Install the package + +In your AppHost project, install the package using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.GoFeatureFlag +``` + +### Example usage + +Then, in the _Program.cs_ file of `AppHost`, add a GO Feature Flag resource and consume the connection using the following methods: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var goff = builder.AddGoFeatureFlag("goff"); + +var myService = builder.AddProject() + .WithReference(goff); + +builder.Build().Run(); +``` + +## Additional Information + +https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-go-feature-flag + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/AspireGoFeatureFlagClientExtensionsTest.cs b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/AspireGoFeatureFlagClientExtensionsTest.cs new file mode 100644 index 00000000..4d07a5cf --- /dev/null +++ b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/AspireGoFeatureFlagClientExtensionsTest.cs @@ -0,0 +1,139 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using OpenFeature.Contrib.Providers.GOFeatureFlag; + +namespace CommunityToolkit.Aspire.GoFeatureFlag.Tests; + +public class AspireGoFeatureFlagClientExtensionsTest(GoFeatureFlagContainerFixture containerFixture) : IClassFixture +{ + private const string DefaultConnectionName = "goff"; + + private string DefaultConnectionString => + RequiresDockerAttribute.IsSupported ? containerFixture.GetConnectionString() : "http://localhost:27011"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + [RequiresDocker] + public async Task AddGoFeatureFlagClient_HealthCheckShouldBeRegisteredWhenEnabled(bool useKeyed) + { + var key = DefaultConnectionName; + + var builder = CreateBuilder(DefaultConnectionString); + + if (useKeyed) + { + builder.AddKeyedGoFeatureFlagClient(key, settings => + { + settings.DisableHealthChecks = false; + }); + } + else + { + builder.AddGoFeatureFlagClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = false; + }); + } + + using var host = builder.Build(); + + var healthCheckService = host.Services.GetRequiredService(); + + var healthCheckReport = await healthCheckService.CheckHealthAsync(); + + var healthCheckName = useKeyed ? $"Goff_{key}" : "Goff"; + + Assert.Contains(healthCheckReport.Entries, x => x.Key == healthCheckName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddGoFeatureFlagClient_HealthCheckShouldNotBeRegisteredWhenDisabled(bool useKeyed) + { + var builder = CreateBuilder(DefaultConnectionString); + + if (useKeyed) + { + builder.AddKeyedGoFeatureFlagClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = true; + }); + } + else + { + builder.AddGoFeatureFlagClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = true; + }); + } + + using var host = builder.Build(); + + var healthCheckService = host.Services.GetService(); + + Assert.Null(healthCheckService); + } + + [Fact] + public void CanAddMultipleKeyedServices() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:goff1", "http://localhost:19530"), + new KeyValuePair("ConnectionStrings:goff2", "http://localhost:19531"), + new KeyValuePair("ConnectionStrings:goff3", "http://localhost:19532"), + ]); + + builder.AddGoFeatureFlagClient("goff1"); + builder.AddKeyedGoFeatureFlagClient("goff2"); + builder.AddKeyedGoFeatureFlagClient("goff3"); + + using var host = builder.Build(); + + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredKeyedService("goff2"); + var client3 = host.Services.GetRequiredKeyedService("goff3"); + + Assert.NotSame(client1, client2); + Assert.NotSame(client1, client3); + Assert.NotSame(client2, client3); + } + + [Fact] + public void CanAddClientFromEncodedConnectionString() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:goff1", "Endpoint=http://localhost:19530"), + new KeyValuePair("ConnectionStrings:goff2", "Endpoint=http://localhost:19531"), + ]); + + builder.AddGoFeatureFlagClient("goff1"); + builder.AddKeyedGoFeatureFlagClient("goff2"); + + using var host = builder.Build(); + + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredKeyedService("goff2"); + + Assert.NotSame(client1, client2); + } + + private static HostApplicationBuilder CreateBuilder(string connectionString) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair($"ConnectionStrings:{DefaultConnectionName}", connectionString) + ]); + return builder; + } +} diff --git a/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests.csproj b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests.csproj new file mode 100644 index 00000000..4fb03094 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/ConfigurationTests.cs b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/ConfigurationTests.cs new file mode 100644 index 00000000..4c66dc24 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/ConfigurationTests.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CommunityToolkit.Aspire.GoFeatureFlag.Tests; + +public class ConfigurationTests +{ + [Fact] + public void EndpointIsNullByDefault() => + Assert.Null(new GoFeatureFlagClientSettings().Endpoint); + + [Fact] + public void HealthChecksEnabledByDefault() => + Assert.False(new GoFeatureFlagClientSettings().DisableHealthChecks); +} diff --git a/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/ConformanceTests.cs b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/ConformanceTests.cs new file mode 100644 index 00000000..e5942de1 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/ConformanceTests.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Components.ConformanceTests; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using OpenFeature.Contrib.Providers.GOFeatureFlag; +using OpenFeature.Model; + +namespace CommunityToolkit.Aspire.GoFeatureFlag.Tests; + +public class ConformanceTests : ConformanceTests, IClassFixture +{ + private readonly GoFeatureFlagContainerFixture _containerFixture; + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => string.Empty; + + protected override string[] RequiredLogCategories => []; + + protected override bool CanConnectToServer => RequiresDockerAttribute.IsSupported; + + protected override bool SupportsKeyedRegistrations => true; + + public ConformanceTests(GoFeatureFlagContainerFixture containerFixture) + { + _containerFixture = containerFixture; + } + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + { + var connectionString = RequiresDockerAttribute.IsSupported ? + $"{_containerFixture.GetConnectionString()}" : + "Endpoint=http://localhost:27017"; + + configuration.AddInMemoryCollection( + [ + new KeyValuePair(CreateConfigKey("Aspire:GoFeatureFlag:Client", key, "Endpoint"), GetConnectionStringKeyValue(connectionString,"Endpoint")), + new KeyValuePair($"ConnectionStrings:{key ?? "goff"}", $"{connectionString}") + ]); + } + + internal static string GetConnectionStringKeyValue(string connectionString, string configKey) + { + // from the connection string, extract the key value of the configKey + var parts = connectionString.Split(';'); + foreach (var part in parts) + { + var keyValue = part.Split('='); + if (keyValue.Length == 2 && keyValue[0].Equals(configKey, StringComparison.OrdinalIgnoreCase)) + { + return keyValue[1]; + } + } + return string.Empty; + } + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddGoFeatureFlagClient("goff", configureSettings: configure); + } + else + { + builder.AddKeyedGoFeatureFlagClient(key, configureSettings: configure); + } + } + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "GoFeatureFlag": { + "Client": { + "Endpoint": "http://localhost:19530" + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "GoFeatureFlag":{ "Client": { "Endpoint": 3 }}}}""", "Value is \"integer\" but should be \"string\""), + ("""{"Aspire": { "GoFeatureFlag":{ "Client": { "Endpoint": "hello" }}}}""", "Value does not match format \"uri\"") + }; + + protected override void SetHealthCheck(GoFeatureFlagClientSettings options, bool enabled) + { + options.DisableHealthChecks = !enabled; + } + + protected override void SetMetrics(GoFeatureFlagClientSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void SetTracing(GoFeatureFlagClientSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void TriggerActivity(GoFeatureFlagProvider service) + { + using var source = new CancellationTokenSource(100); + + var context = EvaluationContext.Builder() + .Set("targetingKey", Guid.NewGuid().ToString()) + .Set("anonymous", true) + .Build(); + service.InitializeAsync(context, source.Token).Wait(); + } +} diff --git a/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/GoFeatureFlagClientPublicApiTests.cs b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/GoFeatureFlagClientPublicApiTests.cs new file mode 100644 index 00000000..8ded4145 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/GoFeatureFlagClientPublicApiTests.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.GoFeatureFlag.Tests; + +public class GoFeatureFlagClientPublicApiTests +{ + [Fact] + public void AddGoFeatureFlagClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + var connectionName = "goff"; + + var action = () => builder.AddGoFeatureFlagClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddGoFeatureFlagClientShouldThrowWhenNameIsNull() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string connectionName = null!; + + var action = () => builder.AddGoFeatureFlagClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(connectionName), exception.ParamName); + } + + [Fact] + public void AddGoFeatureFlagClientShouldThrowWhenNameIsEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string connectionName = ""; + + var action = () => builder.AddGoFeatureFlagClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(connectionName), exception.ParamName); + } + + [Fact] + public void AddKeyedGoFeatureFlagClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + var connectionName = "goff"; + + var action = () => builder.AddKeyedGoFeatureFlagClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddKeyedGoFeatureFlagClientShouldThrowWhenNameIsNull() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string name = null!; + + var action = () => builder.AddKeyedGoFeatureFlagClient(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void AddKeyedGoFeatureFlagClientShouldThrowWhenNameIsEmpty() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + string name = ""; + + var action = () => builder.AddKeyedGoFeatureFlagClient(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } +} diff --git a/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/GoFeatureFlagContainerFixture.cs b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/GoFeatureFlagContainerFixture.cs new file mode 100644 index 00000000..591ece83 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/GoFeatureFlagContainerFixture.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using CommunityToolkit.Aspire.Hosting.GoFeatureFlag; +using Aspire.Components.Common.Tests; +using Aspire.Hosting; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +namespace CommunityToolkit.Aspire.GoFeatureFlag.Tests; + +public sealed class GoFeatureFlagContainerFixture : IAsyncLifetime +{ + public IContainer? Container { get; private set; } + public string GetConnectionString() + { + if (Container is null) + { + throw new InvalidOperationException("The test container was not initialized."); + } + var endpoint = new UriBuilder("http", Container.Hostname, Container.GetMappedPublicPort(1031)).ToString(); + return $"Endpoint={endpoint}"; + } + + public async Task InitializeAsync() + { + if (RequiresDockerAttribute.IsSupported) + { + var source = Path.GetFullPath("./goff", Directory.GetCurrentDirectory()); + Container = new ContainerBuilder() + .WithImage($"{GoFeatureFlagContainerImageTags.Registry}/{GoFeatureFlagContainerImageTags.Image}:{GoFeatureFlagContainerImageTags.Tag}") + .WithPortBinding(1031, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/health").ForPort(1031))) + .WithBindMount(source, "/goff") + .Build(); + + await Container.StartAsync(); + } + } + + public async Task DisposeAsync() + { + if (Container is not null) + { + await Container.DisposeAsync(); + } + } +} diff --git a/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/goff/flags.yaml b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/goff/flags.yaml new file mode 100644 index 00000000..b3efd1f2 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/goff/flags.yaml @@ -0,0 +1,6 @@ +display-banner: + variations: + enabled: true + disabled: false + defaultRule: + variation: enabled diff --git a/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/goff/goff-proxy.yaml b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/goff/goff-proxy.yaml new file mode 100644 index 00000000..4a373a32 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.GoFeatureFlag.Tests/goff/goff-proxy.yaml @@ -0,0 +1,3 @@ +retrievers: + - kind: file + path: /goff/flags.yaml diff --git a/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/AddGoFeatureFlagTests.cs b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/AddGoFeatureFlagTests.cs new file mode 100644 index 00000000..c2ff27e2 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/AddGoFeatureFlagTests.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests; + +public class AddGoFeatureFlagTests +{ + [Fact] + public void AddGoFeatureFlagContainerWithDefaultsAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + var goff = appBuilder.AddGoFeatureFlag("goff"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("goff", containerResource.Name); + + var endpoints = containerResource.Annotations.OfType(); + Assert.Single(endpoints); + + var primaryEndpoint = Assert.Single(endpoints, e => e.Name == "http"); + Assert.Equal(1031, primaryEndpoint.TargetPort); + Assert.False(primaryEndpoint.IsExternal); + Assert.Equal("http", primaryEndpoint.Name); + Assert.Null(primaryEndpoint.Port); + Assert.Equal(ProtocolType.Tcp, primaryEndpoint.Protocol); + Assert.Equal("http", primaryEndpoint.Transport); + Assert.Equal("http", primaryEndpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(GoFeatureFlagContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(GoFeatureFlagContainerImageTags.Image, containerAnnotation.Image); + Assert.Equal(GoFeatureFlagContainerImageTags.Registry, containerAnnotation.Registry); + } + + [Fact] + public void AddGoFeatureFlagContainerAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var goff = appBuilder.AddGoFeatureFlag("goff"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("goff", containerResource.Name); + + var endpoints = containerResource.Annotations.OfType(); + Assert.Single(endpoints); + + var primaryEndpoint = Assert.Single(endpoints, e => e.Name == "http"); + Assert.Equal(1031, primaryEndpoint.TargetPort); + Assert.False(primaryEndpoint.IsExternal); + Assert.Equal("http", primaryEndpoint.Name); + Assert.Null(primaryEndpoint.Port); + Assert.Equal(ProtocolType.Tcp, primaryEndpoint.Protocol); + Assert.Equal("http", primaryEndpoint.Transport); + Assert.Equal("http", primaryEndpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(GoFeatureFlagContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(GoFeatureFlagContainerImageTags.Image, containerAnnotation.Image); + Assert.Equal(GoFeatureFlagContainerImageTags.Registry, containerAnnotation.Registry); + } + + [Fact] + public async Task GoFeatureFlagCreatesConnectionString() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var goff = appBuilder + .AddGoFeatureFlag("goff") + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 27020)); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()) as IResourceWithConnectionString; + var connectionString = await connectionStringResource.GetConnectionStringAsync(); + + Assert.Equal($"Endpoint=http://localhost:27020", connectionString); + Assert.Equal("Endpoint=http://{goff.bindings.http.host}:{goff.bindings.http.port}", connectionStringResource.ConnectionStringExpression.ValueExpression); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/AppHostTests.cs new file mode 100644 index 00000000..35820681 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/AppHostTests.cs @@ -0,0 +1,39 @@ +using CommunityToolkit.Aspire.Testing; +using Aspire.Components.Common.Tests; +using OpenFeature.Model; +using System.Net.Http.Json; + +namespace CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests; + +[RequiresDocker] +public class AppHostTests(AspireIntegrationTestFixture fixture) : IClassFixture> +{ + [Fact] + public async Task ResourceStartsAndRespondsOk() + { + var resourceName = "goff"; + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(5)); + var httpClient = fixture.CreateHttpClient(resourceName); + + var response = await httpClient.GetAsync("/info"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ApiServiceRetrieveFlags() + { + var resourceName = "apiservice"; + + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync("goff").WaitAsync(TimeSpan.FromMinutes(5)); + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(5)); + var httpClient = fixture.CreateHttpClient(resourceName); + + var getResponse = await httpClient.GetAsync("/features/display-banner"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var data = await getResponse.Content.ReadFromJsonAsync>(); + Assert.NotNull(data); + Assert.True(data.Value); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests.csproj new file mode 100644 index 00000000..7277d71e --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/GoFeatureFlagFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/GoFeatureFlagFunctionalTests.cs new file mode 100644 index 00000000..24e5d9d0 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/GoFeatureFlagFunctionalTests.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Hosting; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Xunit.Abstractions; +using Aspire.Hosting.Utils; +using CommunityToolkit.Aspire.Testing; +using OpenFeature.Contrib.Providers.GOFeatureFlag; +using OpenFeature.Model; + +namespace CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests; + +[RequiresDocker] +public class GoFeatureFlagFunctionalTests(ITestOutputHelper testOutputHelper) +{ + private static readonly string SOURCE = Path.GetFullPath("./goff", Directory.GetCurrentDirectory()); + + [Fact] + public async Task VerifyGoFeatureFlagResource() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var goff = builder.AddGoFeatureFlag("goff") + .WithBindMount(SOURCE, "/goff"); + + using var app = builder.Build(); + + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceHealthyAsync(goff.Resource.Name); + + var hb = Host.CreateApplicationBuilder(); + + hb.Configuration[$"ConnectionStrings:{goff.Resource.Name}"] = await goff.Resource.ConnectionStringExpression.GetValueAsync(default); + + hb.AddGoFeatureFlagClient(goff.Resource.Name); + + using var host = hb.Build(); + + await host.StartAsync(); + + var goFeatureFlagProvider = host.Services.GetRequiredService(); + + await VerifyTestData(goFeatureFlagProvider); + } + + [Fact] + public async Task VerifyWaitForOnGoFeatureFlagBlocksDependentResources() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var healthCheckTcs = new TaskCompletionSource(); + builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => + { + return healthCheckTcs.Task; + }); + + var resource = builder.AddGoFeatureFlag("resource") + .WithBindMount(SOURCE, "/goff") + .WithHealthCheck("blocking_check"); + + var dependentResource = builder.AddGoFeatureFlag("dependentresource") + .WithBindMount(SOURCE, "/goff") + .WaitFor(resource); + + using var app = builder.Build(); + + var pendingStart = app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); + + healthCheckTcs.SetResult(HealthCheckResult.Healthy()); + + await rns.WaitForResourceAsync(resource.Resource.Name, re => re.Snapshot.HealthStatus == HealthStatus.Healthy, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await pendingStart; + + await app.StopAsync(); + } + + private static async Task VerifyTestData(GoFeatureFlagProvider goFeatureFlagProvider) + { + var userContext = EvaluationContext.Builder() + .Set("targetingKey", Guid.NewGuid().ToString()) + .Set("anonymous", true) + .Build(); + string featureName = "display-banner"; + var flag = await goFeatureFlagProvider.ResolveBooleanValueAsync( + featureName, false, userContext + ); + + Assert.NotNull(flag); + Assert.True(flag.Value); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/GoFeatureFlagPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/GoFeatureFlagPublicApiTests.cs new file mode 100644 index 00000000..b73a932f --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/GoFeatureFlagPublicApiTests.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests; + +public class GoFeatureFlagPublicApiTests +{ + [Fact] + public void AddGoFeatureFlagContainerShouldThrowWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + const string name = "Goff"; + + var action = () => builder.AddGoFeatureFlag(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddGoFeatureFlagContainerShouldThrowWhenNameIsNull() + { + IDistributedApplicationBuilder builder = new DistributedApplicationBuilder([]); + string name = null!; + + var action = () => builder.AddGoFeatureFlag(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void WithGoffBindMountShouldThrowWhenBuilderIsNull(bool useVolume) + { + IResourceBuilder builder = null!; + + Func>? action = null; + + if (useVolume) + { + action = () => builder.WithDataVolume(); + } + else + { + const string source = "/data"; + + action = () => builder.WithGoffBindMount(source); + } + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithGoffBindMountShouldThrowWhenSourceIsNull() + { + var builder = new DistributedApplicationBuilder([]); + var resourceBuilder = builder.AddGoFeatureFlag("Goff"); + + string source = null!; + + var action = () => resourceBuilder.WithGoffBindMount(source); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(source), exception.ParamName); + } + + [Fact] + public void CtorGoFeatureFlagResourceShouldThrowWhenNameIsNull() + { + const string name = null!; + + var action = () => new GoFeatureFlagResource(name!); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/goff/flags.yaml b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/goff/flags.yaml new file mode 100644 index 00000000..b3efd1f2 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/goff/flags.yaml @@ -0,0 +1,6 @@ +display-banner: + variations: + enabled: true + disabled: false + defaultRule: + variation: enabled diff --git a/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/goff/goff-proxy.yaml b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/goff/goff-proxy.yaml new file mode 100644 index 00000000..4a373a32 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.GoFeatureFlag.Tests/goff/goff-proxy.yaml @@ -0,0 +1,3 @@ +retrievers: + - kind: file + path: /goff/flags.yaml