Skip to content
Merged
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
14 changes: 13 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.ShopHostKitOptions>();
// "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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -12,4 +12,4 @@
"type": "git",
"url": "git+https://github.com/purview-dev/aspire-resourcekit.git"
}
}
}
45 changes: 40 additions & 5 deletions src/src/ResourceKit/OptionsHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,29 @@ public static string PathFor<TOptions>(Expression<Func<TOptions, object?>> selec
return GetMemberPath(selector);
}

static string ResolveSectionName<TOptions>(string? sectionNameOverride)
/// <summary>
/// Gets the configuration section name for the specified options type.
/// </summary>
/// <typeparam name="TOptions">The root options type.</typeparam>
/// <returns>
/// The <c>SectionName</c> constant when present; otherwise the type name trimmed of a known
/// suffix (<c>Options</c>, <c>Settings</c>, <c>Configuration</c>, <c>Config</c>); otherwise the type name.
/// </returns>
public static string SectionNameFor<TOptions>() => SectionNameFor(typeof(TOptions));

/// <summary>
/// Gets the configuration section name for the specified options type.
/// </summary>
/// <param name="optionsType">The options type.</param>
/// <returns>
/// The <c>SectionName</c> constant when present; otherwise the type name trimmed of a known
/// suffix (<c>Options</c>, <c>Settings</c>, <c>Configuration</c>, <c>Config</c>); otherwise the type name.
/// </returns>
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
);
Expand All @@ -82,7 +99,7 @@ static string ResolveSectionName<TOptions>(string? sectionNameOverride)
)
return constantValue;

var typeName = typeof(TOptions).Name;
var typeName = GetSectionNameCandidateTypeName(optionsType);
foreach (var suffix in SectionNameSuffixes)
{
if (!typeName.EndsWith(suffix, StringComparison.Ordinal))
Expand All @@ -95,6 +112,24 @@ static string ResolveSectionName<TOptions>(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<TOptions>(string? sectionNameOverride)
{
if (!string.IsNullOrWhiteSpace(sectionNameOverride))
return sectionNameOverride;

return SectionNameFor<TOptions>();
}

static TOptions CreateRootOptionsInstance<TOptions>()
{
var type = typeof(TOptions);
Expand Down
7 changes: 5 additions & 2 deletions src/src/ResourceKit/ResourceKitBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ private set
/// <param name="builder">The distributed application builder.</param>
/// <returns><see langword="true"/> when the resource should be built; otherwise <see langword="false"/>.</returns>
/// <remarks>
/// The default implementation returns <see cref="IsEnabled"/>.
/// The default implementation returns <see cref="IsEnabled"/>. This hook is only invoked when
/// <see cref="IsEnabled"/> is already <see langword="true"/>; a disabled kit is never re-enabled here.
/// </remarks>
protected virtual bool IsResourceEnabled(IDistributedApplicationBuilder builder) => IsEnabled;

Expand All @@ -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;

Expand Down
125 changes: 125 additions & 0 deletions src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -123,4 +191,61 @@ protected override IResourceBuilder<ParameterResource> BuildResource(
[NotNull] IDistributedApplicationBuilder builder
) => builder.AddParameter(Name, "value", secret: false);
}

sealed class TrackingResourceKit(TestHostKit hostKit)
: ResourceKitBase<TestHostKit, ParameterResource>(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<ParameterResource> 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<TestHostKit, ParameterResource>
{
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<ParameterResource> BuildResource(
[NotNull] IDistributedApplicationBuilder builder
)
{
BuildCalled = true;
return builder.AddParameter(Name, "value", secret: false);
}
}
}
12 changes: 12 additions & 0 deletions src/tests/ResourceKit.UnitTests/Models/OptionsModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,15 @@ sealed class NestedSampleOptions
{
public string Value { get; set; } = "nested-default";
}

sealed class GenericServiceOptions<T>
{
public RedisOptions Redis { get; set; } = new();
}

sealed class GenericSectionOptions<T>
{
public const string SectionName = "GenericSection";

public RedisOptions Redis { get; set; } = new();
}
80 changes: 80 additions & 0 deletions src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PrivateSectionOptions>();

// Assert
await Assert.That(sectionName).IsEqualTo(expected);
}

[Test]
public async Task SectionNameFor_GivenNoConstSection_TrimsKnownSuffix()
{
// Act
var fromOptions = OptionsHelper.SectionNameFor<ServiceOptions>();
var fromSettings = OptionsHelper.SectionNameFor<ServiceSettings>();
var fromConfiguration = OptionsHelper.SectionNameFor<ServiceConfiguration>();
var fromConfig = OptionsHelper.SectionNameFor<ServiceConfig>();

// 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<Options>();

// 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<ServiceOptions>();

// 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<GenericServiceOptions<int>>();

// 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<GenericSectionOptions<int>>();

// Assert
await Assert.That(fromOpenGeneric).IsEqualTo(expected);
await Assert.That(fromClosedGeneric).IsEqualTo(expected);
}

[Test]
public async Task Assign_GivenNoSectionOverride_UsesSectionNameConstValue()
{
Expand Down