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: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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
Expand Down Expand Up @@ -671,5 +683,3 @@ Initial release 🎉
- Dynamic rule factories & atomic snapshot recompute
- DI lifetimes & keyed registrations
- Examples included under `src/Examples/`


Original file line number Diff line number Diff line change
@@ -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<TElement> : JsonConverter<TElement[]>
{
public override TElement[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> ReadItems<TElement>(ref reader, options).ToArray();

public override void Write(Utf8JsonWriter writer, TElement[] value, JsonSerializerOptions options)
=> WriteItems(writer, value, options);
}

private sealed class IndexedListConverter<TElement> : JsonConverter<List<TElement>>
{
public override List<TElement> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> ReadItems<TElement>(ref reader, options);

public override void Write(Utf8JsonWriter writer, List<TElement> value, JsonSerializerOptions options)
=> WriteItems(writer, value, options);
}

private static List<TElement> ReadItems<TElement>(ref Utf8JsonReader reader, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.StartArray)
{
return ReadArray<TElement>(ref reader, options);
}

if (reader.TokenType == JsonTokenType.StartObject)
{
using var document = JsonDocument.ParseValue(ref reader);
return ReadIndexedObject<TElement>(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<TElement>(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<TElement> ReadJsonArrayString<TElement>(string json, JsonSerializerOptions options)
{
try
{
return ParseJsonArray<TElement>(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<TElement>(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<TElement> ParseJsonArray<TElement>(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<TElement>(document.RootElement, options);
}

private static bool TryDecodePreservedStringEscapes(string value, out string decodedValue)
{
try
{
decodedValue = JsonSerializer.Deserialize<string>($"\"{value}\"") ?? string.Empty;
return decodedValue.Length > 0 && !string.Equals(decodedValue, value, StringComparison.Ordinal);
}
catch (JsonException)
{
decodedValue = string.Empty;
return false;
}
}

private static List<TElement> ReadArray<TElement>(ref Utf8JsonReader reader, JsonSerializerOptions options)
{
var items = new List<TElement>();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
{
return items;
}

items.Add(JsonSerializer.Deserialize<TElement>(ref reader, options)!);
}

throw new JsonException("The JSON array is incomplete.");
}

private static List<TElement> ReadArrayElement<TElement>(JsonElement array, JsonSerializerOptions options)
{
var items = new List<TElement>();
foreach (var element in array.EnumerateArray())
{
items.Add(element.Deserialize<TElement>(options)!);
}

return items;
}

private static List<TElement> ReadIndexedObject<TElement>(JsonElement indexedObject, JsonSerializerOptions options)
{
var items = new List<TElement>();
foreach (var property in indexedObject.EnumerateObject().OrderBy(
property => property.Name,
ConfigurationKeySegmentComparer.Instance))
{
items.Add(property.Value.Deserialize<TElement>(options)!);
}

return items;
}

private static void WriteItems<TElement>(
Utf8JsonWriter writer,
IEnumerable<TElement> values,
JsonSerializerOptions options)
{
writer.WriteStartArray();
foreach (var value in values)
{
JsonSerializer.Serialize(writer, value, options);
}

writer.WriteEndArray();
}

private sealed class ConfigurationKeySegmentComparer : IComparer<string>
{
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);
}
}
}
25 changes: 12 additions & 13 deletions src/Cocoar.Configuration/Utilities/ConfigurationDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,7 @@ private static JsonSerializerOptions CreateOptions(ConfigManagerCapabilityScope?
PropertyNameCaseInsensitive = true
};

options.Converters.Add(new StringToPrimitiveConverter<bool>());
options.Converters.Add(new StringToPrimitiveConverter<int>());
options.Converters.Add(new StringToPrimitiveConverter<double>());
options.Converters.Add(new StringToPrimitiveConverter<float>());
options.Converters.Add(new StringToPrimitiveConverter<long>());
options.Converters.Add(new StringToPrimitiveConverter<DateTime>());
options.Converters.Add(new JsonStringEnumConverter());
AddBuiltInConverters(options);

ApplySerializerCapabilities(options, capabilityScope);

Expand All @@ -53,19 +47,24 @@ private static JsonSerializerOptions CreateOptionsWithInterfaceMapping(IReadOnly
PropertyNameCaseInsensitive = true
};

AddBuiltInConverters(options);
options.Converters.Add(new InterfaceConverter(new Dictionary<Type, Type>(deserializationMap)));

ApplySerializerCapabilities(options, capabilityScope);

return options;
}

private static void AddBuiltInConverters(JsonSerializerOptions options)
{
options.Converters.Add(new StringToPrimitiveConverter<bool>());
options.Converters.Add(new StringToPrimitiveConverter<int>());
options.Converters.Add(new StringToPrimitiveConverter<double>());
options.Converters.Add(new StringToPrimitiveConverter<float>());
options.Converters.Add(new StringToPrimitiveConverter<long>());
options.Converters.Add(new StringToPrimitiveConverter<DateTime>());
options.Converters.Add(new ConfigurationCollectionConverterFactory());
options.Converters.Add(new JsonStringEnumConverter());

options.Converters.Add(new InterfaceConverter(new Dictionary<Type, Type>(deserializationMap)));

ApplySerializerCapabilities(options, capabilityScope);

return options;
}

private static void ApplySerializerCapabilities(JsonSerializerOptions options, ConfigManagerCapabilityScope? capabilityScope)
Expand Down
3 changes: 2 additions & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Primitives" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
Expand All @@ -38,7 +39,7 @@
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<!-- Marten backend integration tests: real PostgreSQL via Docker, self-skipping when Docker is absent. -->
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.12.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.14.0" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsExampleProject>true</IsExampleProject>
Expand All @@ -11,6 +12,8 @@
<ProjectReference Include="..\..\Cocoar.Configuration.MicrosoftAdapter\Cocoar.Configuration.MicrosoftAdapter.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
</Project>
</Project>
Loading
Loading