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
33 changes: 26 additions & 7 deletions docs/pages/code-first.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,32 @@ required contract still reports its section when absent. The exported IDE schema
contract under its namespace, so editor completion sees the combined shape.

For gradual adoption, set CLI `coverage` to `registeredSections`: unowned root sections are
allowed, while registered sections still validate. To select the equivalent runtime policy,
configure `ConfixValidationSettings.StrictCoverage = false` in shared setup. The default
startup root-coverage check examines JSON providers and does not treat unrelated environment
variables as configuration errors. For other application sources, explicitly set
`ConfixValidationSettings.Sources`. Bound option values always use final IConfiguration,
including environment and command-line overrides. Build validation checks the deployment
snapshot; startup checks the application's actual inputs, so different outcomes are possible.
allowed, while registered sections still validate.

Coverage is a build-time concern only. `confix validate` and `confix build` check the deployment
snapshot for keys that no contract owns, because at that point the configuration artifact is
unambiguous. At runtime an unread key cannot break the application, so startup never fails over
one — it enforces the other direction: a required section must be present, and keys inside an
owned section must match its type. Bound option values always use final IConfiguration,
including environment and command-line overrides.

## Describing a section you bind yourself

Libraries usually register and bind their own options. `AddConfixSection<T>` describes such a
section without taking over the binding, so the shape is validated and `confix` can see it:

```csharp
services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();

services.AddConfixSection<SmtpOptions>(configuration);
```

Use `AddConfixOptions<T>` when Confix should own the registration outright, and the untyped
`AddConfixSection("Path")` to claim a section whose contents another component validates.

## Build and validate

Expand Down
41 changes: 41 additions & 0 deletions src/Confix.Options/ConfixActivation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Microsoft.Extensions.DependencyInjection;

namespace Confix;

/// <summary>
/// Marks an application as a Confix consumer. Registered by the application-facing registrations,
/// never by a library describing its own section.
/// </summary>
internal sealed class ConfixApplicationMarker;

/// <summary>Records an overlap that a library description could not resolve on its own.</summary>
internal sealed record ConfixConflict(string Message);

/// <summary>Whether a description registered by a library is enforced unconditionally.</summary>
internal enum ConfixEnforcement
{
Always,
WhenActive
}

internal static class ConfixActivation
{
private const string ValidationEnvironmentVariable = "CONFIX_VALIDATION";

/// <summary>
/// Library descriptions become enforceable while <c>confix</c> inspects the application, or
/// once the application itself uses Confix. Applications that do not use Confix see nothing.
/// </summary>
internal static bool IsActive(IServiceProvider services)
{
return IsValidationRun() || services.GetService<ConfixApplicationMarker>() is not null;
}

internal static bool IsValidationRun()
{
return string.Equals(
Environment.GetEnvironmentVariable(ValidationEnvironmentVariable),
"true",
StringComparison.OrdinalIgnoreCase);
}
}
166 changes: 119 additions & 47 deletions src/Confix.Options/ConfixOptionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,46 +16,50 @@ public static ConfixOptionsBuilder<T> AddConfixOptions<T>(
bool? required = null)
where T : class
{
var attribute = typeof(T).GetCustomAttribute<ConfixSectionAttribute>();

// Types owned by another package cannot be annotated, so the caller mounts them instead.
if (attribute is null && section is null)
{
throw new InvalidOperationException(
$"{typeof(T).Name} has no ConfixSection attribute, so a section must be supplied.");
}

section ??= attribute!.Path;
name ??= Options.DefaultName;
required ??= attribute?.Required ?? true;

EnsureValidSection(section);
EnsureSectionAvailable(services, section, typeof(T), name);

var contract = new Contract<T>(section, name, required.Value, configuration);

services.AddSingleton<IConfixContract>(contract);
services.AddSingleton<IValidateOptions<T>>(
sp => new ContractValidator<T>(contract, configuration, sp));
var contract = AddContract<T>(
services, configuration, section, name, required, ConfixEnforcement.Always);

var builder = new ConfixOptionsBuilder<T>(
services.AddOptions(), name, configuration, section, required.Value);
services.AddOptions(),
contract.Name,
configuration,
contract.Section,
contract.Required);

builder.Configure(value => Bind(value, configuration, section, name));
builder.Configure(value => Bind(value, configuration, contract.Section, contract.Name));

services.AddSingleton<IOptionsChangeTokenSource<T>>(
new ConfigurationChangeTokenSource<T>(name, configuration));
new ConfigurationChangeTokenSource<T>(contract.Name, configuration));

if (required.Value || HasSection(configuration, section))
if (contract.Required || HasSection(configuration, contract.Section))
{
builder.ValidateOnStart();
}

AddCoverageValidation(services, configuration);

return builder;
}

/// <summary>
/// Describes a section that the caller binds itself: its shape is validated and <c>confix</c>
/// can see it. Use this from libraries that already own their options registration.
/// <para>
/// Safe to call unconditionally. The description is inert until the application itself uses
/// Confix, or until <c>confix validate</c> inspects the application.
/// </para>
/// </summary>
public static IServiceCollection AddConfixSection<T>(
this IServiceCollection services,
IConfiguration configuration,
string? section = null,
string? name = null,
bool? required = null)
where T : class
{
AddContract<T>(services, configuration, section, name, required, ConfixEnforcement.WhenActive);

return services;
}

/// <summary>
/// Claims a section that another component owns: it counts towards coverage and is checked
/// for presence, but its contents are validated by whoever defines them.
Expand All @@ -69,14 +73,91 @@ public static IServiceCollection AddConfixSection(
ArgumentException.ThrowIfNullOrWhiteSpace(section);

EnsureValidSection(section);
EnsureSectionAvailable(services, section, typeof(ConfixSectionClaim), name: null);

// Libraries claim sections too, so an overlap is reported by validation rather than
// thrown into the face of an application that does not use Confix.
try
{
EnsureSectionAvailable(services, section, typeof(ConfixSectionClaim), name: null);
}
catch (InvalidOperationException ex)
{
services.AddSingleton(new ConfixConflict(ex.Message));

return services;
}

services.AddSingleton<IConfixContract>(new Claim(section, required, configuration));
AddCoverageValidation(services, configuration);

return services;
}

private static Contract<T> AddContract<T>(
IServiceCollection services,
IConfiguration configuration,
string? section,
string? name,
bool? required,
ConfixEnforcement enforcement)
where T : class
{
var attribute = typeof(T).GetCustomAttribute<ConfixSectionAttribute>();

// Types owned by another package cannot be annotated, so the caller mounts them instead.
if (attribute is null && section is null)
{
throw new InvalidOperationException(
$"{typeof(T).Name} has no ConfixSection attribute, so a section must be supplied.");
}

section ??= attribute!.Path;
name ??= Options.DefaultName;
required ??= attribute?.Required ?? true;

EnsureValidSection(section);

var contract = new Contract<T>(
section, name, required.Value, configuration, enforcement);

if (enforcement is ConfixEnforcement.Always)
{
EnsureSectionAvailable(services, section, typeof(T), name);
services.TryAddSingleton(new ConfixApplicationMarker());
}
else if (!TryReserveSection(services, contract))
{
// A library must never crash an application that does not use Confix, so an
// overlapping description is reported by validation instead of thrown here.
return contract;
}

// The runner resolves IOptionsMonitor<T>, so the open generics must be present even
// when the caller owns the binding.
services.AddOptions();
services.AddSingleton<IConfixContract>(contract);
services.AddSingleton<IValidateOptions<T>>(
sp => new ContractValidator<T>(contract, configuration, sp));

return contract;
}

private static bool TryReserveSection<T>(IServiceCollection services, Contract<T> contract)
where T : class
{
try
{
EnsureSectionAvailable(services, contract.Section, typeof(T), contract.Name);

return true;
}
catch (InvalidOperationException ex)
{
services.AddSingleton(new ConfixConflict(ex.Message));

return false;
}
}

private static void EnsureValidSection(string section)
{
if (section.Length > 0 && section.Split(':').Any(string.IsNullOrWhiteSpace))
Expand Down Expand Up @@ -104,22 +185,6 @@ internal static void Bind<T>(T value, IConfiguration configuration, string secti
}
}

// ValidateOnStart accumulates callbacks, so coverage is wired up only for the first contract.
private static void AddCoverageValidation(
IServiceCollection services,
IConfiguration configuration)
{
if (services.Any(descriptor => descriptor.ServiceType == typeof(CoverageSource)))
{
return;
}

services.AddSingleton(new CoverageSource(configuration));
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<CoverageOptions>, CoverageValidator>());
services.AddOptions<CoverageOptions>().ValidateOnStart();
}

private static void EnsureSectionAvailable(
IServiceCollection services,
string section,
Expand Down Expand Up @@ -185,7 +250,8 @@ private sealed record Contract<T>(
string Section,
string Name,
bool Required,
IConfiguration Configuration) : IConfixContract
IConfiguration Configuration,
ConfixEnforcement Enforcement) : IConfixContract
where T : class
{
public Type OptionsType => typeof(T);
Expand Down Expand Up @@ -224,7 +290,7 @@ public void Validate(IServiceProvider services)
}

private sealed class ContractValidator<T>(
IConfixContract contract,
Contract<T> contract,
IConfiguration configuration,
IServiceProvider services) : IValidateOptions<T>
where T : class
Expand All @@ -236,6 +302,12 @@ public ValidateOptionsResult Validate(string? name, T options)
return ValidateOptionsResult.Skip;
}

if (contract.Enforcement is ConfixEnforcement.WhenActive &&
!ConfixActivation.IsActive(services))
{
return ValidateOptionsResult.Skip;
}

var present = HasSection(configuration, contract.Section);

if (!contract.Required && !present)
Expand Down
2 changes: 2 additions & 0 deletions src/Confix.Options/ContractValidation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public static IReadOnlyList<string> Validate(

var errors = new List<string>();

errors.AddRange(services.GetServices<ConfixConflict>().Select(c => c.Message));

CheckContractConflicts(contracts, errors);

var hasRootContract = contracts.Any(contract => contract.Section.Length == 0);
Expand Down
Loading
Loading