From 557a0989e90ea817d411161e1acca20d03528f97 Mon Sep 17 00:00:00 2001 From: Derek McKinnon Date: Thu, 7 Dec 2023 18:44:50 -0500 Subject: [PATCH] Custom Provider (#1) --- .github/workflows/release.yml | 6 +- .github/workflows/run-tests.yml | 4 +- README.md | 3 +- .../AppConfigConfigurationProvider.cs | 117 ++++++++++++++++ ...ppConfigConfigurationProviderExtensions.cs | 44 ++++-- .../AppConfigConfigurationSource.cs | 18 +++ ...sult.AppConfigConfigurationProvider.csproj | 5 +- ...ppConfigConfigurationProviderExtensions.cs | 10 -- .../AppConfigConfigurationProviderTests.cs | 130 ++++++++++++++++++ .../AppConfigProfileParserTests.cs | 9 +- ...ppConfigConfigurationProvider.Tests.csproj | 11 +- 11 files changed, 311 insertions(+), 46 deletions(-) create mode 100644 src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationProvider.cs create mode 100644 src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationSource.cs delete mode 100644 src/CatConsult.AppConfigConfigurationProvider/Extensions/AppConfigConfigurationProviderExtensions.cs create mode 100644 test/CatConsult.AppConfigConfigurationProvider.Tests/AppConfigConfigurationProviderTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4bdfd8..881ecc3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,14 +8,14 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v3 + - uses: actions/setup-dotnet@v4 with: global-json-file: ./global.json - run: dotnet restore - - run: dotnet build --no-restore + - run: dotnet build --no-restore --configuration Release - run: | dotnet pack src/CatConsult.AppConfigConfigurationProvider \ diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a830f3e..216d105 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -6,9 +6,9 @@ jobs: dotnet-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v3 + - uses: actions/setup-dotnet@v4 with: global-json-file: ./global.json diff --git a/README.md b/README.md index da9320a..6dadac3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # AWS AppConfig Configuration Provider for .NET -An opinionated [.NET Configuration Provider](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration-providers) -for AWS AppConfig that wraps [Amazon.Extensions.Configuration.SystemsManager](https://www.nuget.org/packages/Amazon.Extensions.Configuration.SystemsManager/). +An opinionated [.NET Configuration Provider](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration-providers) for AWS AppConfig. ## Usage diff --git a/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationProvider.cs b/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationProvider.cs new file mode 100644 index 0000000..ac2a814 --- /dev/null +++ b/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationProvider.cs @@ -0,0 +1,117 @@ +using Amazon.AppConfigData; +using Amazon.AppConfigData.Model; + +using CatConsult.ConfigurationParsers; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace CatConsult.AppConfigConfigurationProvider; + +public sealed class AppConfigConfigurationProvider : ConfigurationProvider, IDisposable +{ + private const int LockReleaseTimeout = 3_000; + + private readonly IAmazonAppConfigData _client; + private readonly AppConfigProfile _profile; + private readonly SemaphoreSlim _lock; + + private IDisposable? _reloadChangeToken; + + public AppConfigConfigurationProvider(IAmazonAppConfigData client, AppConfigProfile profile) + { + _profile = profile; + _client = client; + _lock = new SemaphoreSlim(1, 1); + } + + public AppConfigConfigurationProvider(AppConfigProfile profile) : this(new AmazonAppConfigDataClient(), profile) { } + + private string? ConfigurationToken { get; set; } + + private DateTimeOffset NextPollingTime { get; set; } + + public override void Load() + { + LoadAsync().GetAwaiter().GetResult(); + + if (_profile.ReloadAfter.HasValue) + { + _reloadChangeToken = ChangeToken.OnChange( + () => new CancellationChangeToken( + new CancellationTokenSource(_profile.ReloadAfter.Value).Token + ), + Load + ); + } + } + + private async Task LoadAsync() + { + if (!await _lock.WaitAsync(LockReleaseTimeout)) + { + return; + } + + try + { + if (DateTimeOffset.UtcNow < NextPollingTime) + { + return; + } + + if (string.IsNullOrEmpty(ConfigurationToken)) + { + await InitializeAppConfigSessionAsync(); + } + + var request = new GetLatestConfigurationRequest + { + ConfigurationToken = ConfigurationToken + }; + + var response = await _client.GetLatestConfigurationAsync(request); + ConfigurationToken = response.NextPollConfigurationToken; + NextPollingTime = DateTimeOffset.UtcNow.AddSeconds(response.NextPollIntervalInSeconds); + + // If the remote configuration has changed, the API will send back data and we re-parse + if (response.ContentLength > 0) + { + Data = ParseConfig(response.Configuration, response.ContentType); + } + } + finally + { + _lock.Release(); + } + } + + private async Task InitializeAppConfigSessionAsync() + { + var session = await _client.StartConfigurationSessionAsync(new StartConfigurationSessionRequest + { + ApplicationIdentifier = _profile.ApplicationId, + EnvironmentIdentifier = _profile.EnvironmentId, + ConfigurationProfileIdentifier = _profile.ProfileId, + }); + + ConfigurationToken = session.InitialConfigurationToken; + } + + private static IDictionary ParseConfig(Stream stream, string? contentType) + { + if (!string.IsNullOrEmpty(contentType)) + { + contentType = contentType.Split(";")[0]; + } + + return contentType switch + { + "application/json" => JsonConfigurationParser.Parse(stream), + "application/x-yaml" => YamlConfigurationParser.Parse(stream), + _ => throw new FormatException($"This configuration provider does not support: {contentType ?? "Unknown"}") + }; + } + + public void Dispose() => _reloadChangeToken?.Dispose(); +} \ No newline at end of file diff --git a/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationProviderExtensions.cs b/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationProviderExtensions.cs index 68bdf66..1ede402 100644 --- a/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationProviderExtensions.cs +++ b/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationProviderExtensions.cs @@ -1,37 +1,51 @@ +using Amazon.AppConfigData; + using CatConsult.AppConfigConfigurationProvider.Utilities; using Microsoft.Extensions.Configuration; namespace CatConsult.AppConfigConfigurationProvider; +// ReSharper disable UnusedType.Global +// ReSharper disable UnusedMember.Global +// ReSharper disable MemberCanBePrivate.Global public static class AppConfigConfigurationProviderExtensions { + public const string DefaultSectionName = "AppConfig"; + public static IConfigurationBuilder AddAppConfig( this IConfigurationBuilder builder, - string sectionName = "AppConfig" + string sectionName = DefaultSectionName ) { - var options = builder.Build() - .GetSection(sectionName) - .Get(); - - if (options is null) + foreach (var profile in LoadProfiles(builder, sectionName)) { - return builder; + builder.Add(new AppConfigConfigurationSource(profile)); } - var profiles = options.Profiles.Select(AppConfigProfileParser.Parse); + return builder; + } - foreach (var profile in profiles) + public static IConfigurationBuilder AddAppConfig( + this IConfigurationBuilder builder, + IAmazonAppConfigData client, + string sectionName = DefaultSectionName + ) + { + foreach (var profile in LoadProfiles(builder, sectionName)) { - builder.AddAppConfig( - profile.ApplicationId, - profile.EnvironmentId, - profile.ProfileId, - TimeSpan.FromSeconds(profile.ReloadAfter ?? options.Defaults.ReloadAfter) - ); + builder.Add(new AppConfigConfigurationSource(client, profile)); } return builder; } + + private static IEnumerable LoadProfiles(IConfigurationBuilder builder, string sectionName) + { + var options = builder.Build() + .GetSection(sectionName) + .Get() ?? new AppConfigOptions(); + + return options.Profiles.Select(AppConfigProfileParser.Parse); + } } diff --git a/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationSource.cs b/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationSource.cs new file mode 100644 index 0000000..23c1f20 --- /dev/null +++ b/src/CatConsult.AppConfigConfigurationProvider/AppConfigConfigurationSource.cs @@ -0,0 +1,18 @@ +using Amazon.AppConfigData; + +using Microsoft.Extensions.Configuration; + +namespace CatConsult.AppConfigConfigurationProvider; + +public sealed class AppConfigConfigurationSource : IConfigurationSource +{ + private readonly AppConfigConfigurationProvider _provider; + + public AppConfigConfigurationSource(IAmazonAppConfigData client, AppConfigProfile profile) => + _provider = new AppConfigConfigurationProvider(client, profile); + + public AppConfigConfigurationSource(AppConfigProfile profile) => + _provider = new AppConfigConfigurationProvider(profile); + + public IConfigurationProvider Build(IConfigurationBuilder builder) => _provider; +} \ No newline at end of file diff --git a/src/CatConsult.AppConfigConfigurationProvider/CatConsult.AppConfigConfigurationProvider.csproj b/src/CatConsult.AppConfigConfigurationProvider/CatConsult.AppConfigConfigurationProvider.csproj index 524ecd5..f2a6017 100644 --- a/src/CatConsult.AppConfigConfigurationProvider/CatConsult.AppConfigConfigurationProvider.csproj +++ b/src/CatConsult.AppConfigConfigurationProvider/CatConsult.AppConfigConfigurationProvider.csproj @@ -12,7 +12,7 @@ Configuration - An opinionated .NET Configuration Provider for AWS AppConfig that wraps Amazon.Extensions.Configuration.SystemsManager + An opinionated .NET Configuration Provider for AWS AppConfig Derek McKinnon Catalyst Consulting Group, Inc. @@ -22,7 +22,8 @@ - + + diff --git a/src/CatConsult.AppConfigConfigurationProvider/Extensions/AppConfigConfigurationProviderExtensions.cs b/src/CatConsult.AppConfigConfigurationProvider/Extensions/AppConfigConfigurationProviderExtensions.cs deleted file mode 100644 index 42c895b..0000000 --- a/src/CatConsult.AppConfigConfigurationProvider/Extensions/AppConfigConfigurationProviderExtensions.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace CatConsult.AppConfigConfigurationProvider.Extensions; - -public static class AppConfigConfigurationProviderExtensions -{ - [Obsolete("Use CatConsult.AppConfigConfigurationProvider instead - this will be removed in a future version")] - public static IConfigurationBuilder AddAppConfig(this IConfigurationBuilder builder, string sectionName = "AppConfig") => - AppConfigConfigurationProvider.AppConfigConfigurationProviderExtensions.AddAppConfig(builder, sectionName); -} diff --git a/test/CatConsult.AppConfigConfigurationProvider.Tests/AppConfigConfigurationProviderTests.cs b/test/CatConsult.AppConfigConfigurationProvider.Tests/AppConfigConfigurationProviderTests.cs new file mode 100644 index 0000000..2a1456e --- /dev/null +++ b/test/CatConsult.AppConfigConfigurationProvider.Tests/AppConfigConfigurationProviderTests.cs @@ -0,0 +1,130 @@ +using System.Text; + +using Amazon.AppConfigData; +using Amazon.AppConfigData.Model; + +using FluentAssertions; + +using Moq; + +namespace CatConsult.AppConfigConfigurationProvider.Tests; + +public class AppConfigConfigurationProviderTests +{ + private readonly Mock _mockClient = new(); + + [Fact] + public void Load_Should_Call_Client_Correctly() + { + var profile = new AppConfigProfile("test", "foo", "bar") + { + ReloadAfter = null, // disable auto-reload for this test + }; + + var sut = new AppConfigConfigurationProvider(_mockClient.Object, profile); + + _mockClient.Setup(p => + p.StartConfigurationSessionAsync( + It.Is(r => + r.ApplicationIdentifier == profile.ApplicationId + && r.EnvironmentIdentifier == profile.EnvironmentId + && r.ConfigurationProfileIdentifier == profile.ProfileId + ), It.IsAny() + ) + ).ReturnsAsync(new StartConfigurationSessionResponse + { + InitialConfigurationToken = "foobar", + }); + + _mockClient.Setup(p => + p.GetLatestConfigurationAsync( + It.Is(r => r.ConfigurationToken == "foobar"), + It.IsAny() + ) + ).ReturnsAsync(new GetLatestConfigurationResponse()); + + sut.Load(); + + _mockClient.VerifyAll(); + } + + [Fact] + public void Load_Should_Parse_Json() + { + var profile = new AppConfigProfile("test", "foo", "bar") + { + ReloadAfter = null, // disable auto-reload for this test + }; + + var sut = new AppConfigConfigurationProvider(_mockClient.Object, profile); + + _mockClient + .Setup(p => + p.StartConfigurationSessionAsync( + It.IsAny(), + It.IsAny() + ) + ).ReturnsAsync(new StartConfigurationSessionResponse()); + + _mockClient.Setup(p => + p.GetLatestConfigurationAsync( + It.IsAny(), + It.IsAny() + ) + ).ReturnsAsync(GenerateJsonResponse()); + + sut.Load(); + + sut.TryGet("Name", out var value).Should().BeTrue(); + value.Should().Be("Catalyst"); + } + + [Fact] + public void Load_Should_Parse_Yaml() + { + var profile = new AppConfigProfile("test", "foo", "bar") + { + ReloadAfter = null, // disable auto-reload for this test + }; + + var sut = new AppConfigConfigurationProvider(_mockClient.Object, profile); + + _mockClient + .Setup(p => + p.StartConfigurationSessionAsync( + It.IsAny(), + It.IsAny() + ) + ).ReturnsAsync(new StartConfigurationSessionResponse()); + + _mockClient.Setup(p => + p.GetLatestConfigurationAsync( + It.IsAny(), + It.IsAny() + ) + ).ReturnsAsync(GenerateYamlResponse()); + + sut.Load(); + + sut.TryGet("Name", out var value).Should().BeTrue(); + value.Should().Be("Catalyst"); + } + + // Helper methods + + private static GetLatestConfigurationResponse GenerateJsonResponse() => + GenerateResponse(@"{""Name"":""Catalyst""}", "application/json"); + + private static GetLatestConfigurationResponse GenerateYamlResponse() => + GenerateResponse("Name: Catalyst", "application/x-yaml"); + + private static GetLatestConfigurationResponse GenerateResponse(string data, string contentType) => + new() + { + ContentLength = 1, // this will trigger the parsing + ContentType = contentType, + Configuration = new MemoryStream(Encoding.UTF8.GetBytes(data)), + NextPollIntervalInSeconds = -1, + NextPollConfigurationToken = "foobar", + }; +} \ No newline at end of file diff --git a/test/CatConsult.AppConfigConfigurationProvider.Tests/AppConfigProfileParserTests.cs b/test/CatConsult.AppConfigConfigurationProvider.Tests/AppConfigProfileParserTests.cs index 0e0a6d2..0534d96 100644 --- a/test/CatConsult.AppConfigConfigurationProvider.Tests/AppConfigProfileParserTests.cs +++ b/test/CatConsult.AppConfigConfigurationProvider.Tests/AppConfigProfileParserTests.cs @@ -8,12 +8,7 @@ namespace CatConsult.AppConfigConfigurationProvider.Tests; public class AppConfigProfileParserTests { - private readonly Faker _faker; - - public AppConfigProfileParserTests() - { - _faker = new Faker(); - } + private readonly Faker _faker = new(); [Fact] public void Parse_Parses_Valid_Profile_String() @@ -47,4 +42,4 @@ public void Parse_Throws_On_Invalid_Profile_String() act.Should().Throw(); } -} +} \ No newline at end of file diff --git a/test/CatConsult.AppConfigConfigurationProvider.Tests/CatConsult.AppConfigConfigurationProvider.Tests.csproj b/test/CatConsult.AppConfigConfigurationProvider.Tests/CatConsult.AppConfigConfigurationProvider.Tests.csproj index 0fdef68..a4abe0d 100644 --- a/test/CatConsult.AppConfigConfigurationProvider.Tests/CatConsult.AppConfigConfigurationProvider.Tests.csproj +++ b/test/CatConsult.AppConfigConfigurationProvider.Tests/CatConsult.AppConfigConfigurationProvider.Tests.csproj @@ -10,14 +10,15 @@ - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all