Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ private static void EmitFlagsServices(IServiceCollection services, ConfigManager

services.AddSingleton<IFeatureFlagsDescriptors>(capability.Descriptors);
foreach (var r in capability.Registrations)
{
services.Add(new ServiceDescriptor(r.Descriptor.Type, r.Descriptor.Type, ServiceLifetime.Singleton));
EmitReactiveServiceForClassConfig(services, r.Descriptor.Type, typeof(IFeatureFlags<>));
}

// Register all resolver types with lifetimes from Capabilities
EmitResolverServices(services,
Expand All @@ -132,7 +135,10 @@ private static void EmitEntitlementsServices(IServiceCollection services, Config

services.AddSingleton<IEntitlementsDescriptors>(capability.Descriptors);
foreach (var r in capability.Registrations)
{
services.Add(new ServiceDescriptor(r.Descriptor.Type, r.Descriptor.Type, ServiceLifetime.Singleton));
EmitReactiveServiceForClassConfig(services, r.Descriptor.Type, typeof(IEntitlements<>));
}

// Register all resolver types with lifetimes from Capabilities
EmitResolverServices(services,
Expand Down Expand Up @@ -216,6 +222,33 @@ private static void EmitConfigService(
}
}

/// <summary>
/// A flag or entitlement class is registered as its own implementation type, so the container builds it and
/// must resolve the <see cref="IReactiveConfig{T}"/> its generated constructor asks for. When the class reads
/// from a value tuple of configs — the documented "Multiple Config Sources" pattern — that service type is not
/// one of the per-config-type registrations emitted from the rule plan, so it has to be added here.
/// </summary>
private static void EmitReactiveServiceForClassConfig(
IServiceCollection services, Type flagClassType, Type openFlagInterface)
{
var configType = flagClassType.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == openFlagInterface)
?.GetGenericArguments()[0];

if (configType is null)
{
return;
}

var reactiveType = typeof(IReactiveConfig<>).MakeGenericType(configType);
if (services.Any(d => d.ServiceType == reactiveType))
{
return;
}

EmitReactiveService(services, configType);
}

private static void EmitReactiveService(IServiceCollection services, Type serviceType)
{
var reactiveType = typeof(IReactiveConfig<>).MakeGenericType(serviceType);
Expand Down
15 changes: 8 additions & 7 deletions src/Cocoar.Configuration/Core/ConfigurationAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@
private readonly bool _preferPendingState;
private ConfigManagerCapabilityScope? _capabilityScope;

/// <param name="preferPendingState">
/// Resolve from the configuration state before the backplane. The backplane only ever holds the last
/// COMMITTED snapshot, so a rule factory reading it mid-recompute sees the previous pass — which is what
/// made config-aware rules (derived paths, URLs, values) lag or freeze. The engine's accessor sets this so
/// factories observe the in-flight pass, exactly as guide/configuration/config-aware.md describes; accessors
/// handed to application code leave it false and keep reading committed state only.
/// </param>
/// <remarks>
/// <paramref name="preferPendingState"/> resolves from the configuration state before the backplane. The
/// backplane only ever holds the last COMMITTED snapshot, so a rule factory reading it mid-recompute sees the
/// previous pass — which is what made config-aware rules (derived paths, URLs, values) lag or freeze. The
/// engine's accessor sets it so factories observe the in-flight pass, exactly as
/// guide/configuration/config-aware.md describes; accessors handed to application code leave it false and
/// keep reading committed state only.
/// </remarks>
public ConfigurationAccessor(
ConfigurationState state,
ExposureRegistry bindingRegistry,
Expand Down Expand Up @@ -77,7 +78,7 @@
// First try the backplane (has cached instances after initialization)
try
{
var result = _state.Backplane.GetConfig(typeof(T));

Check warning on line 81 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Prefer the generic overload 'Cocoar.Configuration.Core.MasterBackplane.GetConfig<T>()' instead of 'Cocoar.Configuration.Core.MasterBackplane.GetConfig(System.Type)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 81 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Prefer the generic overload 'Cocoar.Configuration.Core.MasterBackplane.GetConfig<T>()' instead of 'Cocoar.Configuration.Core.MasterBackplane.GetConfig(System.Type)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 81 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Prefer the generic overload 'Cocoar.Configuration.Core.MasterBackplane.GetConfig<T>()' instead of 'Cocoar.Configuration.Core.MasterBackplane.GetConfig(System.Type)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)
if (result is T typed)
{
return typed;
Expand Down
8 changes: 6 additions & 2 deletions src/Cocoar.Configuration/Flags/IEntitlements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ namespace Cocoar.Configuration.Flags;
/// Implement this on a partial class — the source generator produces the constructor and Config property.
/// Entitlements are permanent and have no expiration.
/// </summary>
/// <typeparam name="TConfig">The configuration type (or value tuple of types) this entitlement class reads from.</typeparam>
public interface IEntitlements<TConfig> where TConfig : class
/// <typeparam name="TConfig">
/// The configuration type this entitlement class reads from, or a value tuple of types to read from several at
/// once. Unconstrained on purpose: the generator maps this straight onto <c>IReactiveConfig&lt;TConfig&gt;</c>,
/// which is equally unconstrained, and a <c>class</c> constraint would reject the documented tuple form.
/// </typeparam>
public interface IEntitlements<TConfig>
{
}
8 changes: 6 additions & 2 deletions src/Cocoar.Configuration/Flags/IFeatureFlags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ namespace Cocoar.Configuration.Flags;
/// Implement this on a partial class — the source generator produces the constructor, Config property,
/// and <c>IsExpired</c> property.
/// </summary>
/// <typeparam name="TConfig">The configuration type (or value tuple of types) this flag class reads from.</typeparam>
public interface IFeatureFlags<TConfig> where TConfig : class
/// <typeparam name="TConfig">
/// The configuration type this flag class reads from, or a value tuple of types to read from several at once.
/// Unconstrained on purpose: the generator maps this straight onto <c>IReactiveConfig&lt;TConfig&gt;</c>, which is
/// equally unconstrained, and a <c>class</c> constraint would reject the documented tuple form.
/// </typeparam>
public interface IFeatureFlags<TConfig>
{
/// <summary>
/// When should these flags be removed from code?
Expand Down
76 changes: 76 additions & 0 deletions src/tests/Cocoar.Configuration.DI.Tests/TupleConfigFlagsDiTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Xunit;
using Cocoar.Configuration.DI;
using Cocoar.Configuration.Flags;
using Cocoar.Configuration.Providers;
using Microsoft.Extensions.DependencyInjection;

namespace Cocoar.Configuration.DI.Tests;

public class DiFeatureConfig
{
public bool NewCheckoutEnabled { get; set; }
}

public class DiTenantConfig
{
public bool AllowExperiments { get; set; }
}

/// <summary>
/// The "Multiple Config Sources" pattern from guide/flags/defining-flags.md, resolved through the container.
/// </summary>
public partial class DiRolloutFlags : IFeatureFlags<(DiFeatureConfig Features, DiTenantConfig Tenant)>
{
public DateTimeOffset ExpiresAt => new(2099, 9, 1, 0, 0, 0, TimeSpan.Zero);

public bool NewCheckout() => Config.Features.NewCheckoutEnabled && Config.Tenant.AllowExperiments;
}

public partial class DiTenantEntitlements : IEntitlements<(DiFeatureConfig Features, DiTenantConfig Tenant)>
{
public bool MayExperiment() => Config.Tenant.AllowExperiments;
}

/// <summary>
/// A flag class is registered as its own implementation type, so the container constructs it and has to resolve
/// the <c>IReactiveConfig&lt;TConfig&gt;</c> its generated constructor asks for. For a tuple config that is a
/// different service type than the per-config-type reactive registrations, which is the case this covers.
/// </summary>
public class TupleConfigFlagsDiTests
{
[Fact]
public void TupleConfigFlagClass_ResolvesFromTheContainer()
{
var services = new ServiceCollection();
services.AddCocoarConfiguration(c => c
.UseConfiguration(rules =>
[
rules.For<DiFeatureConfig>().FromStaticJson("""{"NewCheckoutEnabled":true}"""),
rules.For<DiTenantConfig>().FromStaticJson("""{"AllowExperiments":true}"""),
])
.UseFeatureFlags(flags => [flags.Register<DiRolloutFlags>()]));

using var sp = services.BuildServiceProvider();

var flags = sp.GetRequiredService<DiRolloutFlags>();

Assert.True(flags.NewCheckout());
}

[Fact]
public void TupleConfigEntitlementClass_ResolvesFromTheContainer()
{
var services = new ServiceCollection();
services.AddCocoarConfiguration(c => c
.UseConfiguration(rules =>
[
rules.For<DiFeatureConfig>().FromStaticJson("""{"NewCheckoutEnabled":false}"""),
rules.For<DiTenantConfig>().FromStaticJson("""{"AllowExperiments":true}"""),
])
.UseEntitlements(e => [e.Register<DiTenantEntitlements>()]));

using var sp = services.BuildServiceProvider();

Assert.True(sp.GetRequiredService<DiTenantEntitlements>().MayExperiment());
}
}
109 changes: 109 additions & 0 deletions src/tests/Cocoar.Configuration.Flags.Tests/TupleConfigFlagsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using Cocoar.Configuration.Core;
using Cocoar.Configuration.Providers;
using Cocoar.Configuration.Reactive;
using NSubstitute;

namespace Cocoar.Configuration.Flags.Tests;

public class FeatureConfig
{
public bool NewCheckoutEnabled { get; set; }
}

public class TenantConfig
{
public bool AllowExperiments { get; set; }
}

/// <summary>
/// The "Multiple Config Sources" pattern from guide/flags/defining-flags.md, using a named value tuple. The
/// interface's own typeparam doc promises "the configuration type (or value tuple of types)".
/// </summary>
public partial class RolloutFlags : IFeatureFlags<(FeatureConfig Features, TenantConfig Tenant)>
{
public DateTimeOffset ExpiresAt => new(2099, 9, 1, 0, 0, 0, TimeSpan.Zero);

public bool NewCheckout() => Config.Features.NewCheckoutEnabled && Config.Tenant.AllowExperiments;
}

/// <summary>Same for entitlements, which carry the identical typeparam promise.</summary>
public partial class TenantEntitlements : IEntitlements<(FeatureConfig Features, TenantConfig Tenant)>
{
public bool MayExperiment() => Config.Tenant.AllowExperiments;
}

public class TupleConfigFlagsTests
{
[Fact]
public void FlagClass_WithTupleConfig_ReadsBothConfigs()
{
var reactive = Substitute.For<IReactiveConfig<(FeatureConfig Features, TenantConfig Tenant)>>();
reactive.CurrentValue.Returns((
new FeatureConfig { NewCheckoutEnabled = true },
new TenantConfig { AllowExperiments = true }));

var flags = new RolloutFlags(reactive);

Assert.True(flags.NewCheckout());
Assert.Equal(new DateTimeOffset(2099, 9, 1, 0, 0, 0, TimeSpan.Zero), flags.ExpiresAt);
}

[Fact]
public void FlagClass_WithTupleConfig_RequiresBothConfigs()
{
var reactive = Substitute.For<IReactiveConfig<(FeatureConfig Features, TenantConfig Tenant)>>();
reactive.CurrentValue.Returns((
new FeatureConfig { NewCheckoutEnabled = true },
new TenantConfig { AllowExperiments = false }));

Assert.False(new RolloutFlags(reactive).NewCheckout());
}

/// <summary>
/// The path that actually matters: registration via <c>UseFeatureFlags</c> and resolution through
/// <c>GetFeatureFlags&lt;T&gt;()</c>, which reflects over the generated constructor and calls
/// <c>ConfigManager.GetReactiveConfig&lt;TConfig&gt;()</c> with the tuple type. Constructing the class
/// directly (as the tests above do) would not exercise that.
/// </summary>
[Fact]
public void FlagClass_WithTupleConfig_ResolvesThroughTheConfigManager()
{
using var manager = ConfigManager.Create(c => c
.UseConfiguration(rules =>
[
rules.For<FeatureConfig>().FromStaticJson("""{"NewCheckoutEnabled":true}"""),
rules.For<TenantConfig>().FromStaticJson("""{"AllowExperiments":true}"""),
])
.UseFeatureFlags(flags => [flags.Register<RolloutFlags>()]));

var flags = manager.GetFeatureFlags<RolloutFlags>();

Assert.True(flags.NewCheckout());
}

/// <summary>Same for the entitlement path, which resolves through its own setup and cache.</summary>
[Fact]
public void EntitlementClass_WithTupleConfig_ResolvesThroughTheConfigManager()
{
using var manager = ConfigManager.Create(c => c
.UseConfiguration(rules =>
[
rules.For<FeatureConfig>().FromStaticJson("""{"NewCheckoutEnabled":false}"""),
rules.For<TenantConfig>().FromStaticJson("""{"AllowExperiments":true}"""),
])
.UseEntitlements(e => [e.Register<TenantEntitlements>()]));

Assert.True(manager.GetEntitlements<TenantEntitlements>().MayExperiment());
}

[Fact]
public void EntitlementClass_WithTupleConfig_ReadsItsConfig()
{
var reactive = Substitute.For<IReactiveConfig<(FeatureConfig Features, TenantConfig Tenant)>>();
reactive.CurrentValue.Returns((
new FeatureConfig(),
new TenantConfig { AllowExperiments = true }));

Assert.True(new TenantEntitlements(reactive).MayExperiment());
}
}
Loading