diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5735bba6..51dec4c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: -assemblyfilters:"+PatternKit*;-*Tests*" \ -filefilters:"-**/*.Tests/*;-**/*Tests*/**" echo "COVERAGE_SUMMARY<> $GITHUB_ENV - cat coverage-report/Summary.txt >> $GITHUB_ENV + sed -n '1,60p' coverage-report/Summary.txt >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV - name: Upload coverage HTML report diff --git a/docs/examples/tenant-external-configuration-store.md b/docs/examples/tenant-external-configuration-store.md new file mode 100644 index 00000000..f0fdb5e2 --- /dev/null +++ b/docs/examples/tenant-external-configuration-store.md @@ -0,0 +1,10 @@ +# Tenant External Configuration Store + +The tenant external-configuration-store example loads feature settings from a central provider before application workflows use them. It demonstrates: + +- a fluent `ExternalConfigurationStore` +- a `[GenerateExternalConfigurationStore]` source-generated factory +- typed validation and cache duration +- `IServiceCollection` registration through `AddTenantExternalConfigurationStoreDemo()` + +The example is implemented in `src/PatternKit.Examples/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemo.cs` and covered by TinyBDD tests in `test/PatternKit.Examples.Tests/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemoTests.cs`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 10fc3b19..083e8594 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -189,3 +189,6 @@ - name: Product Search Rate Limiting href: product-search-rate-limiting.md + +- name: Tenant External Configuration Store + href: tenant-external-configuration-store.md diff --git a/docs/generators/external-configuration-store.md b/docs/generators/external-configuration-store.md new file mode 100644 index 00000000..8c44a805 --- /dev/null +++ b/docs/generators/external-configuration-store.md @@ -0,0 +1,17 @@ +# External Configuration Store Generator + +`[GenerateExternalConfigurationStore]` creates a typed `ExternalConfigurationStore` factory from a static loader and ordered validators. + +```csharp +[GenerateExternalConfigurationStore(typeof(TenantFeatureSettings), FactoryName = "Create")] +public static partial class GeneratedTenantConfigStore +{ + [ExternalConfigurationLoader] + private static ValueTask> Load(CancellationToken ct) { } + + [ExternalConfigurationValidator("Tenant id is required.", 10)] + private static bool HasTenant(TenantFeatureSettings settings) => !string.IsNullOrWhiteSpace(settings.TenantId); +} +``` + +The loader must return `ValueTask>` and accept a `CancellationToken`. Validators must be static `bool` methods accepting `TSettings`. diff --git a/docs/generators/index.md b/docs/generators/index.md index 1572348e..6aeefa37 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -105,6 +105,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Queue Load Leveling**](queue-load-leveling.md) | Bounded worker queue policy factories | `[GenerateQueueLoadLevelingPolicy]` | | [**Cache-Aside**](cache-aside.md) | Read-through cache policy factories with TTL and cache predicates | `[GenerateCacheAsidePolicy]` | | [**Rate Limiting**](rate-limiting.md) | Key-partitioned fixed-window rate limit policy factories | `[GenerateRateLimitPolicy]` | +| [**External Configuration Store**](external-configuration-store.md) | Typed centralized configuration loaders | `[GenerateExternalConfigurationStore]` | ## Quick Reference diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index a4ecbb0c..c0577e04 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -115,6 +115,9 @@ - name: Rate Limiting href: rate-limiting.md +- name: External Configuration Store + href: external-configuration-store.md + - name: Queue Load Leveling href: queue-load-leveling.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 286c0368..8fb770ba 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -70,6 +70,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Queue-Based Load Leveling | `QueueLoadLevelingPolicy` | Queue Load Leveling generator | | Cloud Architecture | Cache-Aside | `CacheAsidePolicy` | Cache-Aside generator | | Cloud Architecture | Rate Limiting | `RateLimitPolicy` | Rate Limiting generator | +| Cloud Architecture | External Configuration Store | `ExternalConfigurationStore` | External Configuration Store generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | | Application Architecture | Specification | `Specification` and named registries | Specification generator | | Application Architecture | Repository | `IRepository` and `InMemoryRepository` | Repository generator | diff --git a/docs/patterns/cloud/external-configuration-store.md b/docs/patterns/cloud/external-configuration-store.md new file mode 100644 index 00000000..985df2dc --- /dev/null +++ b/docs/patterns/cloud/external-configuration-store.md @@ -0,0 +1,13 @@ +# External Configuration Store + +Use `ExternalConfigurationStore` when application configuration is centralized in a service such as Azure App Configuration, AWS AppConfig, Consul, Vault, or a tenant settings API. PatternKit keeps the loader, validation rules, and cache policy together so applications import one typed store through DI. + +```csharp +var store = ExternalConfigurationStore.Create("tenant-feature-config") + .LoadFrom(provider.LoadAsync) + .ValidateWith("Tenant id is required.", settings => !string.IsNullOrWhiteSpace(settings.TenantId)) + .CacheFor(TimeSpan.FromMinutes(5)) + .Build(); +``` + +The source-generated path uses `[GenerateExternalConfigurationStore]`, one `[ExternalConfigurationLoader]`, and optional `[ExternalConfigurationValidator]` methods. Import the example through `AddTenantExternalConfigurationStoreDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index ab24580b..66ffb89c 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -339,6 +339,8 @@ href: cloud/cache-aside.md - name: Rate Limiting href: cloud/rate-limiting.md + - name: External Configuration Store + href: cloud/external-configuration-store.md - name: Application Architecture items: - name: Anti-Corruption Layer diff --git a/src/PatternKit.Core/Cloud/ExternalConfigurationStore/ExternalConfigurationStore.cs b/src/PatternKit.Core/Cloud/ExternalConfigurationStore/ExternalConfigurationStore.cs new file mode 100644 index 00000000..776e9eed --- /dev/null +++ b/src/PatternKit.Core/Cloud/ExternalConfigurationStore/ExternalConfigurationStore.cs @@ -0,0 +1,194 @@ +namespace PatternKit.Cloud.ExternalConfigurationStore; + +/// +/// Centralized external configuration store with typed async loading, validation, and optional caching. +/// +public sealed class ExternalConfigurationStore +{ + /// Loads the current configuration snapshot from an external source. + public delegate ValueTask> ConfigurationLoader(CancellationToken cancellationToken); + + /// Validates a loaded settings object. + public delegate bool ConfigurationValidator(TSettings settings); + + private readonly string _name; + private readonly ConfigurationLoader _loader; + private readonly ValidationRule[] _validators; + private readonly TimeSpan _cacheDuration; + private readonly object _gate = new(); + private ExternalConfigurationSnapshot? _cached; + + private ExternalConfigurationStore( + string name, + ConfigurationLoader loader, + ValidationRule[] validators, + TimeSpan cacheDuration) + => (_name, _loader, _validators, _cacheDuration) = (name, loader, validators, cacheDuration); + + /// Gets validated configuration, reusing the cached snapshot while it remains fresh. + public async ValueTask> GetAsync(CancellationToken cancellationToken = default) + { + var cached = GetFreshCachedSnapshot(); + if (cached is not null) + return Validate(cached); + + var loaded = await _loader(cancellationToken).ConfigureAwait(false); + var result = Validate(loaded); + if (result.Succeeded) + { + lock (_gate) + _cached = loaded; + } + + return result; + } + + /// Creates a new external configuration store builder. + public static Builder Create(string name = "external-configuration-store") => new(name); + + private ExternalConfigurationSnapshot? GetFreshCachedSnapshot() + { + if (_cacheDuration <= TimeSpan.Zero) + return null; + + lock (_gate) + { + if (_cached is null) + return null; + + return DateTimeOffset.UtcNow - _cached.LoadedAtUtc <= _cacheDuration ? _cached : null; + } + } + + private ExternalConfigurationResult Validate(ExternalConfigurationSnapshot snapshot) + { + foreach (var validator in _validators) + { + if (!validator.Predicate(snapshot.Settings)) + return ExternalConfigurationResult.Rejected(_name, snapshot, validator.RejectionReason); + } + + return ExternalConfigurationResult.Accepted(_name, snapshot); + } + + /// Fluent builder for . + public sealed class Builder + { + private readonly string _name; + private readonly List _validators = new(4); + private ConfigurationLoader? _loader; + private TimeSpan _cacheDuration = TimeSpan.Zero; + + internal Builder(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("External configuration store name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + } + + /// Registers the async external configuration loader. + public Builder LoadFrom(ConfigurationLoader loader) + { + _loader = loader ?? throw new ArgumentNullException(nameof(loader)); + return this; + } + + /// Adds a validation rule for loaded settings. + public Builder ValidateWith(string rejectionReason, ConfigurationValidator validator) + { + if (string.IsNullOrWhiteSpace(rejectionReason)) + throw new ArgumentException("Validation rejection reason cannot be null, empty, or whitespace.", nameof(rejectionReason)); + if (validator is null) + throw new ArgumentNullException(nameof(validator)); + + _validators.Add(new ValidationRule(rejectionReason, validator)); + return this; + } + + /// Configures how long successful snapshots should be cached. + public Builder CacheFor(TimeSpan duration) + { + if (duration < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(duration), "Cache duration cannot be negative."); + + _cacheDuration = duration; + return this; + } + + /// Builds an immutable external configuration store. + public ExternalConfigurationStore Build() + { + if (_loader is null) + throw new InvalidOperationException("External configuration store requires a loader."); + + return new ExternalConfigurationStore(_name, _loader, _validators.ToArray(), _cacheDuration); + } + } + + private sealed class ValidationRule + { + public ValidationRule(string rejectionReason, ConfigurationValidator predicate) + => (RejectionReason, Predicate) = (rejectionReason, predicate); + + public string RejectionReason { get; } + + public ConfigurationValidator Predicate { get; } + } +} + +/// Loaded configuration snapshot with source version metadata. +public sealed class ExternalConfigurationSnapshot +{ + public ExternalConfigurationSnapshot(TSettings settings, string version, DateTimeOffset loadedAtUtc) + { + if (settings is null) + throw new ArgumentNullException(nameof(settings)); + if (string.IsNullOrWhiteSpace(version)) + throw new ArgumentException("Configuration version cannot be null, empty, or whitespace.", nameof(version)); + + Settings = settings; + Version = version; + LoadedAtUtc = loadedAtUtc; + } + + /// The loaded typed settings. + public TSettings Settings { get; } + + /// External store version, revision, or etag. + public string Version { get; } + + /// UTC timestamp when the snapshot was loaded. + public DateTimeOffset LoadedAtUtc { get; } +} + +/// Validated configuration result returned by . +public sealed class ExternalConfigurationResult +{ + private ExternalConfigurationResult( + string storeName, + ExternalConfigurationSnapshot snapshot, + bool succeeded, + string? rejectionReason) + => (StoreName, Snapshot, Succeeded, RejectionReason) = (storeName, snapshot, succeeded, rejectionReason); + + /// The store name. + public string StoreName { get; } + + /// The loaded snapshot. + public ExternalConfigurationSnapshot Snapshot { get; } + + /// True when all validation rules accepted the snapshot. + public bool Succeeded { get; } + + /// Validation failure reason when rejected. + public string? RejectionReason { get; } + + /// Creates an accepted result. + public static ExternalConfigurationResult Accepted(string storeName, ExternalConfigurationSnapshot snapshot) + => new(storeName, snapshot, true, null); + + /// Creates a rejected result. + public static ExternalConfigurationResult Rejected(string storeName, ExternalConfigurationSnapshot snapshot, string rejectionReason) + => new(storeName, snapshot, false, rejectionReason); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 553a227a..54108a65 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -29,6 +29,7 @@ using PatternKit.Examples.DomainEventDemo; using PatternKit.Examples.EnterpriseFeatureSlices; using PatternKit.Examples.EventSourcingDemo; +using PatternKit.Examples.ExternalConfigurationStoreDemo; using PatternKit.Examples.FeatureToggleDemo; using PatternKit.Examples.FlyweightDemo; using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; @@ -169,6 +170,7 @@ public sealed record ShippingBulkheadExample(BulkheadPolicy public sealed record FulfillmentQueueLoadLevelingExample(QueueLoadLevelingPolicy Policy, FulfillmentQueueLoadLevelingService Service); public sealed record ProductCatalogCacheAsideExample(CacheAsidePolicy Policy, ProductCatalogCacheAsideService Service); public sealed record ProductSearchRateLimitingExample(RateLimitPolicy Policy, ProductSearchRateLimitService Service); +public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfigurationStoreDemoRunner Runner, TenantExternalConfigurationService Service); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -241,7 +243,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddShippingBulkheadExample() .AddFulfillmentQueueLoadLevelingExample() .AddProductCatalogCacheAsideExample() - .AddProductSearchRateLimitingExample(); + .AddProductSearchRateLimitingExample() + .AddTenantExternalConfigurationStoreExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -826,6 +829,15 @@ public static IServiceCollection AddProductSearchRateLimitingExample(this IServi return services.RegisterExample("Product Search Rate Limiting", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddTenantExternalConfigurationStoreExample(this IServiceCollection services) + { + services.AddTenantExternalConfigurationStoreDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Tenant External Configuration Store", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + private static IServiceCollection RegisterExample( this IServiceCollection services, string name, diff --git a/src/PatternKit.Examples/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemo.cs b/src/PatternKit.Examples/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemo.cs new file mode 100644 index 00000000..a3c427e5 --- /dev/null +++ b/src/PatternKit.Examples/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemo.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.ExternalConfigurationStore; +using PatternKit.Generators.Cloud; + +namespace PatternKit.Examples.ExternalConfigurationStoreDemo; + +/// Tenant feature settings loaded from an external configuration source. +public sealed record TenantFeatureSettings(string TenantId, Uri ApiEndpoint, bool NewCheckoutEnabled); + +/// Summary returned by the tenant external-configuration-store example. +public sealed record TenantExternalConfigurationSummary(bool Loaded, string Version, bool NewCheckoutEnabled, string? RejectionReason); + +/// In-memory provider used to model an external configuration service. +public sealed class TenantConfigurationProvider +{ + private TenantFeatureSettings _settings = new("tenant-a", new Uri("https://api.example.com/tenant-a"), true); + private string _version = "v1"; + + public void Replace(TenantFeatureSettings settings, string version) + => (_settings, _version) = (settings, version); + + public ValueTask> LoadAsync(CancellationToken cancellationToken) + => new(new ExternalConfigurationSnapshot(_settings, _version, DateTimeOffset.UtcNow)); +} + +/// Provider registry used by source-generated static loader methods. +public static class TenantConfigurationProviderRegistry +{ + public static TenantConfigurationProvider Provider { get; set; } = new(); +} + +/// Fluent external-configuration-store builder for non-generator consumers. +public static class TenantExternalConfigurationStores +{ + public static ExternalConfigurationStore Create(TenantConfigurationProvider provider) + => ExternalConfigurationStore.Create("tenant-feature-config") + .LoadFrom(provider.LoadAsync) + .ValidateWith("Tenant id is required.", static settings => !string.IsNullOrWhiteSpace(settings.TenantId)) + .ValidateWith("API endpoint must be absolute.", static settings => settings.ApiEndpoint.IsAbsoluteUri) + .CacheFor(TimeSpan.FromMinutes(5)) + .Build(); +} + +/// Source-generated external configuration store for tenant feature settings. +[GenerateExternalConfigurationStore( + typeof(TenantFeatureSettings), + FactoryName = "Create", + StoreName = "tenant-feature-config", + CacheMilliseconds = 300000)] +public static partial class GeneratedTenantExternalConfigurationStore +{ + [ExternalConfigurationLoader] + private static ValueTask> Load(CancellationToken cancellationToken) + => TenantConfigurationProviderRegistry.Provider.LoadAsync(cancellationToken); + + [ExternalConfigurationValidator("Tenant id is required.", 10)] + private static bool HasTenant(TenantFeatureSettings settings) + => !string.IsNullOrWhiteSpace(settings.TenantId); + + [ExternalConfigurationValidator("API endpoint must be absolute.", 20)] + private static bool HasAbsoluteEndpoint(TenantFeatureSettings settings) + => settings.ApiEndpoint.IsAbsoluteUri; +} + +/// Service that reads tenant feature settings from the generated external configuration store. +public sealed class TenantExternalConfigurationService(ExternalConfigurationStore store) +{ + public async ValueTask LoadAsync(CancellationToken cancellationToken = default) + { + var result = await store.GetAsync(cancellationToken); + return new TenantExternalConfigurationSummary( + result.Succeeded, + result.Snapshot.Version, + result.Succeeded && result.Snapshot.Settings.NewCheckoutEnabled, + result.RejectionReason); + } +} + +/// Runner that demonstrates both fluent and generated external-configuration-store paths. +public sealed class TenantExternalConfigurationStoreDemoRunner(TenantExternalConfigurationService service) +{ + public ValueTask RunGeneratedAsync(CancellationToken cancellationToken = default) + => service.LoadAsync(cancellationToken); + + public static async ValueTask RunFluentAsync(CancellationToken cancellationToken = default) + { + var provider = new TenantConfigurationProvider(); + var store = TenantExternalConfigurationStores.Create(provider); + var result = await store.GetAsync(cancellationToken); + return new TenantExternalConfigurationSummary( + result.Succeeded, + result.Snapshot.Version, + result.Succeeded && result.Snapshot.Settings.NewCheckoutEnabled, + result.RejectionReason); + } +} + +/// DI helpers for importing the tenant external configuration store example into standard .NET containers. +public static class TenantExternalConfigurationStoreServiceCollectionExtensions +{ + public static IServiceCollection AddTenantExternalConfigurationStoreDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => + { + TenantConfigurationProviderRegistry.Provider = sp.GetRequiredService(); + return GeneratedTenantExternalConfigurationStore.Create(); + }); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 2d1538e5..32094f6e 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -599,7 +599,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "docs/examples/product-search-rate-limiting.md", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Rate Limiting"], - ["tenant partitioning", "source-generated policy factory", "DI composition"]) + ["tenant partitioning", "source-generated policy factory", "DI composition"]), + Descriptor( + "Tenant External Configuration Store", + "src/PatternKit.Examples/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemo.cs", + "test/PatternKit.Examples.Tests/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemoTests.cs", + "docs/examples/tenant-external-configuration-store.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["External Configuration Store"], + ["central settings provider", "typed validation", "source-generated store factory", "DI composition"]) ]; public IReadOnlyList Entries => Items; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 27c60b8b..55d0f2c0 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -701,6 +701,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/RateLimitingDemo/ProductSearchRateLimitingDemoTests.cs", ["fluent rate-limit policy", "generated rate-limit policy", "DI-importable tenant product search example"]), + Pattern("External Configuration Store", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/external-configuration-store.md", + "src/PatternKit.Core/Cloud/ExternalConfigurationStore/ExternalConfigurationStore.cs", + "test/PatternKit.Tests/Cloud/ExternalConfigurationStore/ExternalConfigurationStoreTests.cs", + "docs/generators/external-configuration-store.md", + "src/PatternKit.Generators/Cloud/ExternalConfigurationStoreGenerator.cs", + "test/PatternKit.Generators.Tests/ExternalConfigurationStoreGeneratorTests.cs", + null, + "docs/examples/tenant-external-configuration-store.md", + "src/PatternKit.Examples/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemo.cs", + "test/PatternKit.Examples.Tests/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemoTests.cs", + ["fluent external configuration store", "generated external configuration store", "DI-importable tenant settings example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Generators.Abstractions/Cloud/ExternalConfigurationStoreAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/ExternalConfigurationStoreAttributes.cs new file mode 100644 index 00000000..2694d2f0 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/ExternalConfigurationStoreAttributes.cs @@ -0,0 +1,51 @@ +using System; + +namespace PatternKit.Generators.Cloud; + +/// +/// Generates a typed external-configuration-store factory for a partial class or struct. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateExternalConfigurationStoreAttribute : Attribute +{ + /// Creates an external-configuration-store generator attribute. + public GenerateExternalConfigurationStoreAttribute(Type settingsType) + => SettingsType = settingsType ?? throw new ArgumentNullException(nameof(settingsType)); + + /// Typed settings loaded by the generated store. + public Type SettingsType { get; } + + /// Name of the generated factory method. + public string FactoryName { get; set; } = "Create"; + + /// Name assigned to the generated configuration store. + public string StoreName { get; set; } = "external-configuration-store"; + + /// Cache duration in milliseconds for successful snapshots. + public int CacheMilliseconds { get; set; } +} + +/// Marks the static async method that loads configuration from an external source. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ExternalConfigurationLoaderAttribute : Attribute; + +/// Marks a static settings validator for a generated external configuration store. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ExternalConfigurationValidatorAttribute : Attribute +{ + /// Creates a validator attribute. + public ExternalConfigurationValidatorAttribute(string rejectionReason, int order) + { + if (string.IsNullOrWhiteSpace(rejectionReason)) + throw new ArgumentException("Validation rejection reason cannot be null, empty, or whitespace.", nameof(rejectionReason)); + + RejectionReason = rejectionReason; + Order = order; + } + + /// Reason returned when this validator rejects settings. + public string RejectionReason { get; } + + /// Validator order in the generated store. + public int Order { get; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index eb245346..16a4ec55 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -292,3 +292,7 @@ PKWT001 | PatternKit.Generators.Messaging | Error | Wire Tap host type must be p PKWT002 | PatternKit.Generators.Messaging | Error | Wire Tap must declare at least one handler. PKWT003 | PatternKit.Generators.Messaging | Error | Wire Tap handler signature is invalid. PKWT004 | PatternKit.Generators.Messaging | Error | Wire Tap handler name or order is duplicated. +PKECS001 | PatternKit.Generators.Cloud | Error | External Configuration Store host type must be partial. +PKECS002 | PatternKit.Generators.Cloud | Error | External Configuration Store loader is invalid. +PKECS003 | PatternKit.Generators.Cloud | Error | External Configuration Store validator signature is invalid. +PKECS004 | PatternKit.Generators.Cloud | Error | External Configuration Store validator order is duplicated. diff --git a/src/PatternKit.Generators/Cloud/ExternalConfigurationStoreGenerator.cs b/src/PatternKit.Generators/Cloud/ExternalConfigurationStoreGenerator.cs new file mode 100644 index 00000000..8eeaf7c1 --- /dev/null +++ b/src/PatternKit.Generators/Cloud/ExternalConfigurationStoreGenerator.cs @@ -0,0 +1,233 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Cloud; + +[Generator] +public sealed class ExternalConfigurationStoreGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKECS001", + "External configuration store type must be partial", + "Type '{0}' is marked with [GenerateExternalConfigurationStore] but is not declared as partial", + "PatternKit.Generators.Cloud", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidLoader = new( + "PKECS002", + "External configuration store loader is invalid", + "Type '{0}' must declare exactly one static [ExternalConfigurationLoader] method returning ValueTask> with a CancellationToken parameter", + "PatternKit.Generators.Cloud", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidValidator = new( + "PKECS003", + "External configuration store validator signature is invalid", + "External configuration validator '{0}' must be static and return bool with a TSettings parameter", + "PatternKit.Generators.Cloud", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateValidator = new( + "PKECS004", + "External configuration store validator order is duplicated", + "External configuration validator '{0}' duplicates another validator order in '{1}'", + "PatternKit.Generators.Cloud", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Cloud.GenerateExternalConfigurationStoreAttribute", + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Cloud.GenerateExternalConfigurationStoreAttribute"); + if (attr is null) + return; + + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate( + SourceProductionContext context, + INamedTypeSymbol type, + TypeDeclarationSyntax node, + AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var settingsType = attribute.ConstructorArguments.Length >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + if (settingsType is null) + return; + + var loaders = type.GetMembers().OfType() + .Where(static method => method.GetAttributes().Any(static attr => + attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Cloud.ExternalConfigurationLoaderAttribute")) + .ToArray(); + if (loaders.Length != 1 || !IsLoader(loaders[0], settingsType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidLoader, node.Identifier.GetLocation(), type.Name)); + return; + } + + var validators = GetValidators(type, settingsType, context); + if (HasDuplicates(validators, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateValidator, duplicate.Location, duplicate.MethodName, type.Name)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var storeName = GetNamedString(attribute, "StoreName") ?? "external-configuration-store"; + var cacheMilliseconds = GetNamedInt(attribute, "CacheMilliseconds"); + var ordered = validators.OrderBy(static validator => validator.Order).ToArray(); + + context.AddSource($"{type.Name}.ExternalConfigurationStore.g.cs", SourceText.From( + GenerateSource(type, settingsType, loaders[0].Name, ordered, factoryName, storeName, cacheMilliseconds), + Encoding.UTF8)); + } + + private static ImmutableArray GetValidators( + INamedTypeSymbol type, + INamedTypeSymbol settingsType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var method in type.GetMembers().OfType()) + { + var attr = method.GetAttributes().FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Cloud.ExternalConfigurationValidatorAttribute"); + if (attr is null) + continue; + + if (!IsValidator(method, settingsType) || attr.ConstructorArguments.Length != 2) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidValidator, method.Locations.FirstOrDefault(), method.Name)); + continue; + } + + var reason = attr.ConstructorArguments[0].Value as string; + var order = attr.ConstructorArguments[1].Value as int? ?? 0; + if (string.IsNullOrWhiteSpace(reason)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidValidator, method.Locations.FirstOrDefault(), method.Name)); + continue; + } + + builder.Add(new Validator(reason!, order, method.Name, method.Locations.FirstOrDefault())); + } + + return builder.ToImmutable(); + } + + private static bool IsLoader(IMethodSymbol method, INamedTypeSymbol settingsType) + => method.IsStatic && + method.Parameters.Length == 1 && + method.Parameters[0].Type.ToDisplayString() == "System.Threading.CancellationToken" && + method.ReturnType is INamedTypeSymbol returnType && + returnType.ConstructedFrom.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + returnType.TypeArguments[0] is INamedTypeSymbol snapshot && + snapshot.ConstructedFrom.ToDisplayString() == "PatternKit.Cloud.ExternalConfigurationStore.ExternalConfigurationSnapshot" && + SymbolEqualityComparer.Default.Equals(snapshot.TypeArguments[0], settingsType); + + private static bool IsValidator(IMethodSymbol method, INamedTypeSymbol settingsType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_Boolean && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, settingsType); + + private static bool HasDuplicates(IReadOnlyList validators, out Validator duplicate) + { + var orders = new HashSet(); + foreach (var validator in validators) + { + if (!orders.Add(validator.Order)) + { + duplicate = validator; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol settingsType, + string loaderName, + IReadOnlyList validators, + string factoryName, + string storeName, + int cacheMilliseconds) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(GetKind(type)).Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Cloud.ExternalConfigurationStore.ExternalConfigurationStore<") + .Append(settingsType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ") + .Append(factoryName) + .AppendLine("()"); + sb.Append(" => global::PatternKit.Cloud.ExternalConfigurationStore.ExternalConfigurationStore<") + .Append(settingsType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Create(") + .Append(ToLiteral(storeName)) + .AppendLine(")"); + sb.Append(" .LoadFrom(").Append(loaderName).AppendLine(")"); + + foreach (var validator in validators) + sb.Append(" .ValidateWith(").Append(ToLiteral(validator.RejectionReason)).Append(", ").Append(validator.MethodName).AppendLine(")"); + + if (cacheMilliseconds > 0) + sb.Append(" .CacheFor(global::System.TimeSpan.FromMilliseconds(").Append(cacheMilliseconds).AppendLine("))"); + + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string GetKind(INamedTypeSymbol type) + => type.TypeKind == TypeKind.Struct ? "struct" : "class"; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static int GetNamedInt(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as int? ?? 0; + + private static string ToLiteral(string value) + => "@\"" + value.Replace("\"", "\"\"") + "\""; + + private readonly record struct Validator(string RejectionReason, int Order, string MethodName, Location? Location); +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 8ef0613b..920e52bf 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -7,6 +7,7 @@ using PatternKit.Examples.DataMapperDemo; using PatternKit.Examples.DependencyInjection; using PatternKit.Examples.DomainEventDemo; +using PatternKit.Examples.ExternalConfigurationStoreDemo; using PatternKit.Examples.IdentityMapDemo; using PatternKit.Examples.MaterializedViewDemo; using PatternKit.Examples.Messaging; @@ -130,6 +131,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var queueLoadLeveling = provider.GetRequiredService(); var productCacheAside = provider.GetRequiredService(); var productRateLimit = provider.GetRequiredService(); + var externalConfiguration = provider.GetRequiredService(); auth.Chain.Execute(new PatternKit.Examples.Chain.HttpRequest("GET", "/admin/metrics", new Dictionary())); @@ -216,7 +218,8 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("generated bulkhead reserves shipping allocations", shippingBulkhead.Service.ReserveAsync("ORDER-100").GetAwaiter().GetResult().Succeeded), ("generated queue load leveling accepts fulfillment work", queueLoadLeveling.Service.EnqueueAsync(new FulfillmentWorkItem("ORDER-QL", "central")).GetAwaiter().GetResult().Accepted), ("generated cache-aside reuses product catalog reads", CacheAsideHits(productCacheAside.Service)), - ("generated rate limit rejects product search overflow", RateLimitRejects(productRateLimit.Service)) + ("generated rate limit rejects product search overflow", RateLimitRejects(productRateLimit.Service)), + ("generated external configuration store loads tenant settings", externalConfiguration.Service.LoadAsync().AsTask().GetAwaiter().GetResult().Loaded) ]; } diff --git a/test/PatternKit.Examples.Tests/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemoTests.cs b/test/PatternKit.Examples.Tests/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemoTests.cs new file mode 100644 index 00000000..84f130a8 --- /dev/null +++ b/test/PatternKit.Examples.Tests/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemoTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ExternalConfigurationStoreDemo; +using TinyBDD; + +namespace PatternKit.Examples.Tests.ExternalConfigurationStoreDemo; + +public sealed class TenantExternalConfigurationStoreDemoTests +{ + [Scenario("FluentStore LoadsTenantConfiguration")] + [Fact] + public async Task FluentStore_LoadsTenantConfiguration() + { + var summary = await TenantExternalConfigurationStoreDemoRunner.RunFluentAsync(); + + ScenarioExpect.True(summary.Loaded); + ScenarioExpect.Equal("v1", summary.Version); + ScenarioExpect.True(summary.NewCheckoutEnabled); + } + + [Scenario("GeneratedStore MatchesFluentValidation")] + [Fact] + public async Task GeneratedStore_MatchesFluentValidation() + { + var provider = new TenantConfigurationProvider(); + TenantConfigurationProviderRegistry.Provider = provider; + + var result = await GeneratedTenantExternalConfigurationStore.Create().GetAsync(); + + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("tenant-a", result.Snapshot.Settings.TenantId); + } + + [Scenario("ServiceCollection ImportsExternalConfigurationStoreExample")] + [Fact] + public async Task ServiceCollection_ImportsExternalConfigurationStoreExample() + { + var services = new ServiceCollection(); + services.AddTenantExternalConfigurationStoreDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var runner = provider.GetRequiredService(); + + var summary = await runner.RunGeneratedAsync(); + + ScenarioExpect.True(summary.Loaded); + ScenarioExpect.Equal("v1", summary.Version); + } + + [Scenario("AggregateServiceCollection ImportsExternalConfigurationStoreExample")] + [Fact] + public async Task AggregateServiceCollection_ImportsExternalConfigurationStoreExample() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + + var summary = await example.Service.LoadAsync(); + + ScenarioExpect.True(summary.Loaded); + ScenarioExpect.True(summary.NewCheckoutEnabled); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 06ce5391..ea0a0d67 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -64,6 +64,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Queue-Based Load Leveling", "Cache-Aside", "Rate Limiting", + "External Configuration Store", "CQRS", "Specification", "Repository", @@ -121,7 +122,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() { ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); - ScenarioExpect.Equal(6, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(7, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 74db369b..61fc5a30 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -4,6 +4,7 @@ using PatternKit.Generators.CacheAside; using PatternKit.Generators.Chain; using PatternKit.Generators.CircuitBreaker; +using PatternKit.Generators.Cloud; using PatternKit.Generators.Command; using PatternKit.Generators.Composite; using PatternKit.Generators.Composer; @@ -72,6 +73,9 @@ private enum TestTrigger { typeof(GenerateBulkheadPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateCacheAsidePolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(CacheAsidePredicateAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateExternalConfigurationStoreAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(ExternalConfigurationLoaderAttribute), AttributeTargets.Method, false, false }, + { typeof(ExternalConfigurationValidatorAttribute), AttributeTargets.Method, false, false }, { typeof(ChainAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ChainHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(ChainDefaultAttribute), AttributeTargets.Method, false, false }, @@ -235,13 +239,29 @@ public void CacheAside_Attributes_Expose_Defaults_And_Configuration() PolicyName = "products", TimeToLiveMilliseconds = 250 }; + var externalConfig = new GenerateExternalConfigurationStoreAttribute(typeof(string)) + { + FactoryName = "BuildTenantConfig", + StoreName = "tenant-config", + CacheMilliseconds = 1000 + }; + var externalValidator = new ExternalConfigurationValidatorAttribute("Endpoint is required.", 10); ScenarioExpect.Equal(typeof(string), cacheAside.ResultType); ScenarioExpect.Equal("BuildProductCache", cacheAside.FactoryMethodName); ScenarioExpect.Equal("products", cacheAside.PolicyName); ScenarioExpect.Equal(250, cacheAside.TimeToLiveMilliseconds); + ScenarioExpect.Equal(typeof(string), externalConfig.SettingsType); + ScenarioExpect.Equal("BuildTenantConfig", externalConfig.FactoryName); + ScenarioExpect.Equal("tenant-config", externalConfig.StoreName); + ScenarioExpect.Equal(1000, externalConfig.CacheMilliseconds); + ScenarioExpect.Equal("Endpoint is required.", externalValidator.RejectionReason); + ScenarioExpect.Equal(10, externalValidator.Order); ScenarioExpect.Throws(() => new GenerateCacheAsidePolicyAttribute(null!)); + ScenarioExpect.Throws(() => new GenerateExternalConfigurationStoreAttribute(null!)); + ScenarioExpect.Throws(() => new ExternalConfigurationValidatorAttribute("", 1)); ScenarioExpect.IsType(new CacheAsidePredicateAttribute()); + ScenarioExpect.IsType(new ExternalConfigurationLoaderAttribute()); } [Scenario("Anti Corruption Attributes Expose Defaults And Validation")] diff --git a/test/PatternKit.Generators.Tests/ExternalConfigurationStoreGeneratorTests.cs b/test/PatternKit.Generators.Tests/ExternalConfigurationStoreGeneratorTests.cs new file mode 100644 index 00000000..0a4ccf99 --- /dev/null +++ b/test/PatternKit.Generators.Tests/ExternalConfigurationStoreGeneratorTests.cs @@ -0,0 +1,194 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Cloud.ExternalConfigurationStore; +using PatternKit.Generators.Cloud; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class ExternalConfigurationStoreGeneratorTests +{ + [Scenario("GeneratesExternalConfigurationStoreFactory")] + [Fact] + public void GeneratesExternalConfigurationStoreFactory() + { + var source = """ + using PatternKit.Cloud.ExternalConfigurationStore; + using PatternKit.Generators.Cloud; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyApp; + + public sealed record AppSettings(string Endpoint); + + [GenerateExternalConfigurationStore(typeof(AppSettings), FactoryName = "Build", StoreName = "tenant-config", CacheMilliseconds = 1000)] + public static partial class AppConfigStore + { + [ExternalConfigurationLoader] + private static ValueTask> Load(CancellationToken cancellationToken) + => new(new ExternalConfigurationSnapshot(new AppSettings("https://api.example.com"), "v1", DateTimeOffset.UtcNow)); + + [ExternalConfigurationValidator("Endpoint is required.", 10)] + private static bool HasEndpoint(AppSettings settings) => !string.IsNullOrWhiteSpace(settings.Endpoint); + } + + public static class Demo + { + public static ValueTask> Run() + => AppConfigStore.Build().GetAsync(); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesExternalConfigurationStoreFactory)); + var gen = new ExternalConfigurationStoreGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("AppConfigStore.ExternalConfigurationStore.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("ExternalConfigurationStore", text); + ScenarioExpect.Contains(".LoadFrom(Load)", text); + ScenarioExpect.Contains(".ValidateWith(@\"Endpoint is required.\", HasEndpoint)", text); + ScenarioExpect.Contains(".CacheFor(global::System.TimeSpan.FromMilliseconds(1000))", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("ReportsDiagnosticForNonPartialStore")] + [Fact] + public void ReportsDiagnosticForNonPartialStore() + { + var source = """ + using PatternKit.Cloud.ExternalConfigurationStore; + using PatternKit.Generators.Cloud; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyApp; + + public sealed record AppSettings(string Endpoint); + + [GenerateExternalConfigurationStore(typeof(AppSettings))] + public static class AppConfigStore + { + [ExternalConfigurationLoader] + private static ValueTask> Load(CancellationToken cancellationToken) + => new(new ExternalConfigurationSnapshot(new AppSettings("endpoint"), "v1", DateTimeOffset.UtcNow)); + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialStore)); + var gen = new ExternalConfigurationStoreGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKECS001", diagnostic.Id); + } + + [Scenario("ReportsDiagnosticForMissingOrInvalidLoader")] + [Fact] + public void ReportsDiagnosticForMissingOrInvalidLoader() + { + var source = """ + using PatternKit.Generators.Cloud; + + namespace MyApp; + + public sealed record AppSettings(string Endpoint); + + [GenerateExternalConfigurationStore(typeof(AppSettings))] + public static partial class AppConfigStore; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingOrInvalidLoader)); + var gen = new ExternalConfigurationStoreGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKECS002", diagnostic.Id); + } + + [Scenario("ReportsDiagnosticForInvalidValidator")] + [Fact] + public void ReportsDiagnosticForInvalidValidator() + { + var source = """ + using PatternKit.Cloud.ExternalConfigurationStore; + using PatternKit.Generators.Cloud; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyApp; + + public sealed record AppSettings(string Endpoint); + + [GenerateExternalConfigurationStore(typeof(AppSettings))] + public static partial class AppConfigStore + { + [ExternalConfigurationLoader] + private static ValueTask> Load(CancellationToken cancellationToken) + => new(new ExternalConfigurationSnapshot(new AppSettings("endpoint"), "v1", DateTimeOffset.UtcNow)); + + [ExternalConfigurationValidator("Endpoint is required.", 10)] + private static string HasEndpoint(AppSettings settings) => settings.Endpoint; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidValidator)); + var gen = new ExternalConfigurationStoreGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKECS003", diagnostic.Id); + } + + [Scenario("ReportsDiagnosticForDuplicateValidatorOrder")] + [Fact] + public void ReportsDiagnosticForDuplicateValidatorOrder() + { + var source = """ + using PatternKit.Cloud.ExternalConfigurationStore; + using PatternKit.Generators.Cloud; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyApp; + + public sealed record AppSettings(string Endpoint); + + [GenerateExternalConfigurationStore(typeof(AppSettings))] + public static partial class AppConfigStore + { + [ExternalConfigurationLoader] + private static ValueTask> Load(CancellationToken cancellationToken) + => new(new ExternalConfigurationSnapshot(new AppSettings("endpoint"), "v1", DateTimeOffset.UtcNow)); + + [ExternalConfigurationValidator("Endpoint is required.", 10)] + private static bool HasEndpoint(AppSettings settings) => true; + + [ExternalConfigurationValidator("Endpoint is absolute.", 10)] + private static bool IsAbsolute(AppSettings settings) => true; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForDuplicateValidatorOrder)); + var gen = new ExternalConfigurationStoreGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKECS004", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(ExternalConfigurationStore<>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Cloud/ExternalConfigurationStore/ExternalConfigurationStoreTests.cs b/test/PatternKit.Tests/Cloud/ExternalConfigurationStore/ExternalConfigurationStoreTests.cs new file mode 100644 index 00000000..2ce1dcc6 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/ExternalConfigurationStore/ExternalConfigurationStoreTests.cs @@ -0,0 +1,79 @@ +using PatternKit.Cloud.ExternalConfigurationStore; +using TinyBDD; + +namespace PatternKit.Tests.Cloud.ExternalConfigurationStore; + +public sealed class ExternalConfigurationStoreTests +{ + [Scenario("GetAsync LoadsAndValidatesSettings")] + [Fact] + public async Task GetAsync_LoadsAndValidatesSettings() + { + var store = ExternalConfigurationStore.Create("app-config") + .LoadFrom(static _ => new ValueTask>( + new ExternalConfigurationSnapshot(new("https://api.example.com", true), "v1", DateTimeOffset.UtcNow))) + .ValidateWith("Endpoint is required.", static settings => !string.IsNullOrWhiteSpace(settings.Endpoint)) + .Build(); + + var result = await store.GetAsync(); + + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("app-config", result.StoreName); + ScenarioExpect.Equal("v1", result.Snapshot.Version); + ScenarioExpect.Equal("https://api.example.com", result.Snapshot.Settings.Endpoint); + } + + [Scenario("GetAsync RejectsInvalidSettings")] + [Fact] + public async Task GetAsync_RejectsInvalidSettings() + { + var store = ExternalConfigurationStore.Create() + .LoadFrom(static _ => new ValueTask>( + new ExternalConfigurationSnapshot(new("", true), "v1", DateTimeOffset.UtcNow))) + .ValidateWith("Endpoint is required.", static settings => !string.IsNullOrWhiteSpace(settings.Endpoint)) + .Build(); + + var result = await store.GetAsync(); + + ScenarioExpect.False(result.Succeeded); + ScenarioExpect.Equal("Endpoint is required.", result.RejectionReason); + } + + [Scenario("GetAsync ReusesFreshCachedSnapshot")] + [Fact] + public async Task GetAsync_ReusesFreshCachedSnapshot() + { + var loads = 0; + var store = ExternalConfigurationStore.Create() + .LoadFrom(_ => + { + loads++; + return new ValueTask>( + new ExternalConfigurationSnapshot(new("https://api.example.com", true), $"v{loads}", DateTimeOffset.UtcNow)); + }) + .CacheFor(TimeSpan.FromMinutes(1)) + .Build(); + + var first = await store.GetAsync(); + var second = await store.GetAsync(); + + ScenarioExpect.Equal(1, loads); + ScenarioExpect.Equal(first.Snapshot.Version, second.Snapshot.Version); + } + + [Scenario("Builder RejectsInvalidConfiguration")] + [Fact] + public void Builder_RejectsInvalidConfiguration() + { + ScenarioExpect.Throws(() => ExternalConfigurationStore.Create("")); + ScenarioExpect.Throws(() => ExternalConfigurationStore.Create().LoadFrom(null!)); + ScenarioExpect.Throws(() => ExternalConfigurationStore.Create().ValidateWith("", static _ => true)); + ScenarioExpect.Throws(() => ExternalConfigurationStore.Create().ValidateWith("invalid", null!)); + ScenarioExpect.Throws(() => ExternalConfigurationStore.Create().CacheFor(TimeSpan.FromMilliseconds(-1))); + ScenarioExpect.Throws(() => ExternalConfigurationStore.Create().Build()); + ScenarioExpect.Throws(() => new ExternalConfigurationSnapshot(null!, "v1", DateTimeOffset.UtcNow)); + ScenarioExpect.Throws(() => new ExternalConfigurationSnapshot(new("endpoint", true), "", DateTimeOffset.UtcNow)); + } + + private sealed record AppSettings(string Endpoint, bool Enabled); +}