From e4d5248466e2771b69f23c9772c5143b5dffb4e7 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 16 Sep 2026 01:03:47 +0100 Subject: [PATCH] fix: IsEnabled --- docs/configuration.md | 14 +- package.json | 4 +- src/src/ResourceKit/OptionsHelper.cs | 45 ++++++- src/src/ResourceKit/ResourceKitBase.cs | 7 +- .../HostAppResourceTests.cs | 125 ++++++++++++++++++ .../Models/OptionsModels.cs | 12 ++ .../OptionsHelperTests.cs | 80 +++++++++++ 7 files changed, 277 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 90c5ffa..b5bfcfa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -90,7 +90,7 @@ Use both together for flexible control: - `IsEnabled`: static/configured toggle (usually generated options). - `IsResourceEnabled(builder)`: runtime decision hook. -At runtime, `Build` sets `IsEnabled = IsResourceEnabled(builder)` first. If the result is `false`, ResourceKit skips both `BuildResource(...)` and `ConfigureResource()` for that resource. +At runtime, `Build` only calls `IsResourceEnabled(builder)` when `IsEnabled` is already `true`. If the hook returns `false`, ResourceKit skips both `BuildResource(...)` and `ConfigureResource()` for that resource. A kit disabled via `IsEnabled=false` (for example from options) is never re-enabled by the hook. Use this hook to react to runtime state, for example environment-specific availability, publish mode, or dynamic configuration checks. @@ -152,6 +152,18 @@ If you do not pass a section name explicitly, `OptionsHelper` resolves it as fol 2. Type name trimmed by one suffix: `Options`, `Settings`, `Configuration`, `Config` 3. Original type name +You can look up the resolved section name for any options type directly: + +```csharp +var sectionName = OptionsHelper.SectionNameFor(); +// "ShopHostKit" + +var sectionNameFromType = OptionsHelper.SectionNameFor(typeof(ShopHostKit.ShopHostKitOptions)); +// "ShopHostKit" +``` + +`SectionNameFor` accepts both a generic type argument and a `Type`, and applies the same resolution rules as `Assign` (suffix trimming works for generic types by ignoring type arguments). + ## Tip Prefer resource-level toggles over conditional host code. It keeps the composition model declarative and testable. diff --git a/package.json b/package.json index db5291c..522ef80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-aspire-appresources", - "version": "1.0.0-prerelease.27", + "version": "1.0.0-prerelease.28", "keywords": [], "homepage": "https://github.com/purview-dev/aspire-resourcekit#readme", "bugs": { @@ -12,4 +12,4 @@ "type": "git", "url": "git+https://github.com/purview-dev/aspire-resourcekit.git" } -} +} \ No newline at end of file diff --git a/src/src/ResourceKit/OptionsHelper.cs b/src/src/ResourceKit/OptionsHelper.cs index 6359f5f..5deb433 100644 --- a/src/src/ResourceKit/OptionsHelper.cs +++ b/src/src/ResourceKit/OptionsHelper.cs @@ -62,12 +62,29 @@ public static string PathFor(Expression> selec return GetMemberPath(selector); } - static string ResolveSectionName(string? sectionNameOverride) + /// + /// Gets the configuration section name for the specified options type. + /// + /// The root options type. + /// + /// The SectionName constant when present; otherwise the type name trimmed of a known + /// suffix (Options, Settings, Configuration, Config); otherwise the type name. + /// + public static string SectionNameFor() => SectionNameFor(typeof(TOptions)); + + /// + /// Gets the configuration section name for the specified options type. + /// + /// The options type. + /// + /// The SectionName constant when present; otherwise the type name trimmed of a known + /// suffix (Options, Settings, Configuration, Config); otherwise the type name. + /// + public static string SectionNameFor(Type optionsType) { - if (!string.IsNullOrWhiteSpace(sectionNameOverride)) - return sectionNameOverride; + ArgumentNullException.ThrowIfNull(optionsType); - var sectionNameField = typeof(TOptions).GetField( + var sectionNameField = optionsType.GetField( "SectionName", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.FlattenHierarchy ); @@ -82,7 +99,7 @@ static string ResolveSectionName(string? sectionNameOverride) ) return constantValue; - var typeName = typeof(TOptions).Name; + var typeName = GetSectionNameCandidateTypeName(optionsType); foreach (var suffix in SectionNameSuffixes) { if (!typeName.EndsWith(suffix, StringComparison.Ordinal)) @@ -95,6 +112,24 @@ static string ResolveSectionName(string? sectionNameOverride) return typeName; } + static string GetSectionNameCandidateTypeName(Type type) + { + var typeName = type.Name; + if (!type.IsGenericType) + return typeName; + + var arityMarker = typeName.IndexOf('`', StringComparison.Ordinal); + return arityMarker < 0 ? typeName : typeName[..arityMarker]; + } + + static string ResolveSectionName(string? sectionNameOverride) + { + if (!string.IsNullOrWhiteSpace(sectionNameOverride)) + return sectionNameOverride; + + return SectionNameFor(); + } + static TOptions CreateRootOptionsInstance() { var type = typeof(TOptions); diff --git a/src/src/ResourceKit/ResourceKitBase.cs b/src/src/ResourceKit/ResourceKitBase.cs index 5006e38..0313b91 100644 --- a/src/src/ResourceKit/ResourceKitBase.cs +++ b/src/src/ResourceKit/ResourceKitBase.cs @@ -70,7 +70,8 @@ private set /// The distributed application builder. /// when the resource should be built; otherwise . /// - /// The default implementation returns . + /// The default implementation returns . This hook is only invoked when + /// is already ; a disabled kit is never re-enabled here. /// protected virtual bool IsResourceEnabled(IDistributedApplicationBuilder builder) => IsEnabled; @@ -92,7 +93,9 @@ public void Build(IDistributedApplicationBuilder builder) ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(Name, nameof(Name)); - IsEnabled = IsResourceEnabled(builder); + if (IsEnabled) + IsEnabled = IsResourceEnabled(builder); + if (!IsEnabled) return; diff --git a/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs b/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs index ac41d12..3d3570c 100644 --- a/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs +++ b/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs @@ -19,6 +19,74 @@ public async Task Build_WhenDisabledByServices_DoesNotCallBuild() await Assert.That(resource.BuildCalled).IsFalse(); } + [Test] + public async Task Build_WhenIsEnabledIsFalse_DoesNotCallIsResourceEnabled() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostApp = new(); + TrackingResourceKit resource = new(hostApp) { IsEnabled = false }; + + // Act + resource.Build(builder); + + // Assert + await Assert.That(resource.IsEnabled).IsFalse(); + await Assert.That(resource.IsResourceEnabledCalled).IsFalse(); + await Assert.That(resource.BuildCalled).IsFalse(); + } + + [Test] + public async Task Build_WhenIsEnabledIsTrue_CallsIsResourceEnabled() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostApp = new(); + TrackingResourceKit resource = new(hostApp) { IsEnabled = true }; + + // Act + resource.Build(builder); + + // Assert + await Assert.That(resource.IsEnabled).IsTrue(); + await Assert.That(resource.IsResourceEnabledCalled).IsTrue(); + await Assert.That(resource.BuildCalled).IsTrue(); + } + + [Test] + public async Task Build_GivenOptionsSetIsEnabledToFalse_DoesNotCallIsResourceEnabled() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostApp = new(); + OptionTrackingResourceKit resource = new(hostApp, new OptionTrackingOptions { IsEnabled = false }); + + // Act + resource.Build(builder); + + // Assert + await Assert.That(resource.IsEnabled).IsFalse(); + await Assert.That(resource.IsResourceEnabledCalled).IsFalse(); + await Assert.That(resource.BuildCalled).IsFalse(); + } + + [Test] + public async Task Build_GivenOptionsSetIsEnabledToTrue_StillCallsIsResourceEnabled() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostApp = new(); + OptionTrackingResourceKit resource = new(hostApp, new OptionTrackingOptions { IsEnabled = true }); + + // Act + resource.Build(builder); + + // Assert + await Assert.That(resource.IsEnabled).IsTrue(); + await Assert.That(resource.IsResourceEnabledCalled).IsTrue(); + await Assert.That(resource.BuildCalled).IsTrue(); + } + [Test] public async Task Build_WhenEnabled_CallsBuildAndSetsResourceBuilder() { @@ -123,4 +191,61 @@ protected override IResourceBuilder BuildResource( [NotNull] IDistributedApplicationBuilder builder ) => builder.AddParameter(Name, "value", secret: false); } + + sealed class TrackingResourceKit(TestHostKit hostKit) + : ResourceKitBase(hostKit, "test") + { + public bool IsResourceEnabledCalled { get; private set; } + + public bool BuildCalled { get; private set; } + + protected override bool IsResourceEnabled([NotNull] IDistributedApplicationBuilder builder) + { + IsResourceEnabledCalled = true; + return true; + } + + protected override IResourceBuilder BuildResource( + [NotNull] IDistributedApplicationBuilder builder + ) + { + BuildCalled = true; + return builder.AddParameter(Name, "value", secret: false); + } + } + + sealed class OptionTrackingOptions + { + public bool IsEnabled { get; set; } = true; + } + + sealed class OptionTrackingResourceKit : ResourceKitBase + { + public OptionTrackingResourceKit(TestHostKit hostKit, OptionTrackingOptions options) + : base(hostKit, "test") + { + Options = options; + IsEnabled = options.IsEnabled; + } + + public OptionTrackingOptions Options { get; } + + public bool IsResourceEnabledCalled { get; private set; } + + public bool BuildCalled { get; private set; } + + protected override bool IsResourceEnabled([NotNull] IDistributedApplicationBuilder builder) + { + IsResourceEnabledCalled = true; + return true; + } + + protected override IResourceBuilder BuildResource( + [NotNull] IDistributedApplicationBuilder builder + ) + { + BuildCalled = true; + return builder.AddParameter(Name, "value", secret: false); + } + } } diff --git a/src/tests/ResourceKit.UnitTests/Models/OptionsModels.cs b/src/tests/ResourceKit.UnitTests/Models/OptionsModels.cs index 87ae4c6..8b64e32 100644 --- a/src/tests/ResourceKit.UnitTests/Models/OptionsModels.cs +++ b/src/tests/ResourceKit.UnitTests/Models/OptionsModels.cs @@ -136,3 +136,15 @@ sealed class NestedSampleOptions { public string Value { get; set; } = "nested-default"; } + +sealed class GenericServiceOptions +{ + public RedisOptions Redis { get; set; } = new(); +} + +sealed class GenericSectionOptions +{ + public const string SectionName = "GenericSection"; + + public RedisOptions Redis { get; set; } = new(); +} diff --git a/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs b/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs index 10cbea7..17a76ad 100644 --- a/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs +++ b/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs @@ -68,6 +68,86 @@ public async Task Assign_WithVariables_GeneratesCorrectSet() await Assert.That(args[2]).IsEqualTo($"--TestOptions:MoreOptions:EvenMore:EndOfTheLine={_aTestingValue}"); } + [Test] + public async Task SectionNameFor_GivenSectionNameConst_ReturnsConstValue() + { + // Arrange + const string expected = "PrivateSection"; + + // Act + var sectionName = OptionsHelper.SectionNameFor(); + + // Assert + await Assert.That(sectionName).IsEqualTo(expected); + } + + [Test] + public async Task SectionNameFor_GivenNoConstSection_TrimsKnownSuffix() + { + // Act + var fromOptions = OptionsHelper.SectionNameFor(); + var fromSettings = OptionsHelper.SectionNameFor(); + var fromConfiguration = OptionsHelper.SectionNameFor(); + var fromConfig = OptionsHelper.SectionNameFor(); + + // Assert + await Assert.That(fromOptions).IsEqualTo("Service"); + await Assert.That(fromSettings).IsEqualTo("Service"); + await Assert.That(fromConfiguration).IsEqualTo("Service"); + await Assert.That(fromConfig).IsEqualTo("Service"); + } + + [Test] + public async Task SectionNameFor_GivenTypeNameOnlySuffix_UsesOriginalTypeName() + { + // Act + var sectionName = OptionsHelper.SectionNameFor(); + + // Assert + await Assert.That(sectionName).IsEqualTo("Options"); + } + + [Test] + public async Task SectionNameFor_GivenTypeOverload_ReturnsSameAsGeneric() + { +#pragma warning disable CA2263 // Prefer generic overload; this test intentionally exercises the Type overload. + // Act + var fromType = OptionsHelper.SectionNameFor(typeof(ServiceOptions)); + var fromGeneric = OptionsHelper.SectionNameFor(); + + // Assert + await Assert.That(fromType).IsEqualTo(fromGeneric); + await Assert.That(fromType).IsEqualTo("Service"); +#pragma warning restore CA2263 + } + + [Test] + public async Task SectionNameFor_GivenGenericType_TrimsKnownSuffix() + { + // Act + var fromOpenGeneric = OptionsHelper.SectionNameFor(typeof(GenericServiceOptions<>)); + var fromClosedGeneric = OptionsHelper.SectionNameFor>(); + + // Assert + await Assert.That(fromOpenGeneric).IsEqualTo("GenericService"); + await Assert.That(fromClosedGeneric).IsEqualTo("GenericService"); + } + + [Test] + public async Task SectionNameFor_GivenGenericTypeWithConst_ReturnsConstValue() + { + // Arrange + const string expected = "GenericSection"; + + // Act + var fromOpenGeneric = OptionsHelper.SectionNameFor(typeof(GenericSectionOptions<>)); + var fromClosedGeneric = OptionsHelper.SectionNameFor>(); + + // Assert + await Assert.That(fromOpenGeneric).IsEqualTo(expected); + await Assert.That(fromClosedGeneric).IsEqualTo(expected); + } + [Test] public async Task Assign_GivenNoSectionOverride_UsesSectionNameConstValue() {