diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c04923..cbf7dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ All notable changes to this project will be documented in this file. - Removed the Newtonsoft.Json companion package and custom serializer abstraction. - Made `AddObject` generic and added `JsonTypeInfo` overloads for source-generated System.Text.Json metadata. - Added `AddObjectAsFallback` for registering object configuration at the lowest precedence. -- Aligned object flattening with .NET 10 configuration semantics for null values, empty strings, empty objects, empty arrays, and null array elements. +- Aligned object configuration with .NET 10 JSON configuration semantics for null values, empty strings, empty objects, empty arrays, and null array elements. +- Reused the built-in JSON stream configuration provider instead of maintaining a custom JSON-to-configuration flattener and provider. ## 3.0.1 - 2024-07-12 diff --git a/Directory.Packages.props b/Directory.Packages.props index 23137b5..0760408 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/src/Objects/Internal/JsonConfigurationFlattener.cs b/src/Objects/Internal/JsonConfigurationFlattener.cs deleted file mode 100644 index 6523e79..0000000 --- a/src/Objects/Internal/JsonConfigurationFlattener.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text.Json; -using Microsoft.Extensions.Configuration; - -namespace Kralizek.Extensions.Configuration.Internal; - -internal static class JsonConfigurationFlattener -{ - public static IDictionary Flatten(JsonElement element, string rootSectionName) - { - if (string.IsNullOrEmpty(rootSectionName) && element.ValueKind != JsonValueKind.Object) - { - throw new FormatException($"A top-level JSON value of kind '{element.ValueKind}' is not supported without a root section."); - } - - var visitor = new JsonVisitor(); - - return visitor.Flatten(element, rootSectionName); - } - - private sealed class JsonVisitor - { - private readonly IDictionary _data = new SortedDictionary(StringComparer.OrdinalIgnoreCase); - private readonly Stack _context = new(); - private string _currentPath = string.Empty; - - public IDictionary Flatten(JsonElement element, string rootSectionName) - { - if (!string.IsNullOrEmpty(rootSectionName)) - { - EnterContext(rootSectionName); - } - - VisitElement(element); - - if (!string.IsNullOrEmpty(rootSectionName)) - { - ExitContext(); - } - - return _data; - } - - private void VisitElement(JsonElement element) - { - switch (element.ValueKind) - { - case JsonValueKind.Object: - VisitObject(element); - break; - - case JsonValueKind.Array: - VisitArray(element); - break; - - case JsonValueKind.Number: - case JsonValueKind.String: - case JsonValueKind.True: - case JsonValueKind.False: - case JsonValueKind.Null: - VisitPrimitive(element); - break; - - case JsonValueKind.Undefined: - default: - throw new NotSupportedException($"Unsupported JSON token '{element.ValueKind}' was found"); - } - } - - private void VisitObject(JsonElement element) - { - var isEmpty = true; - - foreach (var property in element.EnumerateObject()) - { - isEmpty = false; - EnterContext(property.Name); - VisitElement(property.Value); - ExitContext(); - } - - if (isEmpty) - { - AddCurrentValue(null); - } - } - - private void VisitArray(JsonElement element) - { - var index = 0; - - foreach (var item in element.EnumerateArray()) - { - EnterContext(index.ToString(CultureInfo.InvariantCulture)); - VisitElement(item); - ExitContext(); - index++; - } - - if (index == 0) - { - AddCurrentValue(string.Empty); - } - } - - private void VisitPrimitive(JsonElement element) - { - var value = element.ValueKind switch - { - JsonValueKind.Null => null, - JsonValueKind.String => element.GetString(), - _ => element.GetRawText() - }; - - AddCurrentValue(value); - } - - private void AddCurrentValue(string? value) - { - if (string.IsNullOrEmpty(_currentPath)) - { - return; - } - - if (_data.ContainsKey(_currentPath)) - { - throw new FormatException($"A duplicate key '{_currentPath}' was found."); - } - - _data[_currentPath] = value; - } - - private void EnterContext(string context) - { - _context.Push(context); - _currentPath = ConfigurationPath.Combine(_context.Reverse()); - } - - private void ExitContext() - { - _context.Pop(); - _currentPath = ConfigurationPath.Combine(_context.Reverse()); - } - } -} diff --git a/src/Objects/Internal/ObjectConfigurationProvider.cs b/src/Objects/Internal/ObjectConfigurationProvider.cs deleted file mode 100644 index 6721015..0000000 --- a/src/Objects/Internal/ObjectConfigurationProvider.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.Extensions.Configuration; - -namespace Kralizek.Extensions.Configuration.Internal; - -internal sealed class ObjectConfigurationProvider : ConfigurationProvider -{ - private readonly Func> _dataFactory; - - public ObjectConfigurationProvider(Func> dataFactory) - { - _dataFactory = dataFactory ?? throw new ArgumentNullException(nameof(dataFactory)); - } - - public override void Load() - { - Data = _dataFactory(); - - base.Load(); - } -} diff --git a/src/Objects/Internal/ObjectConfigurationSource.cs b/src/Objects/Internal/ObjectConfigurationSource.cs deleted file mode 100644 index c1b91e6..0000000 --- a/src/Objects/Internal/ObjectConfigurationSource.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.Extensions.Configuration; - -namespace Kralizek.Extensions.Configuration.Internal; - -internal sealed class ObjectConfigurationSource : IConfigurationSource -{ - private readonly Func> _dataFactory; - - public ObjectConfigurationSource(Func> dataFactory) - { - _dataFactory = dataFactory ?? throw new ArgumentNullException(nameof(dataFactory)); - } - - public IConfigurationProvider Build(IConfigurationBuilder builder) - { - return new ObjectConfigurationProvider(_dataFactory); - } -} diff --git a/src/Objects/Internal/SystemTextJsonConfigurationSerializer.cs b/src/Objects/Internal/SystemTextJsonConfigurationSerializer.cs index fa8c188..85ac26e 100644 --- a/src/Objects/Internal/SystemTextJsonConfigurationSerializer.cs +++ b/src/Objects/Internal/SystemTextJsonConfigurationSerializer.cs @@ -1,5 +1,8 @@ +using System; +using System.IO; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.Configuration; namespace Kralizek.Extensions.Configuration.Internal; @@ -7,13 +10,47 @@ internal static class SystemTextJsonConfigurationSerializer { private static readonly JsonSerializerOptions JsonOptions = new(); - public static JsonElement Serialize(T source) + public static MemoryStream Serialize(T source, string rootSectionName) { - return JsonSerializer.SerializeToElement(source, JsonOptions); + return Serialize(rootSectionName, writer => JsonSerializer.Serialize(writer, source, JsonOptions)); } - public static JsonElement Serialize(T source, JsonTypeInfo jsonTypeInfo) + public static MemoryStream Serialize(T source, JsonTypeInfo jsonTypeInfo, string rootSectionName) { - return JsonSerializer.SerializeToElement(source, jsonTypeInfo); + return Serialize(rootSectionName, writer => JsonSerializer.Serialize(writer, source, jsonTypeInfo)); + } + + private static MemoryStream Serialize(string rootSectionName, Action serialize) + { + var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream)) + { + var rootSections = GetRootSections(rootSectionName); + + foreach (var rootSection in rootSections) + { + writer.WriteStartObject(); + writer.WritePropertyName(rootSection); + } + + serialize(writer); + + for (var index = 0; index < rootSections.Length; index++) + { + writer.WriteEndObject(); + } + } + + stream.Position = 0; + + return stream; + } + + private static string[] GetRootSections(string rootSectionName) + { + return string.IsNullOrEmpty(rootSectionName) + ? Array.Empty() + : rootSectionName.Split(new[] { ConfigurationPath.KeyDelimiter }, StringSplitOptions.None); } } diff --git a/src/Objects/ObjectConfigurationExtensions.cs b/src/Objects/ObjectConfigurationExtensions.cs index 22249be..5f88b73 100644 --- a/src/Objects/ObjectConfigurationExtensions.cs +++ b/src/Objects/ObjectConfigurationExtensions.cs @@ -1,8 +1,8 @@ using System; -using System.Collections.Generic; -using System.Text.Json; +using System.IO; using System.Text.Json.Serialization.Metadata; using Kralizek.Extensions.Configuration.Internal; +using Microsoft.Extensions.Configuration.Json; // ReSharper disable CheckNamespace @@ -12,7 +12,7 @@ public static class ObjectConfigurationExtensions { public static IConfigurationBuilder AddObject(this IConfigurationBuilder configurationBuilder, T? objectToAdd, string? rootSectionName = "") { - return AddObject(configurationBuilder, objectToAdd, rootSectionName, static value => SystemTextJsonConfigurationSerializer.Serialize(value)); + return AddObject(configurationBuilder, objectToAdd, rootSectionName, static (value, rootSection) => SystemTextJsonConfigurationSerializer.Serialize(value, rootSection)); } public static IConfigurationBuilder AddObject(this IConfigurationBuilder configurationBuilder, T? objectToAdd, JsonTypeInfo jsonTypeInfo, string? rootSectionName = "") @@ -22,12 +22,12 @@ public static IConfigurationBuilder AddObject(this IConfigurationBuilder conf throw new ArgumentNullException(nameof(jsonTypeInfo)); } - return AddObject(configurationBuilder, objectToAdd, rootSectionName, value => SystemTextJsonConfigurationSerializer.Serialize(value, jsonTypeInfo)); + return AddObject(configurationBuilder, objectToAdd, rootSectionName, (value, rootSection) => SystemTextJsonConfigurationSerializer.Serialize(value, jsonTypeInfo, rootSection)); } public static IConfigurationBuilder AddObjectAsFallback(this IConfigurationBuilder configurationBuilder, T? objectToAdd, string? rootSectionName = "") { - return AddObjectAsFallback(configurationBuilder, objectToAdd, rootSectionName, static value => SystemTextJsonConfigurationSerializer.Serialize(value)); + return AddObjectAsFallback(configurationBuilder, objectToAdd, rootSectionName, static (value, rootSection) => SystemTextJsonConfigurationSerializer.Serialize(value, rootSection)); } public static IConfigurationBuilder AddObjectAsFallback(this IConfigurationBuilder configurationBuilder, T? objectToAdd, JsonTypeInfo jsonTypeInfo, string? rootSectionName = "") @@ -37,10 +37,10 @@ public static IConfigurationBuilder AddObjectAsFallback(this IConfigurationBu throw new ArgumentNullException(nameof(jsonTypeInfo)); } - return AddObjectAsFallback(configurationBuilder, objectToAdd, rootSectionName, value => SystemTextJsonConfigurationSerializer.Serialize(value, jsonTypeInfo)); + return AddObjectAsFallback(configurationBuilder, objectToAdd, rootSectionName, (value, rootSection) => SystemTextJsonConfigurationSerializer.Serialize(value, jsonTypeInfo, rootSection)); } - private static IConfigurationBuilder AddObject(IConfigurationBuilder configurationBuilder, T? objectToAdd, string? rootSectionName, Func serialize) + private static IConfigurationBuilder AddObject(IConfigurationBuilder configurationBuilder, T? objectToAdd, string? rootSectionName, Func serialize) { if (configurationBuilder is null) { @@ -52,12 +52,12 @@ private static IConfigurationBuilder AddObject(IConfigurationBuilder configur return configurationBuilder; } - configurationBuilder.Add(CreateSource(objectToAdd, rootSectionName, serialize)); + configurationBuilder.Sources.Add(CreateSource(objectToAdd, rootSectionName, serialize)); return configurationBuilder; } - private static IConfigurationBuilder AddObjectAsFallback(IConfigurationBuilder configurationBuilder, T? objectToAdd, string? rootSectionName, Func serialize) + private static IConfigurationBuilder AddObjectAsFallback(IConfigurationBuilder configurationBuilder, T? objectToAdd, string? rootSectionName, Func serialize) { if (configurationBuilder is null) { @@ -74,15 +74,11 @@ private static IConfigurationBuilder AddObjectAsFallback(IConfigurationBuilde return configurationBuilder; } - private static ObjectConfigurationSource CreateSource(T objectToAdd, string? rootSectionName, Func serialize) + private static JsonStreamConfigurationSource CreateSource(T objectToAdd, string? rootSectionName, Func serialize) { - var rootSection = rootSectionName ?? string.Empty; - - return new ObjectConfigurationSource(() => + return new JsonStreamConfigurationSource { - var json = serialize(objectToAdd); - - return JsonConfigurationFlattener.Flatten(json, rootSection); - }); + Stream = serialize(objectToAdd, rootSectionName ?? string.Empty) + }; } } diff --git a/src/Objects/Objects.csproj b/src/Objects/Objects.csproj index e480d9e..052b63f 100644 --- a/src/Objects/Objects.csproj +++ b/src/Objects/Objects.csproj @@ -17,6 +17,7 @@ + diff --git a/tests/Tests.Objects/Internal/JsonConfigurationFlattenerTests.cs b/tests/Tests.Objects/Internal/JsonConfigurationFlattenerTests.cs deleted file mode 100644 index b9af91a..0000000 --- a/tests/Tests.Objects/Internal/JsonConfigurationFlattenerTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json; -using Kralizek.Extensions.Configuration.Internal; -using NUnit.Framework; - -namespace Tests.Internal; - -[TestFixture] -public class JsonConfigurationFlattenerTests -{ - [Test] - public void Null_value_is_preserved() - { - var result = Flatten("""{ "Value": null }"""); - - Assert.That(result, Contains.Key("Value")); - Assert.That(result["Value"], Is.Null); - } - - [Test] - public void Empty_string_is_preserved() - { - var result = Flatten("""{ "Value": "" }"""); - - Assert.That(result["Value"], Is.EqualTo(string.Empty)); - } - - [Test] - public void Empty_array_is_represented_by_an_empty_value() - { - var result = Flatten("""{ "Values": [] }"""); - - Assert.That(result["Values"], Is.EqualTo(string.Empty)); - } - - [Test] - public void Empty_object_is_represented_by_a_null_value() - { - var result = Flatten("""{ "Value": {} }"""); - - Assert.That(result, Contains.Key("Value")); - Assert.That(result["Value"], Is.Null); - } - - [Test] - public void Null_array_elements_are_preserved() - { - var result = Flatten("""{ "Values": [null, "one"] }"""); - - Assert.That(result, Contains.Key("Values:0")); - Assert.That(result["Values:0"], Is.Null); - Assert.That(result["Values:1"], Is.EqualTo("one")); - } - - [Test] - public void Root_section_is_applied_to_semantic_values() - { - var result = Flatten("""{ "Null": null, "Empty": [], "Text": "" }""", "Defaults"); - - Assert.That(result["Defaults:Null"], Is.Null); - Assert.That(result["Defaults:Empty"], Is.EqualTo(string.Empty)); - Assert.That(result["Defaults:Text"], Is.EqualTo(string.Empty)); - } - - [TestCase("42")] - [TestCase("[]")] - [TestCase("null")] - [TestCase("\"value\"")] - public void Non_object_root_without_root_section_is_rejected(string json) - { - Assert.That(() => Flatten(json), Throws.TypeOf()); - } - - [Test] - public void Non_object_root_with_root_section_is_supported() - { - var result = Flatten("42", "Value"); - - Assert.That(result["Value"], Is.EqualTo("42")); - } - - private static IDictionary Flatten(string json, string rootSectionName = "") - { - using var document = JsonDocument.Parse(json); - - return JsonConfigurationFlattener.Flatten(document.RootElement, rootSectionName); - } -} diff --git a/tests/Tests.Objects/Internal/SystemTextJsonConfigurationSerializerTests.cs b/tests/Tests.Objects/Internal/SystemTextJsonConfigurationSerializerTests.cs index 1eb6b71..a80da7e 100644 --- a/tests/Tests.Objects/Internal/SystemTextJsonConfigurationSerializerTests.cs +++ b/tests/Tests.Objects/Internal/SystemTextJsonConfigurationSerializerTests.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Text.Json; using Kralizek.Extensions.Configuration.Internal; using NUnit.Framework; @@ -10,7 +11,8 @@ public class SystemTextJsonConfigurationSerializerTests [Test] public void Serialize_preserves_empty_array_as_json_array() { - var result = SystemTextJsonConfigurationSerializer.Serialize(new ObjectWithStringArray { Values = [] }); + using var stream = SystemTextJsonConfigurationSerializer.Serialize(new ObjectWithStringArray { Values = [] }, string.Empty); + var result = Parse(stream); Assert.That(result.ValueKind, Is.EqualTo(JsonValueKind.Object)); Assert.That(result.GetProperty(nameof(ObjectWithStringArray.Values)).ValueKind, Is.EqualTo(JsonValueKind.Array)); @@ -20,21 +22,54 @@ public void Serialize_preserves_empty_array_as_json_array() [Test] public void Serialize_preserves_null_and_empty_string_values() { - var result = SystemTextJsonConfigurationSerializer.Serialize(new ObjectWithStringList { Values = [null, ""] }); + using var stream = SystemTextJsonConfigurationSerializer.Serialize(new ObjectWithStringList { Values = [null, ""] }, string.Empty); + var result = Parse(stream); var values = result.GetProperty(nameof(ObjectWithStringList.Values)); Assert.That(values[0].ValueKind, Is.EqualTo(JsonValueKind.Null)); Assert.That(values[1].GetString(), Is.EqualTo(string.Empty)); } + [Test] + public void Serialize_wraps_object_in_root_section() + { + using var stream = SystemTextJsonConfigurationSerializer.Serialize(new ObjectWithTwoScalars { Count = 2, Name = "rooted" }, "Settings"); + var result = Parse(stream); + var settings = result.GetProperty("Settings"); + + Assert.That(settings.GetProperty(nameof(ObjectWithTwoScalars.Count)).GetInt32(), Is.EqualTo(2)); + Assert.That(settings.GetProperty(nameof(ObjectWithTwoScalars.Name)).GetString(), Is.EqualTo("rooted")); + } + + [Test] + public void Serialize_wraps_object_in_multi_level_root_section() + { + using var stream = SystemTextJsonConfigurationSerializer.Serialize(new ObjectWithTwoScalars { Count = 3, Name = "payments" }, "Features:Payments"); + var result = Parse(stream); + var payments = result.GetProperty("Features").GetProperty("Payments"); + + Assert.That(payments.GetProperty(nameof(ObjectWithTwoScalars.Count)).GetInt32(), Is.EqualTo(3)); + Assert.That(payments.GetProperty(nameof(ObjectWithTwoScalars.Name)).GetString(), Is.EqualTo("payments")); + } + [Test] public void JsonTypeInfo_serialization_matches_reflection_serialization() { var source = new ObjectWithStringList { Values = ["one", null, ""] }; - var reflection = SystemTextJsonConfigurationSerializer.Serialize(source); - var sourceGenerated = SystemTextJsonConfigurationSerializer.Serialize(source, TestJsonContext.Default.ObjectWithStringList); + using var reflectionStream = SystemTextJsonConfigurationSerializer.Serialize(source, "Root:Nested"); + using var sourceGeneratedStream = SystemTextJsonConfigurationSerializer.Serialize(source, TestJsonContext.Default.ObjectWithStringList, "Root:Nested"); + + var reflection = Parse(reflectionStream); + var sourceGenerated = Parse(sourceGeneratedStream); Assert.That(JsonElement.DeepEquals(sourceGenerated, reflection), Is.True); } + + private static JsonElement Parse(MemoryStream stream) + { + using var document = JsonDocument.Parse(stream); + + return document.RootElement.Clone(); + } } diff --git a/tests/Tests.Objects/ObjectConfigurationIntegrationTests.cs b/tests/Tests.Objects/ObjectConfigurationIntegrationTests.cs index 62d729b..780b2bf 100644 --- a/tests/Tests.Objects/ObjectConfigurationIntegrationTests.cs +++ b/tests/Tests.Objects/ObjectConfigurationIntegrationTests.cs @@ -97,6 +97,70 @@ public void Added_object_can_bind_to_an_equivalent_different_type() }); } + [Test] + public void Multi_level_root_section_exposes_expected_configuration_keys() + { + var source = new ObjectWithTwoScalars + { + Count = 3, + Name = "payments" + }; + + var configuration = new ConfigurationBuilder() + .AddObject(source, "Features:Payments") + .Build(); + + Assert.Multiple(() => + { + Assert.That(configuration["Features:Payments:Count"], Is.EqualTo("3")); + Assert.That(configuration["Features:Payments:Name"], Is.EqualTo("payments")); + }); + } + + [Test] + public void Multi_level_root_section_with_json_type_info_binds_from_the_expected_section() + { + var source = new ObjectWithTwoScalars + { + Count = 5, + Name = "source-generated" + }; + + var configuration = new ConfigurationBuilder() + .AddObject(source, TestJsonContext.Default.ObjectWithTwoScalars, "Features:Payments") + .Build(); + + var result = configuration + .GetSection("Features:Payments") + .Get(); + + Assert.That(result, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(result!.Count, Is.EqualTo(source.Count)); + Assert.That(result.Name, Is.EqualTo(source.Name)); + }); + } + + [Test] + public void Scalar_value_with_root_section_is_supported() + { + var configuration = new ConfigurationBuilder() + .AddObject(42, "Value") + .Build(); + + Assert.That(configuration["Value"], Is.EqualTo("42")); + } + + [Test] + public void Scalar_value_without_root_section_is_rejected() + { + var builder = new ConfigurationBuilder() + .AddObject(42); + + Assert.That(() => builder.Build(), Throws.TypeOf()); + } + [Test] public void Nested_object_can_bind_to_an_equivalent_different_type() { diff --git a/tests/Tests.Objects/SystemTextJsonObjectConfigurationExtensionsTests.cs b/tests/Tests.Objects/SystemTextJsonObjectConfigurationExtensionsTests.cs index f4b7c99..aad71b0 100644 --- a/tests/Tests.Objects/SystemTextJsonObjectConfigurationExtensionsTests.cs +++ b/tests/Tests.Objects/SystemTextJsonObjectConfigurationExtensionsTests.cs @@ -92,7 +92,7 @@ public void Fallback_JsonTypeInfo_overload_matches_reflection_overload() } [Test] - public void Serialization_is_deferred_until_configuration_is_built() + public void Serialization_captures_object_state_when_source_is_registered() { var source = new ObjectWithTwoScalars { Name = "before", Count = 42 }; var builder = new ConfigurationBuilder().AddObject(source); @@ -101,6 +101,6 @@ public void Serialization_is_deferred_until_configuration_is_built() var configuration = builder.Build(); - Assert.That(configuration[nameof(ObjectWithTwoScalars.Name)], Is.EqualTo("after")); + Assert.That(configuration[nameof(ObjectWithTwoScalars.Name)], Is.EqualTo("before")); } }