diff --git a/src/NetEscapades.Configuration.Yaml/YamlConfigurationExtensions.cs b/src/NetEscapades.Configuration.Yaml/YamlConfigurationExtensions.cs index 97171eb..30fed83 100644 --- a/src/NetEscapades.Configuration.Yaml/YamlConfigurationExtensions.cs +++ b/src/NetEscapades.Configuration.Yaml/YamlConfigurationExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.FileProviders; using NetEscapades.Configuration.Yaml; using YamlDotNet.Core; +using YamlDotNet.Serialization; namespace Microsoft.Extensions.Configuration { @@ -19,39 +20,52 @@ public static class YamlConfigurationExtensions /// Adds the YAML configuration provider at to . /// /// The to add to. - /// Path relative to the base path stored in + /// Path relative to the base path stored in + /// of . + /// The action that configures the YAML deserializer. + /// The . + public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder builder, string path, Action configureDeserializer = null) + { + return AddYamlFile(builder, provider: null, path: path, optional: false, reloadOnChange: false, configureDeserializer: configureDeserializer); + } + + /// + /// Adds the YAML configuration provider at to . + /// + /// The to add to. + /// Path relative to the base path stored in /// of . /// The . public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder builder, string path) { - return AddYamlFile(builder, provider: null, path: path, optional: false, reloadOnChange: false); + return AddYamlFile(builder, provider: null, path: path, optional: false, reloadOnChange: false, configureDeserializer: null); } /// /// Adds the YAML configuration provider at to . /// /// The to add to. - /// Path relative to the base path stored in + /// Path relative to the base path stored in /// of . /// Whether the file is optional. /// The . public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder builder, string path, bool optional) { - return AddYamlFile(builder, provider: null, path: path, optional: optional, reloadOnChange: false); + return AddYamlFile(builder, provider: null, path: path, optional: optional, reloadOnChange: false, configureDeserializer: null); } /// /// Adds the YAML configuration provider at to . /// /// The to add to. - /// Path relative to the base path stored in + /// Path relative to the base path stored in /// of . /// Whether the file is optional. /// Whether the configuration should be reloaded if the file changes. /// The . - public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder builder, string path, bool optional, bool reloadOnChange) + public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder builder, string path, bool optional, bool reloadOnChange, Action configureDeserializer) { - return AddYamlFile(builder, provider: null, path: path, optional: optional, reloadOnChange: reloadOnChange); + return AddYamlFile(builder, provider: null, path: path, optional: optional, reloadOnChange: reloadOnChange, configureDeserializer: configureDeserializer); } /// @@ -59,12 +73,12 @@ public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder build /// /// The to add to. /// The to use to access the file. - /// Path relative to the base path stored in + /// Path relative to the base path stored in /// of . /// Whether the file is optional. /// Whether the configuration should be reloaded if the file changes. /// The . - public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder builder, IFileProvider provider, string path, bool optional, bool reloadOnChange) + public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder builder, IFileProvider provider, string path, bool optional, bool reloadOnChange, Action configureDeserializer) { if (builder == null) { @@ -74,17 +88,18 @@ public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder build { throw new ArgumentException(Resources.FormatError_InvalidFilePath(), nameof(path)); } - + return builder.AddYamlFile(s => { s.FileProvider = provider; s.Path = path; s.Optional = optional; s.ReloadOnChange = reloadOnChange; + s.ConfigureDeserializer = configureDeserializer; s.ResolveFileProvider(); }); } - + /// /// Adds a YAML configuration source to . /// @@ -100,22 +115,22 @@ public static IConfigurationBuilder AddYamlFile(this IConfigurationBuilder build /// The to add to. /// The to read the yaml configuration data from. /// The . - public static IConfigurationBuilder AddYamlStream(this IConfigurationBuilder builder, Stream stream) + public static IConfigurationBuilder AddYamlStream(this IConfigurationBuilder builder, Stream stream, Action configureDeserializer = null) { if (builder == null) { throw new ArgumentNullException(nameof(builder)); } - var data = ReadStream(stream); + var data = ReadStream(stream, configureDeserializer); return builder.Add(s => s.Data = data); - static IDictionary ReadStream(Stream s) + static IDictionary ReadStream(Stream s, Action configureDeserializer) { try { - return new YamlConfigurationStreamParser().Parse(s); + return new YamlConfigurationStreamParser(configureDeserializer).Parse(s); } catch (YamlException e) { @@ -124,4 +139,4 @@ static IDictionary ReadStream(Stream s) } } } -} \ No newline at end of file +} diff --git a/src/NetEscapades.Configuration.Yaml/YamlConfigurationProvider.cs b/src/NetEscapades.Configuration.Yaml/YamlConfigurationProvider.cs index 9de2bb4..10c6430 100644 --- a/src/NetEscapades.Configuration.Yaml/YamlConfigurationProvider.cs +++ b/src/NetEscapades.Configuration.Yaml/YamlConfigurationProvider.cs @@ -10,11 +10,16 @@ namespace NetEscapades.Configuration.Yaml /// public class YamlConfigurationProvider : FileConfigurationProvider { - public YamlConfigurationProvider(YamlConfigurationSource source) : base(source) { } + private readonly YamlConfigurationSource _source; + + public YamlConfigurationProvider(YamlConfigurationSource source) : base(source) + { + _source = source; + } public override void Load(Stream stream) { - var parser = new YamlConfigurationStreamParser(); + var parser = new YamlConfigurationStreamParser(_source.ConfigureDeserializer); try { Data = parser.Parse(stream); diff --git a/src/NetEscapades.Configuration.Yaml/YamlConfigurationSource.cs b/src/NetEscapades.Configuration.Yaml/YamlConfigurationSource.cs index 2e80ca4..abde3b1 100644 --- a/src/NetEscapades.Configuration.Yaml/YamlConfigurationSource.cs +++ b/src/NetEscapades.Configuration.Yaml/YamlConfigurationSource.cs @@ -1,4 +1,6 @@ +using System; using Microsoft.Extensions.Configuration; +using YamlDotNet.Serialization; namespace NetEscapades.Configuration.Yaml { @@ -7,10 +9,12 @@ namespace NetEscapades.Configuration.Yaml /// public class YamlConfigurationSource : FileConfigurationSource { + public Action ConfigureDeserializer { get; set; } + public override IConfigurationProvider Build(IConfigurationBuilder builder) { EnsureDefaults(builder); return new YamlConfigurationProvider(this); } } -} \ No newline at end of file +} diff --git a/src/NetEscapades.Configuration.Yaml/YamlConfigurationStreamParser.cs b/src/NetEscapades.Configuration.Yaml/YamlConfigurationStreamParser.cs index 38d262a..bd4cbf9 100644 --- a/src/NetEscapades.Configuration.Yaml/YamlConfigurationStreamParser.cs +++ b/src/NetEscapades.Configuration.Yaml/YamlConfigurationStreamParser.cs @@ -1,132 +1,133 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; -using System.Linq; using Microsoft.Extensions.Configuration; -using YamlDotNet.RepresentationModel; +using YamlDotNet.Core; +using YamlDotNet.Serialization; namespace NetEscapades.Configuration.Yaml { internal class YamlConfigurationStreamParser { + private readonly Action _configureDeserializer; private readonly IDictionary _data = new SortedDictionary(StringComparer.OrdinalIgnoreCase); private readonly Stack _context = new Stack(); - private string _currentPath; + + public YamlConfigurationStreamParser(Action configureDeserializer) + { + _configureDeserializer = configureDeserializer; + } public IDictionary Parse(Stream input) { _data.Clear(); _context.Clear(); - // https://dotnetfiddle.net/rrR2Bb - var yaml = new YamlStream(); - yaml.Load(new StreamReader(input, detectEncodingFromByteOrderMarks: true)); + using var reader = new StreamReader(input, detectEncodingFromByteOrderMarks: true); + var parser = new Parser(reader); + var document = CreateDeserializer().Deserialize(parser); - if (yaml.Documents.Any()) + if (document is not IDictionary documentDict) { - var mapping = (YamlMappingNode)yaml.Documents[0].RootNode; - - // The document node is a mapping node - VisitYamlMappingNode(mapping); + throw new FormatException("Root document must be a dictionary, but got " + document?.GetType().FullName); } + VisitMap(documentDict); + return _data; } - private void VisitYamlNodePair(KeyValuePair yamlNodePair) + private IDeserializer CreateDeserializer() { - var context = ((YamlScalarNode)yamlNodePair.Key).Value; - VisitYamlNode(context, yamlNodePair.Value); + var builder = new DeserializerBuilder(); + builder.WithAttemptingUnquotedStringTypeDeserialization(); + _configureDeserializer?.Invoke(builder); + return builder.Build(); } - private void VisitYamlNode(string context, YamlNode node) + private void VisitMap(IDictionary map) { - if (node is YamlScalarNode scalarNode) - { - VisitYamlScalarNode(context, scalarNode); - } - if (node is YamlMappingNode mappingNode) - { - VisitYamlMappingNode(context, mappingNode); - } - if (node is YamlSequenceNode sequenceNode) - { - VisitYamlSequenceNode(context, sequenceNode); - } - } + var isEmpty = true; - private void VisitYamlScalarNode(string context, YamlScalarNode yamlValue) - { - //a node with a single 1-1 mapping - EnterContext(context); - var currentKey = _currentPath; - - if (_data.ContainsKey(currentKey)) + foreach (var entry in map) { - throw new FormatException(Resources.FormatError_KeyIsDuplicated(currentKey)); + isEmpty = false; + EnterContext(entry.Key.ToString()!); + Visit(entry.Value); + ExitContext(); } - _data[currentKey] = IsNullValue(yamlValue) ? null : yamlValue.Value; - ExitContext(); + SetNullIfElementIsEmpty(isEmpty); } - private void VisitYamlMappingNode(YamlMappingNode node) + private void Visit(object value) { - foreach (var yamlNodePair in node.Children) + switch (value) { - VisitYamlNodePair(yamlNodePair); + case IDictionary map: + VisitMap(map); + break; + case IList sequence: + VisitSequence(sequence); + break; + default: + VisitScalar(value); + break; } } - private void VisitYamlMappingNode(string context, YamlMappingNode yamlValue) + private void VisitScalar(object scalar) { - //a node with an associated sub-document - EnterContext(context); - - VisitYamlMappingNode(yamlValue); + var currentKey = _context.Peek(); + if (_data.ContainsKey(currentKey)) + { + throw new FormatException(Resources.FormatError_KeyIsDuplicated(currentKey)); + } - ExitContext(); + _data[currentKey] = scalar switch + { + null => "", + bool boolean => boolean ? "true" : "false", + string str => str, + // the only remaining type is a number, but there is not a common base class "Number". the best thing to use is IFormattable, which is also used to ensure numbers are formatted correctly + IFormattable formattable => formattable.ToString("G", CultureInfo.InvariantCulture), + _ => throw new FormatException($"Unsupported scalar type: {scalar.GetType().Name}") + }; } - private void VisitYamlSequenceNode(string context, YamlSequenceNode yamlValue) + private void VisitSequence(IList sequence) { - //a node with an associated list - EnterContext(context); + var i = 0; - VisitYamlSequenceNode(yamlValue); - - ExitContext(); - } - - private void VisitYamlSequenceNode(YamlSequenceNode node) - { - for (int i = 0; i < node.Children.Count; i++) + for (; i < sequence.Count; i++) { - VisitYamlNode(i.ToString(), node.Children[i]); + EnterContext(i.ToString(CultureInfo.InvariantCulture)); + Visit(sequence[i]); + ExitContext(); } + + SetNullIfElementIsEmpty(i == 0); } private void EnterContext(string context) { - _context.Push(context); - _currentPath = ConfigurationPath.Combine(_context.Reverse()); + _context.Push(_context.Count > 0 ? + _context.Peek() + ConfigurationPath.KeyDelimiter + context : + context); } private void ExitContext() { _context.Pop(); - _currentPath = ConfigurationPath.Combine(_context.Reverse()); } - private bool IsNullValue(YamlScalarNode yamlValue) + private void SetNullIfElementIsEmpty(bool isEmpty) { - return yamlValue.Style == YamlDotNet.Core.ScalarStyle.Plain - && ( - yamlValue.Value == "~" - || yamlValue.Value == "null" - || yamlValue.Value == "Null" - || yamlValue.Value == "NULL" - ); + if (isEmpty && _context.Count > 0) + { + _data[_context.Peek()] = null; + } } } } diff --git a/test/NetEscapades.Configuration.Yaml.Tests/YamlConfigurationTests.cs b/test/NetEscapades.Configuration.Yaml.Tests/YamlConfigurationTests.cs index 1cb7ec1..052b4a6 100644 --- a/test/NetEscapades.Configuration.Yaml.Tests/YamlConfigurationTests.cs +++ b/test/NetEscapades.Configuration.Yaml.Tests/YamlConfigurationTests.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Globalization; using System.IO; using Microsoft.Extensions.Configuration; using NetEscapades.Configuration.Tests.Common; @@ -56,6 +57,43 @@ public void LoadMethodCanHandleEmptyValue() Assert.Equal(string.Empty, yamlConfigSrc.Get("name")); } + [Fact] + public void LoadMethodCanHandleBooleanValue() + { + var yaml = @" + boolean: true + boolean2: false + boolean3: yes + boolean4: no + "; + var yamlConfigSrc = LoadProvider(yaml); + Assert.Equal("true", yamlConfigSrc.Get("boolean")); + Assert.Equal("false", yamlConfigSrc.Get("boolean2")); + Assert.Equal("true", yamlConfigSrc.Get("boolean3")); + Assert.Equal("false", yamlConfigSrc.Get("boolean4")); + } + + [Fact] + public void LoadMethodCanHandleNumberValueInDifferentCultures() + { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + var yaml = @" + number: 12345.6789 + "; + var yamlConfigSrc = LoadProvider(yaml); + Assert.Equal("12345.6789", yamlConfigSrc.Get("number")); + } + + [Fact] + public void LoadMethodCanHandleYamlTags() + { + var yaml = @" + number: !int 123_456_789 + "; + var yamlConfigSrc = LoadProvider(yaml); + Assert.Equal("123456789", yamlConfigSrc.Get("number")); + } + [Fact] public void LoadMethodCanHandleNullInObject() {