From 1166881642fb43fb847f06ae7b1a543bd6db6855 Mon Sep 17 00:00:00 2001 From: Gabriel-Lucaci_slch Date: Wed, 16 Sep 2026 21:00:46 +0200 Subject: [PATCH] feat: refine code-first API with enhanced section binding and validation mechanisms --- docs/pages/code-first.mdx | 33 ++- src/Confix.Options/ConfixActivation.cs | 41 ++++ src/Confix.Options/ConfixOptionsExtensions.cs | 166 ++++++++++----- src/Confix.Options/ContractValidation.cs | 2 + src/Confix.Options/CoverageValidator.cs | 98 --------- .../CodeFirst/CoverageValidatorTests.cs | 193 ------------------ .../CodeFirst/ExternalSectionTests.cs | 17 +- .../CodeFirst/LibraryDescriptionTests.cs | 110 ++++++++++ .../CodeFirst/OptionsTests.cs | 15 +- .../CodeFirst/RegistrationTests.cs | 30 ++- 10 files changed, 332 insertions(+), 373 deletions(-) create mode 100644 src/Confix.Options/ConfixActivation.cs delete mode 100644 src/Confix.Options/CoverageValidator.cs delete mode 100644 src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/CoverageValidatorTests.cs create mode 100644 src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/LibraryDescriptionTests.cs diff --git a/docs/pages/code-first.mdx b/docs/pages/code-first.mdx index b7a8df9..57c08e6 100644 --- a/docs/pages/code-first.mdx +++ b/docs/pages/code-first.mdx @@ -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` describes such a +section without taking over the binding, so the shape is validated and `confix` can see it: + +```csharp +services + .AddOptions() + .BindConfiguration(SmtpOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + +services.AddConfixSection(configuration); +``` + +Use `AddConfixOptions` when Confix should own the registration outright, and the untyped +`AddConfixSection("Path")` to claim a section whose contents another component validates. ## Build and validate diff --git a/src/Confix.Options/ConfixActivation.cs b/src/Confix.Options/ConfixActivation.cs new file mode 100644 index 0000000..de1efcc --- /dev/null +++ b/src/Confix.Options/ConfixActivation.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Confix; + +/// +/// Marks an application as a Confix consumer. Registered by the application-facing registrations, +/// never by a library describing its own section. +/// +internal sealed class ConfixApplicationMarker; + +/// Records an overlap that a library description could not resolve on its own. +internal sealed record ConfixConflict(string Message); + +/// Whether a description registered by a library is enforced unconditionally. +internal enum ConfixEnforcement +{ + Always, + WhenActive +} + +internal static class ConfixActivation +{ + private const string ValidationEnvironmentVariable = "CONFIX_VALIDATION"; + + /// + /// Library descriptions become enforceable while confix inspects the application, or + /// once the application itself uses Confix. Applications that do not use Confix see nothing. + /// + internal static bool IsActive(IServiceProvider services) + { + return IsValidationRun() || services.GetService() is not null; + } + + internal static bool IsValidationRun() + { + return string.Equals( + Environment.GetEnvironmentVariable(ValidationEnvironmentVariable), + "true", + StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Confix.Options/ConfixOptionsExtensions.cs b/src/Confix.Options/ConfixOptionsExtensions.cs index 0095148..f722f93 100644 --- a/src/Confix.Options/ConfixOptionsExtensions.cs +++ b/src/Confix.Options/ConfixOptionsExtensions.cs @@ -16,46 +16,50 @@ public static ConfixOptionsBuilder AddConfixOptions( bool? required = null) where T : class { - var attribute = typeof(T).GetCustomAttribute(); - - // 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(section, name, required.Value, configuration); - - services.AddSingleton(contract); - services.AddSingleton>( - sp => new ContractValidator(contract, configuration, sp)); + var contract = AddContract( + services, configuration, section, name, required, ConfixEnforcement.Always); var builder = new ConfixOptionsBuilder( - 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>( - new ConfigurationChangeTokenSource(name, configuration)); + new ConfigurationChangeTokenSource(contract.Name, configuration)); - if (required.Value || HasSection(configuration, section)) + if (contract.Required || HasSection(configuration, contract.Section)) { builder.ValidateOnStart(); } - AddCoverageValidation(services, configuration); - return builder; } + /// + /// Describes a section that the caller binds itself: its shape is validated and confix + /// can see it. Use this from libraries that already own their options registration. + /// + /// Safe to call unconditionally. The description is inert until the application itself uses + /// Confix, or until confix validate inspects the application. + /// + /// + public static IServiceCollection AddConfixSection( + this IServiceCollection services, + IConfiguration configuration, + string? section = null, + string? name = null, + bool? required = null) + where T : class + { + AddContract(services, configuration, section, name, required, ConfixEnforcement.WhenActive); + + return services; + } + /// /// 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. @@ -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(new Claim(section, required, configuration)); - AddCoverageValidation(services, configuration); return services; } + private static Contract AddContract( + IServiceCollection services, + IConfiguration configuration, + string? section, + string? name, + bool? required, + ConfixEnforcement enforcement) + where T : class + { + var attribute = typeof(T).GetCustomAttribute(); + + // 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( + 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, so the open generics must be present even + // when the caller owns the binding. + services.AddOptions(); + services.AddSingleton(contract); + services.AddSingleton>( + sp => new ContractValidator(contract, configuration, sp)); + + return contract; + } + + private static bool TryReserveSection(IServiceCollection services, Contract 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)) @@ -104,22 +185,6 @@ internal static void Bind(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, CoverageValidator>()); - services.AddOptions().ValidateOnStart(); - } - private static void EnsureSectionAvailable( IServiceCollection services, string section, @@ -185,7 +250,8 @@ private sealed record Contract( string Section, string Name, bool Required, - IConfiguration Configuration) : IConfixContract + IConfiguration Configuration, + ConfixEnforcement Enforcement) : IConfixContract where T : class { public Type OptionsType => typeof(T); @@ -224,7 +290,7 @@ public void Validate(IServiceProvider services) } private sealed class ContractValidator( - IConfixContract contract, + Contract contract, IConfiguration configuration, IServiceProvider services) : IValidateOptions where T : class @@ -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) diff --git a/src/Confix.Options/ContractValidation.cs b/src/Confix.Options/ContractValidation.cs index ec69278..9e5cf21 100644 --- a/src/Confix.Options/ContractValidation.cs +++ b/src/Confix.Options/ContractValidation.cs @@ -38,6 +38,8 @@ public static IReadOnlyList Validate( var errors = new List(); + errors.AddRange(services.GetServices().Select(c => c.Message)); + CheckContractConflicts(contracts, errors); var hasRootContract = contracts.Any(contract => contract.Section.Length == 0); diff --git a/src/Confix.Options/CoverageValidator.cs b/src/Confix.Options/CoverageValidator.cs deleted file mode 100644 index ac3f014..0000000 --- a/src/Confix.Options/CoverageValidator.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Confix; - -public sealed class ConfixValidationSettings -{ - public bool StrictCoverage { get; set; } = true; - - /// - /// Explicit application source boundary for startup coverage. Defaults to JSON providers. - /// - public IConfiguration? Sources { get; set; } -} - -internal sealed class CoverageOptions; - -internal sealed record CoverageSource(IConfiguration Configuration); - -/// -/// At startup check owned JSON paths, without treating ambient environment keys as appsettings. -/// -internal sealed class CoverageValidator(IServiceProvider services, CoverageSource source) - : IValidateOptions -{ - private const string JsonProviderNamespace = "Microsoft.Extensions.Configuration.Json"; - - public ValidateOptionsResult Validate(string? name, CoverageOptions options) - { - var settings = services.GetService>()?.Value ?? new(); - - if (!settings.StrictCoverage) - { - return ValidateOptionsResult.Success; - } - - if (settings.Sources is { } selected) - { - return ValidateCoverage(selected); - } - - if (source.Configuration is not IConfigurationRoot root) - { - return ValidateOptionsResult.Fail( - "Confix startup coverage needs an IConfigurationRoot with explicit JSON sources."); - } - - var data = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var provider in root.Providers) - { - if (provider.GetType().Namespace == JsonProviderNamespace) - { - Collect(provider, null, data); - } - } - - using var configuration = (ConfigurationRoot)new ConfigurationBuilder() - .AddInMemoryCollection(data) - .Build(); - - return ValidateCoverage(configuration); - } - - private ValidateOptionsResult ValidateCoverage(IConfiguration configuration) - { - var contracts = services.GetServices().ToArray(); - var errors = new List(); - - if (!contracts.Any(contract => contract.Section.Length == 0)) - { - ContractValidation.CheckCoverage(configuration, string.Empty, contracts, errors); - } - - return errors.Count == 0 - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(errors); - } - - private static void Collect( - IConfigurationProvider provider, - string? parent, - Dictionary data) - { - foreach (var key in provider.GetChildKeys([], parent).Distinct(StringComparer.OrdinalIgnoreCase)) - { - var path = parent is null ? key : parent + ":" + key; - - if (provider.TryGet(path, out var value)) - { - data[path] = value; - } - - Collect(provider, path, data); - } - } -} diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/CoverageValidatorTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/CoverageValidatorTests.cs deleted file mode 100644 index e8917ab..0000000 --- a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/CoverageValidatorTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text; -using Confix; -using FluentAssertions; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Confix.CodeFirst.Tests; - -/// Covers the startup-time root coverage check and its source boundary. -public sealed class CoverageValidatorTests -{ - [Fact] - public void AmbientEnvironmentKeysAreNotTreatedAsApplicationConfiguration() - { - using var configuration = (ConfigurationRoot)new ConfigurationBuilder() - .AddJsonStream(Stream("{\"Mail\":{\"Host\":\"server\"}}")) - .AddInMemoryCollection(new Dictionary - { - ["PATH"] = "/usr/bin", - ["SOME_CI_VARIABLE"] = "1" - }) - .Build(); - - using var provider = Services(configuration).BuildServiceProvider(); - - Action start = () => provider.GetRequiredService().Validate(); - - start.Should().NotThrow(); - } - - [Fact] - public void UnownedJsonRootsFailStartup() - { - using var configuration = Json("{\"Mail\":{\"Host\":\"server\"},\"Rogue\":{\"a\":1}}"); - using var provider = Services(configuration).BuildServiceProvider(); - - Action start = () => provider.GetRequiredService().Validate(); - - start.Should().Throw() - .Which.Failures.Should().Contain(f => f.Contains("Rogue")); - } - - [Fact] - public void StrictCoverageCanBeDisabledForGradualAdoption() - { - using var configuration = Json("{\"Mail\":{\"Host\":\"server\"},\"Rogue\":{\"a\":1}}"); - var services = Services(configuration); - services.Configure(s => s.StrictCoverage = false); - using var provider = services.BuildServiceProvider(); - - Action start = () => provider.GetRequiredService().Validate(); - - start.Should().NotThrow(); - } - - [Fact] - public void AnExplicitSourceBoundaryReplacesTheJsonProviderDefault() - { - using var configuration = Json("{\"Mail\":{\"Host\":\"server\"},\"Rogue\":{\"a\":1}}"); - using var boundary = (ConfigurationRoot)new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { ["Mail:Host"] = "server" }) - .Build(); - - var services = Services(configuration); - services.Configure(s => s.Sources = boundary); - using var provider = services.BuildServiceProvider(); - - Action start = () => provider.GetRequiredService().Validate(); - - start.Should().NotThrow(); - } - - [Fact] - public void AnExplicitSourceBoundaryStillReportsUnownedSections() - { - using var configuration = Json("{\"Mail\":{\"Host\":\"server\"}}"); - using var boundary = (ConfigurationRoot)new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { ["Rogue:Key"] = "value" }) - .Build(); - - var services = Services(configuration); - services.Configure(s => s.Sources = boundary); - using var provider = services.BuildServiceProvider(); - - Action start = () => provider.GetRequiredService().Validate(); - - start.Should().Throw() - .Which.Failures.Should().Contain(f => f.Contains("Rogue")); - } - - [Fact] - public void ANonRootConfigurationNeedsAnExplicitSourceBoundary() - { - using var root = Json("{\"Mail\":{\"Host\":\"server\"}}"); - var services = new ServiceCollection(); - services.AddConfixOptions(Wrap(root)); - using var provider = services.BuildServiceProvider(); - - Action start = () => provider.GetRequiredService().Validate(); - - start.Should().Throw() - .Which.Failures.Should().Contain(f => f.Contains("IConfigurationRoot")); - } - - [Fact] - public void ARootContractSuppressesCoverageEntirely() - { - using var configuration = Json("{\"Host\":\"server\"}"); - var services = new ServiceCollection(); - services.AddConfixOptions(configuration); - using var provider = services.BuildServiceProvider(); - - Action start = () => provider.GetRequiredService().Validate(); - - start.Should().NotThrow(); - } - - [Fact] - public void NestedOwnedSectionsAreWalkedRatherThanRejected() - { - using var configuration = Json("{\"Messaging\":{\"Smtp\":{\"Host\":\"server\"}}}"); - var services = new ServiceCollection(); - services.AddConfixOptions(configuration); - using var provider = services.BuildServiceProvider(); - - Action start = () => provider.GetRequiredService().Validate(); - - start.Should().NotThrow(); - } - - /// Hides IConfigurationRoot so the validator cannot enumerate providers. - private static IConfiguration Wrap(IConfiguration configuration) - { - return new WrappedConfiguration(configuration); - } - - private static ServiceCollection Services(IConfiguration configuration) - { - var services = new ServiceCollection(); - services.AddConfixOptions(configuration); - - return services; - } - - private static ConfigurationRoot Json(string json) - { - return (ConfigurationRoot)new ConfigurationBuilder().AddJsonStream(Stream(json)).Build(); - } - - private static MemoryStream Stream(string json) - { - return new MemoryStream(Encoding.UTF8.GetBytes(json)); - } - - private sealed class WrappedConfiguration(IConfiguration inner) : IConfiguration - { - public string? this[string key] - { - get => inner[key]; - set => inner[key] = value; - } - - public IEnumerable GetChildren() => inner.GetChildren(); - - public Microsoft.Extensions.Primitives.IChangeToken GetReloadToken() - => inner.GetReloadToken(); - - public IConfigurationSection GetSection(string key) => inner.GetSection(key); - } - - [ConfixSection("Mail")] - public sealed class Mail - { - [Required] - public string Host { get; set; } = ""; - } - - [ConfixSection("")] - public sealed class RootMail - { - [Required] - public string Host { get; set; } = ""; - } - - [ConfixSection("Messaging:Smtp")] - public sealed class MountedMail - { - [Required] - public string Host { get; set; } = ""; - } -} diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/ExternalSectionTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/ExternalSectionTests.cs index 1833734..7d1bb27 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/ExternalSectionTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/ExternalSectionTests.cs @@ -86,7 +86,7 @@ public void RequirednessCanBeOverriddenAtTheRegistrationSite() using var provider = services.BuildServiceProvider(); ContractValidation.Validate(provider, configuration).Should().BeEmpty(); - provider.GetRequiredService().Validate(); + provider.GetService()?.Validate(); } [Fact] @@ -161,10 +161,11 @@ public void AClaimCannotShadowAnExistingContract() using var configuration = Config("{}"); var services = new ServiceCollection(); services.AddConfixOptions(configuration); + services.AddConfixSection(configuration, "Mail"); + using var provider = services.BuildServiceProvider(); - Action claim = () => services.AddConfixSection(configuration, "Mail"); - - claim.Should().Throw().WithMessage("*already claimed*"); + ContractValidation.Validate(provider, configuration).Should() + .Contain(error => error.Contains("already claimed")); } [Fact] @@ -175,11 +176,11 @@ public void ClaimsCannotBeNestedInsideAContract() """); var services = new ServiceCollection(); services.AddConfixOptions(configuration, "Portal"); + services.AddConfixSection(configuration, "Portal:Telemetry"); + using var provider = services.BuildServiceProvider(); - Action claim = () => services.AddConfixSection(configuration, "Portal:Telemetry"); - - claim.Should().Throw() - .Which.Message.Should().Contain("cannot be nested inside 'Portal' of Annotated"); + ContractValidation.Validate(provider, configuration).Should() + .Contain(error => error.Contains("cannot be nested inside 'Portal' of Annotated")); } [Fact] diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/LibraryDescriptionTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/LibraryDescriptionTests.cs new file mode 100644 index 0000000..6a90fc9 --- /dev/null +++ b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/LibraryDescriptionTests.cs @@ -0,0 +1,110 @@ +using System.ComponentModel.DataAnnotations; +using System.Text; +using Confix; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Confix.CodeFirst.Tests; + +/// +/// A library describes its section unconditionally; enforcement depends on whether the +/// application itself uses Confix, or whether confix is inspecting the application. +/// +public sealed class LibraryDescriptionTests : IDisposable +{ + public void Dispose() + { + Environment.SetEnvironmentVariable("CONFIX_VALIDATION", null); + } + + [Fact] + public void AnApplicationWithoutConfixIsUnaffectedByABadValue() + { + using var configuration = Config("""{"Mail":{"Hst":"typo"}}"""); + var services = new ServiceCollection(); + RegisterLikeALibrary(services, configuration); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService>().Value.Host.Should().Be(""); + } + + [Fact] + public void AnApplicationUsingConfixEnforcesLibraryDescriptions() + { + using var configuration = Config("""{"Mail":{"Hst":"typo"},"App":{"Name":"x"}}"""); + var services = new ServiceCollection(); + RegisterLikeALibrary(services, configuration); + services.AddConfixOptions(configuration); + using var provider = services.BuildServiceProvider(); + + Action resolve = () => _ = provider.GetRequiredService>().Value; + + resolve.Should().Throw().Which.Message.Should().Contain("Hst"); + } + + [Fact] + public void ValidationRunsEnforceLibraryDescriptionsWithoutAnyApplicationOptIn() + { + Environment.SetEnvironmentVariable("CONFIX_VALIDATION", "true"); + using var configuration = Config("""{"Mail":{"Hst":"typo"}}"""); + var services = new ServiceCollection(); + RegisterLikeALibrary(services, configuration); + using var provider = services.BuildServiceProvider(); + + ContractValidation.Validate(provider, configuration).Should() + .ContainSingle().Which.Should().Contain("Hst"); + } + + [Fact] + public void AnOverlappingDescriptionIsReportedInsteadOfCrashingTheHost() + { + using var configuration = Config("""{"Mail":{"Host":"server"}}"""); + var services = new ServiceCollection(); + RegisterLikeALibrary(services, configuration); + + // A second library describing the same section must not take the application down. + Action second = () => services.AddConfixSection(configuration, "Mail"); + + second.Should().NotThrow(); + + using var provider = services.BuildServiceProvider(); + ContractValidation.Validate(provider, configuration).Should() + .Contain(error => error.Contains("already claimed")); + } + + private static void RegisterLikeALibrary( + IServiceCollection services, + IConfiguration configuration) + { + services.AddSingleton(configuration); + services.AddOptions().BindConfiguration("Mail"); + services.AddConfixSection(configuration); + } + + private static ConfigurationRoot Config(string json) + { + return (ConfigurationRoot)new ConfigurationBuilder() + .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json))) + .Build(); + } + + [ConfixSection("Mail")] + public sealed class Mail + { + public string Host { get; set; } = ""; + } + + [ConfixSection("App")] + public sealed class AppOptions + { + [Required] + public string Name { get; set; } = ""; + } + + public sealed class Other + { + public string Value { get; set; } = ""; + } +} diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/OptionsTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/OptionsTests.cs index 3c6721c..d69ece2 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/OptionsTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/OptionsTests.cs @@ -50,17 +50,6 @@ public void RegisteredSectionsExplicitlyAllowsUnownedRoots() .Contain(e => e.Contains("Other")); } - [Fact] - public void StartupRejectsUnownedJsonButNotAmbientConfiguration() - { - using var configuration = Config("{\"Mail\":{\"Host\":\"server\"},\"Other\":1}"); - using var provider = Services(configuration).BuildServiceProvider(); - - Action start = () => provider.GetRequiredService().Validate(); - - start.Should().Throw(); - } - [Fact] public void RecursesThroughItemsAndRequiredKeys() { @@ -164,7 +153,7 @@ public void OptionalSectionsAreSkippedUntilPresent() using var provider = services.BuildServiceProvider(); ContractValidation.Validate(provider, configuration).Should().BeEmpty(); - provider.GetRequiredService().Validate(); + provider.GetService()?.Validate(); } [Fact] @@ -175,7 +164,7 @@ public void AbsentOptionalSectionDoesNotConstructOptionsAtStartup() services.AddConfixOptions(configuration); using var provider = services.BuildServiceProvider(); - provider.GetRequiredService().Validate(); + provider.GetService()?.Validate(); } [Fact] diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RegistrationTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RegistrationTests.cs index cd1bd5b..0c2f4c1 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RegistrationTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RegistrationTests.cs @@ -151,16 +151,32 @@ public void ARootContractLeavesNoRoomForOtherContracts() } [Fact] - public void CoverageValidationIsRegisteredOnlyOncePerCollection() + public void ADescribedSectionIsValidatedButNotBoundByConfix() { - using var configuration = Config("{}"); + using var configuration = Config("""{"Other":{"Value":"configured"}}"""); var services = new ServiceCollection(); - services.AddConfixOptions(configuration, "First", "first"); - services.AddConfixOptions(configuration, "Second", "second"); - services.AddConfixOptions(configuration, "Third"); + services.AddConfixSection(configuration); + using var provider = services.BuildServiceProvider(); + + // The caller owns the binding, so Confix leaves the value untouched. + provider.GetRequiredService>().Value.Value.Should().BeEmpty(); + provider.GetServices().Should().ContainSingle(); + } + + [Fact] + public void ADescribedSectionStillRejectsUnknownKeys() + { + using var configuration = Config("""{"Other":{"Vale":"typo"}}"""); + var services = new ServiceCollection(); + services.AddConfixSection(configuration); + + // A description is only enforced once the application itself uses Confix. + services.AddConfixOptions(configuration); + using var provider = services.BuildServiceProvider(); + + Action resolve = () => _ = provider.GetRequiredService>().Value; - services.Count(d => d.ServiceType == typeof(CoverageSource)).Should().Be(1); - services.Count(d => d.ServiceType == typeof(IValidateOptions)).Should().Be(1); + resolve.Should().Throw().Which.Message.Should().Contain("Vale"); } [Fact]