diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c9617..424482b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [6.1.1] - 2026-08-17 + +### Fixed +- Config-aware provider options, query values, and `.When()` predicates now observe the current recompute pass, so changes to derived file paths, HTTP URLs, environment prefixes, tenant configuration, and service-backed configuration propagate immediately instead of lagging or remaining stale. +- Environment, command-line, dotenv, INI, and Microsoft-adapter values with indexed children (`Key__0`, `Key:0`) now bind to `List` and one-dimensional arrays. A collection can also be supplied as a JSON-array string in one value; numeric dictionary keys remain object properties. + +### Documentation +- Documented indexed collection binding and collection replacement across Cocoar layers. The runnable Microsoft-adapter example now compares Microsoft binding, direct environment binding, and adapter binding with the same indexed values. + +### Maintenance +- Updated the PostgreSQL testcontainer dependency to consume the patched SSH.NET release and refreshed documentation build dependencies where compatible security fixes were available. + ## [6.1.0] - 2026-06-03 ### Added @@ -671,5 +683,3 @@ Initial release 🎉 - Dynamic rule factories & atomic snapshot recompute - DI lifetimes & keyed registrations - Examples included under `src/Examples/` - - diff --git a/src/Cocoar.Configuration/Utilities/ConfigurationCollectionConverterFactory.cs b/src/Cocoar.Configuration/Utilities/ConfigurationCollectionConverterFactory.cs new file mode 100644 index 0000000..ca806c6 --- /dev/null +++ b/src/Cocoar.Configuration/Utilities/ConfigurationCollectionConverterFactory.cs @@ -0,0 +1,208 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Utilities; + +internal sealed class ConfigurationCollectionConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + if (typeToConvert.IsArray) + { + return typeToConvert.GetArrayRank() == 1 && typeToConvert != typeof(byte[]); + } + + return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(List<>); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var elementType = typeToConvert.IsArray + ? typeToConvert.GetElementType()! + : typeToConvert.GetGenericArguments()[0]; + var converterType = typeToConvert.IsArray + ? typeof(IndexedArrayConverter<>).MakeGenericType(elementType) + : typeof(IndexedListConverter<>).MakeGenericType(elementType); + + return (JsonConverter)Activator.CreateInstance(converterType)!; + } + + private sealed class IndexedArrayConverter : JsonConverter + { + public override TElement[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => ReadItems(ref reader, options).ToArray(); + + public override void Write(Utf8JsonWriter writer, TElement[] value, JsonSerializerOptions options) + => WriteItems(writer, value, options); + } + + private sealed class IndexedListConverter : JsonConverter> + { + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => ReadItems(ref reader, options); + + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) + => WriteItems(writer, value, options); + } + + private static List ReadItems(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.StartArray) + { + return ReadArray(ref reader, options); + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + using var document = JsonDocument.ParseValue(ref reader); + return ReadIndexedObject(document.RootElement, options); + } + + if (reader.TokenType == JsonTokenType.String) + { + var json = reader.GetString(); + if (string.IsNullOrWhiteSpace(json)) + { + throw new JsonException("A collection string must contain a JSON array."); + } + + return ReadJsonArrayString(json, options); + } + + throw new JsonException( + $"A configuration collection must be a JSON array, an indexed object, or a string containing a JSON array; found {reader.TokenType}."); + } + + private static List ReadJsonArrayString(string json, JsonSerializerOptions options) + { + try + { + return ParseJsonArray(json, options); + } + catch (JsonException directException) + { + // MutableJson preserves the escaped lexical form of strings containing quotes. Decode that one + // retained JSON-string layer so provider values such as ["value"] remain usable as JSON arrays. + if (TryDecodePreservedStringEscapes(json, out var decodedJson)) + { + try + { + return ParseJsonArray(decodedJson, options); + } + catch (JsonException) + { + // Report the original parse failure because it best describes the supplied value. + } + } + + throw new JsonException( + $"A collection string must contain a valid JSON array. {directException.Message}", + directException); + } + } + + private static List ParseJsonArray(string json, JsonSerializerOptions options) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + throw new JsonException("A collection string must contain a JSON array."); + } + + return ReadArrayElement(document.RootElement, options); + } + + private static bool TryDecodePreservedStringEscapes(string value, out string decodedValue) + { + try + { + decodedValue = JsonSerializer.Deserialize($"\"{value}\"") ?? string.Empty; + return decodedValue.Length > 0 && !string.Equals(decodedValue, value, StringComparison.Ordinal); + } + catch (JsonException) + { + decodedValue = string.Empty; + return false; + } + } + + private static List ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var items = new List(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + return items; + } + + items.Add(JsonSerializer.Deserialize(ref reader, options)!); + } + + throw new JsonException("The JSON array is incomplete."); + } + + private static List ReadArrayElement(JsonElement array, JsonSerializerOptions options) + { + var items = new List(); + foreach (var element in array.EnumerateArray()) + { + items.Add(element.Deserialize(options)!); + } + + return items; + } + + private static List ReadIndexedObject(JsonElement indexedObject, JsonSerializerOptions options) + { + var items = new List(); + foreach (var property in indexedObject.EnumerateObject().OrderBy( + property => property.Name, + ConfigurationKeySegmentComparer.Instance)) + { + items.Add(property.Value.Deserialize(options)!); + } + + return items; + } + + private static void WriteItems( + Utf8JsonWriter writer, + IEnumerable values, + JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var value in values) + { + JsonSerializer.Serialize(writer, value, options); + } + + writer.WriteEndArray(); + } + + private sealed class ConfigurationKeySegmentComparer : IComparer + { + public static ConfigurationKeySegmentComparer Instance { get; } = new(); + + public int Compare(string? x, string? y) + { + var xIsInteger = int.TryParse(x, out var xInteger); + var yIsInteger = int.TryParse(y, out var yInteger); + + if (xIsInteger && yIsInteger) + { + var numericComparison = xInteger.CompareTo(yInteger); + return numericComparison != 0 + ? numericComparison + : StringComparer.OrdinalIgnoreCase.Compare(x, y); + } + + if (xIsInteger != yIsInteger) + { + return xIsInteger ? -1 : 1; + } + + return StringComparer.OrdinalIgnoreCase.Compare(x, y); + } + } +} diff --git a/src/Cocoar.Configuration/Utilities/ConfigurationDeserializer.cs b/src/Cocoar.Configuration/Utilities/ConfigurationDeserializer.cs index 1219fdf..d8544a5 100644 --- a/src/Cocoar.Configuration/Utilities/ConfigurationDeserializer.cs +++ b/src/Cocoar.Configuration/Utilities/ConfigurationDeserializer.cs @@ -33,13 +33,7 @@ private static JsonSerializerOptions CreateOptions(ConfigManagerCapabilityScope? PropertyNameCaseInsensitive = true }; - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new StringToPrimitiveConverter()); - options.Converters.Add(new JsonStringEnumConverter()); + AddBuiltInConverters(options); ApplySerializerCapabilities(options, capabilityScope); @@ -53,19 +47,24 @@ private static JsonSerializerOptions CreateOptionsWithInterfaceMapping(IReadOnly PropertyNameCaseInsensitive = true }; + AddBuiltInConverters(options); + options.Converters.Add(new InterfaceConverter(new Dictionary(deserializationMap))); + + ApplySerializerCapabilities(options, capabilityScope); + + return options; + } + + private static void AddBuiltInConverters(JsonSerializerOptions options) + { options.Converters.Add(new StringToPrimitiveConverter()); options.Converters.Add(new StringToPrimitiveConverter()); options.Converters.Add(new StringToPrimitiveConverter()); options.Converters.Add(new StringToPrimitiveConverter()); options.Converters.Add(new StringToPrimitiveConverter()); options.Converters.Add(new StringToPrimitiveConverter()); + options.Converters.Add(new ConfigurationCollectionConverterFactory()); options.Converters.Add(new JsonStringEnumConverter()); - - options.Converters.Add(new InterfaceConverter(new Dictionary(deserializationMap))); - - ApplySerializerCapabilities(options, capabilityScope); - - return options; } private static void ApplySerializerCapabilities(JsonSerializerOptions options, ConfigManagerCapabilityScope? capabilityScope) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index c4e0bf5..5952150 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -23,6 +23,7 @@ + @@ -38,7 +39,7 @@ - + diff --git a/src/Examples/MicrosoftAdapterExample/MicrosoftAdapterExample.csproj b/src/Examples/MicrosoftAdapterExample/MicrosoftAdapterExample.csproj index 4f4d0fa..6a39356 100644 --- a/src/Examples/MicrosoftAdapterExample/MicrosoftAdapterExample.csproj +++ b/src/Examples/MicrosoftAdapterExample/MicrosoftAdapterExample.csproj @@ -1,6 +1,7 @@ net9.0 + Exe enable enable true @@ -11,6 +12,8 @@ + + - \ No newline at end of file + diff --git a/src/Examples/MicrosoftAdapterExample/Program.cs b/src/Examples/MicrosoftAdapterExample/Program.cs index 0353cf2..e4db365 100644 --- a/src/Examples/MicrosoftAdapterExample/Program.cs +++ b/src/Examples/MicrosoftAdapterExample/Program.cs @@ -1,5 +1,7 @@ +using Cocoar.Configuration.Core; using Cocoar.Configuration.DI; using Cocoar.Configuration.MicrosoftAdapter; +using Cocoar.Configuration.Providers; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -18,10 +20,31 @@ public class AppSettings public string Version { get; set; } = ""; } +public class CollectionSettings +{ + public ForwardedHeadersSettings ForwardedHeaders { get; set; } = new(); +} + +public class ForwardedHeadersSettings +{ + public List KnownNetworks { get; set; } = []; +} + public static class Program { public static void Main(string[] args) { + const string environmentPrefix = "COCOAR_COLLECTION_EXAMPLE_"; + SetDefaultEnvironmentVariable( + environmentPrefix + "ForwardedHeaders__KnownNetworks__4", + "10.40.0.0/16"); + SetDefaultEnvironmentVariable( + environmentPrefix + "ForwardedHeaders__KnownNetworks__0", + "10.10.10.0/24"); + SetDefaultEnvironmentVariable( + environmentPrefix + "ForwardedHeaders__KnownNetworks__2", + "10.20.0.0/16"); + // Build an IConfiguration from any Microsoft configuration sources var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -32,6 +55,7 @@ public static void Main(string[] args) ["App:ApplicationName"] = "Microsoft Adapter Demo", ["App:Version"] = "2.1.0" }) + .AddEnvironmentVariables(environmentPrefix) .Build(); var services = new ServiceCollection(); @@ -55,5 +79,54 @@ public static void Main(string[] args) var appSettings = serviceProvider.GetRequiredService(); Console.WriteLine($"DB Timeout: {dbSettings.CommandTimeout} App: {appSettings.ApplicationName} v{appSettings.Version}"); + + CompareIndexedCollectionBinding(configuration, environmentPrefix); + } + + private static void CompareIndexedCollectionBinding( + IConfiguration configuration, + string environmentPrefix) + { + var microsoftNetworks = configuration + .GetSection("ForwardedHeaders:KnownNetworks") + .Get() ?? []; + + using var environmentManager = ConfigManager.Create(c => c.UseConfiguration(rule => + [ + rule.For().FromEnvironment(environmentPrefix) + ])); + var cocoarEnvironmentNetworks = environmentManager + .GetConfig()! + .ForwardedHeaders + .KnownNetworks; + + using var adapterManager = ConfigManager.Create(c => c.UseConfiguration(rule => + [ + rule.For().FromIConfiguration(configuration) + ])); + var cocoarAdapterNetworks = adapterManager + .GetConfig()! + .ForwardedHeaders + .KnownNetworks; + + if (!microsoftNetworks.SequenceEqual(cocoarEnvironmentNetworks) + || !microsoftNetworks.SequenceEqual(cocoarAdapterNetworks)) + { + throw new InvalidOperationException( + "Indexed collection binding differs between Microsoft and Cocoar configuration."); + } + + Console.WriteLine($"Microsoft binder: {string.Join(", ", microsoftNetworks)}"); + Console.WriteLine($"Cocoar environment: {string.Join(", ", cocoarEnvironmentNetworks)}"); + Console.WriteLine($"Cocoar Microsoft adapter: {string.Join(", ", cocoarAdapterNetworks)}"); + Console.WriteLine("Indexed collection binding matches."); + } + + private static void SetDefaultEnvironmentVariable(string name, string value) + { + if (Environment.GetEnvironmentVariable(name) is null) + { + Environment.SetEnvironmentVariable(name, value); + } } } diff --git a/src/Examples/README.md b/src/Examples/README.md index 08b75f4..bb50a85 100644 --- a/src/Examples/README.md +++ b/src/Examples/README.md @@ -11,7 +11,7 @@ This directory contains runnable examples for **Cocoar.Configuration**. Each sub - **StaticProviderExample** – Static seeding with JSON strings and factory functions - **CommandLineExample** – Command-line argument provider with configurable prefixes - **HttpPollingExample** – Remote/polling configuration pattern -- **MicrosoftAdapterExample** – Bridging existing `IConfiguration`/`IConfigurationSource` providers +- **MicrosoftAdapterExample** – Bridging existing `IConfiguration` providers and comparing indexed collection binding with Cocoar's environment provider - **GenericProviderAPI** – Using the generic provider registration API ### Rules, dependencies & reactivity diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Helpers/ConfigurationCollectionConverterTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Helpers/ConfigurationCollectionConverterTests.cs new file mode 100644 index 0000000..926ff6b --- /dev/null +++ b/src/tests/Cocoar.Configuration.Core.Tests/Helpers/ConfigurationCollectionConverterTests.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using Cocoar.Configuration.Utilities; +using Xunit; + +namespace Cocoar.Configuration.Core.Tests.Helpers; + +public class ConfigurationCollectionConverterTests +{ + [Fact] + public void JsonArray_RemainsSupported() + { + var config = Deserialize("""{"Values":["first","second"]}"""); + + Assert.Equal(["first", "second"], config.Values); + } + + [Fact] + public void IndexedObject_UsesConfigurationOrderingAndCompactsGaps() + { + var config = Deserialize("""{"Values":{"10":"ten","2":"two","0":"zero"}}"""); + + Assert.Equal(["zero", "two", "ten"], config.Values); + } + + [Fact] + public void JsonArrayString_BindsToArray() + { + var config = Deserialize("""{"Ports":"[80,443]"}"""); + + Assert.Equal([80, 443], config.Ports); + } + + [Fact] + public void NumericDictionaryKey_RemainsAnObjectProperty() + { + var config = Deserialize("""{"StatusCodes":{"404":"Not Found"}}"""); + + Assert.Equal("Not Found", config.StatusCodes["404"]); + } + + [Fact] + public void Base64ByteArray_KeepsSystemTextJsonSemantics() + { + var config = Deserialize("""{"Bytes":"AQID"}"""); + + Assert.Equal([1, 2, 3], config.Bytes); + } + + private static CollectionConfig Deserialize(string json) + { + using var document = JsonDocument.Parse(json); + return ConfigurationDeserializer.Deserialize(document.RootElement)!; + } + + private sealed class CollectionConfig + { + public List Values { get; set; } = []; + public int[] Ports { get; set; } = []; + public byte[] Bytes { get; set; } = []; + public Dictionary StatusCodes { get; set; } = new(); + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Verification/ConfigAwareRollbackAndTenantTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Verification/ConfigAwareRollbackAndTenantTests.cs index 5692536..cbfa9af 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Verification/ConfigAwareRollbackAndTenantTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Verification/ConfigAwareRollbackAndTenantTests.cs @@ -97,9 +97,10 @@ public async Task TenantScopedDerivedRule_FollowsTheSharedBase() source.OnNext("""{"Name":"two"}"""); await ActiveWaitHelpers.WaitUntilAsync( - () => mgr.GetConfigForTenant("acme")!.Value == "acme-two", + () => mgr.GetConfigForTenant("acme")!.Value == "acme-two" + && mgr.GetConfigForTenant("globex")!.Value == "globex-two", timeout: TimeSpan.FromSeconds(5), - description: "tenant acme to follow the base change"); + description: "both tenants to follow the base change"); Assert.Equal("acme-two", mgr.GetConfigForTenant("acme")!.Value); Assert.Equal("globex-two", mgr.GetConfigForTenant("globex")!.Value); diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Environment/EnvironmentProviderUnitTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/Environment/EnvironmentProviderUnitTests.cs index 7634b5b..c3588da 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/Environment/EnvironmentProviderUnitTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Environment/EnvironmentProviderUnitTests.cs @@ -1,7 +1,7 @@ -using Xunit; using Cocoar.Configuration.Core; using Cocoar.Configuration.Fluent; using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; namespace Cocoar.Configuration.Providers.Tests.Environment; @@ -10,6 +10,24 @@ public class EnvironmentProviderUnitTests private sealed class SimpleValueConfig { public int Value { get; set; } } private sealed class AppSettings { public LoggingSettings Logging { get; set; } = new(); public string? Feature_Flag { get; set; } } private sealed class LoggingSettings { public string? Level { get; set; } } + private sealed class CollectionConfig + { + public ForwardedHeadersConfig ForwardedHeaders { get; set; } = new(); + public int[] Ports { get; set; } = []; + public List Endpoints { get; set; } = []; + public Dictionary StatusCodes { get; set; } = new(); + } + + private sealed class ForwardedHeadersConfig + { + public List KnownNetworks { get; set; } = []; + } + + private sealed class EndpointConfig + { + public string Host { get; set; } = string.Empty; + public int Port { get; set; } + } [Fact] [Trait("Type", "Unit")] @@ -104,6 +122,92 @@ public void TripleUnderscore_StillSeparates() Assert.Equal("Warn", cfg!.Logging.Level); } + [Fact] + [Trait("Type", "Integration")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void IndexedValues_BindCollectionsInNumericOrderAndCompactGaps() + { + var prefix = $"COCOAR_COLLECTION_{Guid.NewGuid():N}_"; + using var network4 = EnvScope.Set(prefix + "ForwardedHeaders__KnownNetworks__4", "10.40.0.0/16"); + using var network0 = EnvScope.Set(prefix + "ForwardedHeaders__KnownNetworks__0", "10.10.0.0/16"); + using var network2 = EnvScope.Set(prefix + "ForwardedHeaders__KnownNetworks__2", "10.20.0.0/16"); + using var port1 = EnvScope.Set(prefix + "Ports__1", "443"); + using var port0 = EnvScope.Set(prefix + "Ports__0", "80"); + using var endpointHost = EnvScope.Set(prefix + "Endpoints__0__Host", "proxy.example.com"); + using var endpointPort = EnvScope.Set(prefix + "Endpoints__0__Port", "8443"); + + var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration([rule])); + + var config = manager.GetConfig(); + + Assert.NotNull(config); + Assert.Equal( + ["10.10.0.0/16", "10.20.0.0/16", "10.40.0.0/16"], + config.ForwardedHeaders.KnownNetworks); + Assert.Equal([80, 443], config.Ports); + var endpoint = Assert.Single(config.Endpoints); + Assert.Equal("proxy.example.com", endpoint.Host); + Assert.Equal(8443, endpoint.Port); + } + + [Fact] + [Trait("Type", "Integration")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void JsonArrayString_BindsToCollection() + { + var prefix = $"COCOAR_COLLECTION_{Guid.NewGuid():N}_"; + using var networks = EnvScope.Set( + prefix + "ForwardedHeaders__KnownNetworks", + """["10.10.10.0/24","10.20.20.0/24"]"""); + + var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration([rule])); + + var config = manager.GetConfig(); + + Assert.NotNull(config); + Assert.Equal(["10.10.10.0/24", "10.20.20.0/24"], config.ForwardedHeaders.KnownNetworks); + } + + [Fact] + [Trait("Type", "Integration")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void IndexedEnvironmentCollection_ReplacesEarlierArrayLayer() + { + var prefix = $"COCOAR_COLLECTION_{Guid.NewGuid():N}_"; + using var network = EnvScope.Set(prefix + "ForwardedHeaders__KnownNetworks__0", "10.30.0.0/16"); + + using var manager = ConfigManager.Create(c => c.UseConfiguration(rules => + [ + rules.For().FromStaticJson( + """{"ForwardedHeaders":{"KnownNetworks":["10.10.0.0/16","10.20.0.0/16"]}}"""), + rules.For().FromEnvironment(prefix) + ])); + + var config = manager.GetConfig(); + + Assert.NotNull(config); + Assert.Equal(["10.30.0.0/16"], config.ForwardedHeaders.KnownNetworks); + } + + [Fact] + [Trait("Type", "Integration")] + [Trait("Provider", "EnvironmentVariableProvider")] + public void NumericDictionaryKey_RemainsAnObjectProperty() + { + var prefix = $"COCOAR_COLLECTION_{Guid.NewGuid():N}_"; + using var status = EnvScope.Set(prefix + "StatusCodes__404", "Not Found"); + + var rule = EnvironmentVariableProvider.CreateRule(prefix, required: true); + using var manager = ConfigManager.Create(c => c.UseConfiguration([rule])); + + var config = manager.GetConfig(); + + Assert.NotNull(config); + Assert.Equal("Not Found", config.StatusCodes["404"]); + } + [Fact] [Trait("Type", "Unit")] [Trait("Provider", "EnvironmentVariableProvider")] diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterBattleTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterBattleTests.cs index d218a81..de17708 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterBattleTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterBattleTests.cs @@ -160,6 +160,31 @@ public async Task FetchConfigurationAsync_HandlesComplexNesting_IConfiguration() Assert.Equal("redis2.example.com", redisEndpoints.GetProperty("1").GetString()); } + [Fact] + [Trait("Type", "Integration")] + [Trait("Provider", "MicrosoftAdapter")] + public void ConfigManager_BindsIndexedConfigurationChildrenToCollection() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ForwardedHeaders:KnownNetworks:4"] = "10.40.0.0/16", + ["ForwardedHeaders:KnownNetworks:0"] = "10.10.0.0/16", + ["ForwardedHeaders:KnownNetworks:2"] = "10.20.0.0/16" + }) + .Build(); + + using var manager = ConfigManager.Create(c => c.UseConfiguration( + rules => [rules.For().FromIConfiguration(configuration)])); + + var config = manager.GetConfig(); + + Assert.NotNull(config); + Assert.Equal( + ["10.10.0.0/16", "10.20.0.0/16", "10.40.0.0/16"], + config.ForwardedHeaders.KnownNetworks); + } + [Fact] [Trait("Type", "Integration")] [Trait("Provider", "MicrosoftAdapter")] @@ -344,6 +369,16 @@ private class LoggingProvidersConfig public bool Console { get; set; } } + private class CollectionConfig + { + public ForwardedHeadersConfig ForwardedHeaders { get; set; } = new(); + } + + private class ForwardedHeadersConfig + { + public List KnownNetworks { get; set; } = []; + } + /// /// Minimal IObserver implementation for testing change detection. /// diff --git a/website/changelog.md b/website/changelog.md index f544589..6230c3f 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -1,5 +1,17 @@ # Changelog +## [6.1.1] — 2026-08-17 + +### Fixed +- Config-aware provider options, query values, and `.When()` predicates now observe the current recompute pass, so changes to derived file paths, HTTP URLs, environment prefixes, tenant configuration, and service-backed configuration propagate immediately instead of lagging or remaining stale. +- Environment, command-line, dotenv, INI, and Microsoft-adapter values with indexed children (`Key__0`, `Key:0`) now bind to `List` and one-dimensional arrays. A collection can also be supplied as a JSON-array string in one value; numeric dictionary keys remain object properties. + +### Documentation +- Documented indexed collection binding and collection replacement across Cocoar layers. The runnable Microsoft-adapter example now compares Microsoft binding, direct environment binding, and adapter binding with the same indexed values. + +### Maintenance +- Updated the PostgreSQL testcontainer dependency to consume the patched SSH.NET release and refreshed documentation build dependencies where compatible security fixes were available. + ## [6.1.0] — 2026-06-03 ### Added diff --git a/website/guide/configuration/required-optional.md b/website/guide/configuration/required-optional.md index 5cbd13d..aa5bf79 100644 --- a/website/guide/configuration/required-optional.md +++ b/website/guide/configuration/required-optional.md @@ -6,6 +6,8 @@ description: Optional rules degrade gracefully to empty {} with Degraded health, Every rule is **optional by default**. This controls what happens when a provider fails — file not found, HTTP timeout, parse error. +`Required()` applies to failures while fetching or transforming an individual rule. A deserialization failure in the final merged configuration is different: no valid initial snapshot can be published, so startup fails regardless of which contributing rules are optional. During a runtime recompute, the last valid snapshot is retained instead. + ## Optional Rules (Default) When an optional rule fails, the system continues with graceful degradation: @@ -97,6 +99,7 @@ rule.For().FromFile("premium.json") |---|---|---| | Required rule fails | App throws, does not start | Rolls back, keeps last good state | | Optional rule fails | Continues with defaults | Continues with defaults | +| Merged configuration cannot be deserialized | App throws, does not start | Rolls back, keeps last good state | | All rules succeed | Config loaded normally | New snapshot replaces old one | This dual behavior means: strict validation at startup (catch misconfigurations early), resilient behavior at runtime (never lose working state because of a transient failure). diff --git a/website/guide/health/overview.md b/website/guide/health/overview.md index 8aee6cf..de7b241 100644 --- a/website/guide/health/overview.md +++ b/website/guide/health/overview.md @@ -48,6 +48,7 @@ Health behaves differently depending on when a failure occurs: **During startup:** - Required rule failures **throw immediately** — the application won't start with missing critical configuration - Optional rule failures are recorded and health starts as `Degraded` +- A final merged configuration that cannot be deserialized throws even when its contributing rules are optional, because there is no valid initial snapshot to publish **At runtime (after a config change):** - Required rule failures **roll back** the entire recompute — the last known good configuration is preserved @@ -101,4 +102,4 @@ The meter name is `Cocoar.Configuration`. To collect these metrics, register the ```csharp builder.Services.AddOpenTelemetry() .WithMetrics(m => m.AddMeter("Cocoar.Configuration")); -``` \ No newline at end of file +``` diff --git a/website/guide/providers/environment.md b/website/guide/providers/environment.md index 141ff48..25040a2 100644 --- a/website/guide/providers/environment.md +++ b/website/guide/providers/environment.md @@ -1,5 +1,5 @@ --- -description: "FromEnvironment provider, case-insensitive prefix filtering, __ and : nesting to JSON, final-override pattern, dynamic per-tenant prefix" +description: "FromEnvironment provider, case-insensitive prefix filtering, __ and : nesting, indexed collections, final-override pattern, dynamic per-tenant prefix" --- # Environment Variables Provider @@ -62,6 +62,35 @@ APP_Database:Host=localhost # { "Database": { "Host": "localhost" } } ``` +## Collections + +Use numeric path segments to bind `List` and one-dimensional arrays, following the Microsoft configuration convention: + +```shell +APP_ForwardedHeaders__KnownNetworks__0=10.10.10.0/24 +APP_ForwardedHeaders__KnownNetworks__1=10.20.0.0/16 +``` + +```json +{ + "ForwardedHeaders": { + "KnownNetworks": ["10.10.10.0/24", "10.20.0.0/16"] + } +} +``` + +Indices are ordered numerically. Gaps are compacted, matching the Microsoft binder: indices `0`, `2`, and `4` produce a three-element collection. + +A JSON array can alternatively be supplied as one environment variable. This interpretation is only applied when the target property is a collection; other target types do not gain collection semantics: + +```shell +APP_ForwardedHeaders__KnownNetworks='["10.10.10.0/24","10.20.0.0/16"]' +``` + +::: warning Collection overrides +Cocoar configuration layers replace collections as a whole. An indexed environment-variable contribution therefore replaces an array from an earlier file rule; provide every element that the effective collection should contain. +::: + ## Common Pattern Environment variables are typically the last rule, overriding everything else: diff --git a/website/guide/providers/microsoft-adapter.md b/website/guide/providers/microsoft-adapter.md index 3f146fe..96c2c63 100644 --- a/website/guide/providers/microsoft-adapter.md +++ b/website/guide/providers/microsoft-adapter.md @@ -87,8 +87,11 @@ If a third-party library provides configuration through `IConfiguration`, pass i rule.For().FromIConfiguration(vaultConfiguration) ``` +## Collections + +Microsoft collection keys such as `KnownNetworks:0` and `KnownNetworks:1` bind to `List` and one-dimensional array properties. The adapter's raw JSON keeps those indices as object-property names, and Cocoar's target-aware deserializer materializes the collection in numeric order. Index gaps are compacted like they are by the Microsoft binder. + ## Limitations -- **Array keys**: Microsoft uses `Key:0`, `Key:1` for arrays. The adapter converts these to JSON object properties (`"0": "value"`, `"1": "value"`), not JSON arrays. This matches Microsoft's own `IConfiguration` behavior. - **Performance**: The adapter reads ALL key-value pairs from `IConfiguration` on each fetch. For very large configurations (thousands of keys), consider using `.Select()` to scope to the relevant section. - **One-way bridge**: Changes flow FROM Microsoft configuration TO Cocoar. Cocoar does not write back to `IConfiguration`. diff --git a/website/package-lock.json b/website/package-lock.json index a82544d..41918ab 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -1679,16 +1679,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/ccount": { @@ -2167,9 +2167,9 @@ } }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -2201,10 +2201,20 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -2239,15 +2249,25 @@ "license": "MIT" }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -2934,9 +2954,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2986,9 +3006,9 @@ "license": "ISC" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -3006,7 +3026,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" },