diff --git a/DisCatSharp.Configuration.Tests/ConfigurationExtensionTests.cs b/DisCatSharp.Configuration.Tests/ConfigurationExtensionTests.cs index 17e490d1a..92a9e064b 100644 --- a/DisCatSharp.Configuration.Tests/ConfigurationExtensionTests.cs +++ b/DisCatSharp.Configuration.Tests/ConfigurationExtensionTests.cs @@ -1,308 +1,312 @@ using System; using System.Collections.Generic; using System.Linq; using DisCatSharp.Configuration.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Xunit; namespace DisCatSharp.Configuration.Tests { public class ConfigurationExtensionTests { #region Test Classes class SampleClass { public int Amount { get; set; } public string? Email { get; set; } } class ClassWithArray { public int[] Values { get; set; } = { 1, 2, 3, 4, 5 }; public string[] Strings { get; set; } = { "1", "2", "3", "4", "5" }; } class ClassWithEnumerable { public IEnumerable Values { get; set; } = new[] { 1, 2, 3, 4, 5 }; public IEnumerable Strings { get; set; } = new[] { "1", "2", "3", "4", "5" }; } class ClassWithList { public List Strings { get; set; } = new List { "1", "2", "3", "4", "5" }; public List Values { get; set; } = new List { 1, 2, 3, 4, 5 }; } class SampleClass2 { public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(7); public string Name { get; set; } = "Sample"; public string ConstructorValue { get; } public SampleClass2(string value) { this.ConstructorValue = value; } } #endregion private IConfiguration EnumerableTestConfiguration() => new ConfigurationBuilder() .AddJsonFile("enumerable-test.json") .Build(); private IConfiguration HasSectionWithSuffixConfiguration() => new ConfigurationBuilder() .AddJsonFile("section-with-suffix.json") .Build(); private IConfiguration HasSectionNoSuffixConfiguration() => new ConfigurationBuilder() .AddJsonFile("section-no-suffix.json") .Build(); private IConfiguration BasicDiscordConfiguration() => new ConfigurationBuilder() .AddJsonFile("default-discord.json") .Build(); private IConfiguration DiscordIntentsConfig() => new ConfigurationBuilder() .AddJsonFile("intents-discord.json") .Build(); private IConfiguration DiscordHaphazardConfig() => new ConfigurationBuilder() .AddJsonFile("haphazard-discord.json") .Build(); private IConfiguration SampleConfig() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "Sample:Amount", "200" }, { "Sample:Email", "test@gmail.com" } }) .Build(); private IConfiguration SampleClass2Configuration_Default() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { {"Random:Stuff", "Meow"}, {"SampleClass2:Name", "Purfection"} }) .Build(); private IConfiguration SampleClass2Configuration_Change() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "SampleClass:Timeout", "01:30:00" }, { "SampleClass:NotValid", "Something" } }) .Build(); private IConfiguration SampleClass2EnumerableTest() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "SampleClass:EnumerableTest", "[\"10\",\"20\",\"30\"]" } }) .Build(); private IConfiguration SampleClass2ArrayTest() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "SampleClass:ArrayTest", "[\"10\",\"20\",\"30\"]" } }) .Build(); private IConfiguration SampleClass2ListTest() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "SampleClass:ListTest", "[\"10\",\"20\",\"30\"]" } }) .Build(); [Fact] public void TestExtractDiscordConfig_Intents() { var source = this.DiscordIntentsConfig(); DiscordConfiguration config = source.ExtractConfig("Discord"); var expected = DiscordIntents.GuildEmojisAndStickers | DiscordIntents.GuildMembers | DiscordIntents.GuildInvites | DiscordIntents.GuildMessageReactions; Assert.Equal(expected, config.Intents); } [Fact] public void TestExtractDiscordConfig_Haphzard() { var source = this.DiscordHaphazardConfig(); DiscordConfiguration config = source.ExtractConfig("Discord"); var expectedIntents = DiscordIntents.GuildEmojisAndStickers | DiscordIntents.GuildMembers | DiscordIntents.Guilds; Assert.Equal(expectedIntents, config.Intents); Assert.True(config.MobileStatus); Assert.Equal(1000, config.LargeThreshold); Assert.Equal(TimeSpan.FromHours(10), config.HttpTimeout); } [Fact] public void TestExtractDiscordConfig_Default() { var source = this.BasicDiscordConfiguration(); DiscordConfiguration config = source.ExtractConfig("Discord"); Assert.Equal("1234567890", config.Token); Assert.Equal(TokenType.Bot, config.TokenType); Assert.Equal(LogLevel.Information, config.MinimumLogLevel); Assert.True(config.UseRelativeRatelimit); Assert.Equal("yyyy-MM-dd HH:mm:ss zzz", config.LogTimestampFormat); Assert.Equal(250, config.LargeThreshold); Assert.True(config.AutoReconnect); Assert.Equal(123123, config.ShardId); Assert.Equal(GatewayCompressionLevel.Stream, config.GatewayCompressionLevel); Assert.Equal(1024, config.MessageCacheSize); Assert.Equal(TimeSpan.FromSeconds(20), config.HttpTimeout); Assert.False(config.ReconnectIndefinitely); Assert.True(config.AlwaysCacheMembers); Assert.Equal(DiscordIntents.AllUnprivileged, config.Intents); Assert.False(config.MobileStatus); Assert.False(config.UseCanary); Assert.False(config.AutoRefreshChannelCache); } [Fact] public void TestSection() { var source = this.SampleConfig(); SampleClass config = source.ExtractConfig("Sample", null); Assert.Equal(200, config.Amount); Assert.Equal("test@gmail.com", config.Email); } [Fact] public void TestExtractConfig_V2_Default() { var source = this.SampleClass2Configuration_Default(); var config = (SampleClass2) source.ExtractConfig("SampleClass", () => new SampleClass2("Test"), null); Assert.Equal(TimeSpan.FromMinutes(7), config.Timeout); Assert.Equal("Test", config.ConstructorValue); Assert.Equal("Sample", config.Name); } [Fact] public void TestExtractConfig_V2_Change() { var source = this.SampleClass2Configuration_Change(); var config = (SampleClass2) source.ExtractConfig("SampleClass", () => new SampleClass2("Test123"), null); var span = new TimeSpan(0, 1, 30, 0); Assert.Equal(span, config.Timeout); Assert.Equal("Test123", config.ConstructorValue); Assert.Equal("Sample", config.Name); } [Fact] public void TestExtractConfig_V3_Default() { var source = this.SampleClass2Configuration_Default(); var config = (SampleClass2)new ConfigSection(ref source, "SampleClass", null).ExtractConfig(() => new SampleClass2("Meow")); Assert.Equal("Meow", config.ConstructorValue); Assert.Equal(TimeSpan.FromMinutes(7), config.Timeout); Assert.Equal("Sample", config.Name); } [Fact] public void TestExtractConfig_V3_Change() { var source = this.SampleClass2Configuration_Change(); var config = (SampleClass2)new ConfigSection(ref source, "SampleClass", null).ExtractConfig(() => new SampleClass2("Meow")); Assert.Equal("Meow", config.ConstructorValue); var span = new TimeSpan(0, 1, 30, 0); Assert.Equal(span, config.Timeout); Assert.Equal("Sample", config.Name); } [Fact] public void TestExtractConfig_Enumerable() { var source = this.EnumerableTestConfiguration(); var config = (ClassWithEnumerable)new ConfigSection(ref source, "ClassWithEnumerable", null).ExtractConfig(() => new ClassWithEnumerable()); Assert.NotNull(config.Values); Assert.Equal(3, config.Values.Count()); Assert.NotNull(config.Strings); Assert.Equal(3, config.Values.Count()); } [Fact] public void TestExtractConfig_Array() { var source = this.EnumerableTestConfiguration(); var config = (ClassWithArray)new ConfigSection(ref source, "ClassWithArray", null).ExtractConfig(() => new ClassWithArray()); Assert.NotNull(config.Values); Assert.Equal(3, config.Values.Length); Assert.NotNull(config.Strings); Assert.Equal(3, config.Values.Length); } [Fact] public void TestExtractConfig_List() { var source = this.EnumerableTestConfiguration(); var config = (ClassWithList)new ConfigSection(ref source, "ClassWithList", null).ExtractConfig(() => new ClassWithList()); Assert.NotNull(config.Values); Assert.Equal(3, config.Values.Count); Assert.NotNull(config.Strings); Assert.Equal(3, config.Values.Count); } [Fact] public void TestHasSectionWithSuffix() { var source = this.HasSectionWithSuffixConfiguration(); Assert.True(source.HasSection("DiscordConfiguration")); Assert.False(source.HasSection("Discord")); +#pragma warning disable 8625 Assert.False(source.HasSection("DiscordConfiguration", null)); +#pragma warning restore 8625 } [Fact] public void TestHasSectionNoSuffix() { var source = this.HasSectionNoSuffixConfiguration(); Assert.True(source.HasSection("Discord")); Assert.False(source.HasSection("DiscordConfiguration")); +#pragma warning disable 8625 Assert.False(source.HasSection("Discord", null)); +#pragma warning restore 8625 } } } diff --git a/DisCatSharp.Configuration/ConfigurationExtensions.cs b/DisCatSharp.Configuration/ConfigurationExtensions.cs index 4047784e8..76e6d9633 100644 --- a/DisCatSharp.Configuration/ConfigurationExtensions.cs +++ b/DisCatSharp.Configuration/ConfigurationExtensions.cs @@ -1,228 +1,227 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections; using System.Linq; using System.Reflection; using DisCatSharp.Configuration.Models; using Microsoft.Extensions.Configuration; namespace DisCatSharp.Configuration { internal static class ConfigurationExtensions { private const string FactoryErrorMessage = "Require a function which provides a default entity to work with"; public const string DefaultRootLib = "DisCatSharp"; /// /// Easily piece together paths that will work within /// /// (not used - only for adding context based functionality) /// The strings to piece together /// Strings joined together via ':' public static string ConfigPath(this IConfiguration config, params string[] values) => string.Join(":", values); /// /// Skims over the configuration section and only overrides values that are explicitly defined within the config /// /// Instance of config /// Section which contains values for private static void HydrateInstance(ref object config, ConfigSection section) { PropertyInfo[] props = config.GetType().GetProperties(); foreach (var prop in props) { // Must have a set method for this to work, otherwise continue on if (prop.SetMethod == null) continue; string entry = section.GetValue(prop.Name); object? value = null; if (typeof(string) == prop.PropertyType) { // We do NOT want to override value if nothing was provided if(!string.IsNullOrEmpty(entry)) prop.SetValue(config, entry); continue; } // We need to address collections a bit differently // They can come in the form of "root:section:name" with a string representation OR // "root:section:name:0" <--- this is not detectable when checking the above path if (typeof(IEnumerable).IsAssignableFrom(prop.PropertyType)) { if (string.IsNullOrEmpty(section.GetValue(prop.Name))) value = section.Config .GetSection(section.GetPath(prop.Name)).Get(prop.PropertyType); else value = Newtonsoft.Json.JsonConvert.DeserializeObject(entry, prop.PropertyType); if (value == null) continue; prop.SetValue(config, value); } // From this point onward we require the 'entry' value to have something useful if (string.IsNullOrEmpty(entry)) continue; try { // Primitive types are simple to convert if (prop.PropertyType.IsPrimitive) value = Convert.ChangeType(entry, prop.PropertyType); else { // The following types require a different approach if (prop.PropertyType.IsEnum) value = Enum.Parse(prop.PropertyType, entry); else if (typeof(TimeSpan) == prop.PropertyType) value = TimeSpan.Parse(entry); else if (typeof(DateTime) == prop.PropertyType) value = DateTime.Parse(entry); else if (typeof(DateTimeOffset) == prop.PropertyType) value = DateTimeOffset.Parse(entry); } // Update value within our config instance prop.SetValue(config, value); } catch (Exception ex) { Console.Error.WriteLine( $"Unable to convert value of '{entry}' to type '{prop.PropertyType.Name}' for prop '{prop.Name}' in config '{config.GetType().Name}'\n\t\t{ex.Message}"); } } } /// /// Instantiate an entity using then walk through the specified /// and translate user-defined config values to the instantiated instance from /// /// Section containing values for targeted config /// Function which generates a default entity /// Hydrated instance of an entity which contains user-defined values (if any) /// When is null public static object ExtractConfig(this ConfigSection section, Func factory) { if (factory == null) throw new ArgumentNullException(nameof(factory),FactoryErrorMessage); // Create default instance object config = factory(); HydrateInstance(ref config, section); return config; } /// /// Instantiate an entity using then walk through the specified /// in . Translate user-defined config values to the instantiated instance from /// /// Loaded App Configuration /// Name of section to load /// Function which creates a default entity to work with /// (Optional) Used when section is nested within another. Default value is /// Hydrated instance of an entity which contains user-defined values (if any) /// When is null public static object ExtractConfig(this IConfiguration config, string sectionName, Func factory, string? rootSectionName = DefaultRootLib) { if (factory == null) throw new ArgumentNullException(nameof(factory), FactoryErrorMessage); // create default instance object instance = factory(); HydrateInstance(ref instance, new ConfigSection(ref config, sectionName, rootSectionName)); return instance; } /// /// Instantiate a new instance of , then walk through the specified /// in . Translate user-defined config values to the instance. /// /// Loaded App Configuration /// Name of section to load /// (Optional) Used when section is nested with another. Default value is /// Type of instance that represents /// Hydrated instance of which contains the user-defined values (if any). public static TConfig ExtractConfig(this IConfiguration config, string sectionName, string? rootSectionName = DefaultRootLib) where TConfig : new() { // Default values should hopefully be provided from the constructor object configInstance = new TConfig(); HydrateInstance(ref configInstance, new ConfigSection(ref config, sectionName, rootSectionName)); return (TConfig) configInstance; } /// /// Determines if contains a particular section/object (not value) /// /// /// /// { /// "Discord": { // this is a section/object /// /// }, /// "Value": "something" // this is not a section/object /// } /// /// /// /// /// True if section exists, otherwise false public static bool HasSection(this IConfiguration config, params string[] values) { if (!values.Any()) return false; if (values.Length == 1) return config.GetChildren().Any(x => x.Key == values[0]); - int index = 0; if (config.GetChildren().All(x => x.Key != values[0])) return false; IConfigurationSection current = config.GetSection(values[0]); for (int i = 1; i < values.Length - 1; i++) { if (current.GetChildren().All(x => x.Key != values[i])) return false; current = current.GetSection(values[i]); } return current.GetChildren().Any(x=>x.Key == values[^1]); } } } diff --git a/DisCatSharp.Configuration/Models/ConfigSection.cs b/DisCatSharp.Configuration/Models/ConfigSection.cs index 64ccbd8a5..162981435 100644 --- a/DisCatSharp.Configuration/Models/ConfigSection.cs +++ b/DisCatSharp.Configuration/Models/ConfigSection.cs @@ -1,88 +1,87 @@ // This file is part of the DisCatSharp project, a fork of DSharpPlus. // // Copyright (c) 2021 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using Microsoft.Extensions.Configuration; namespace DisCatSharp.Configuration.Models { /// /// Represents an object in /// internal readonly struct ConfigSection { /// /// Key within which represents an object containing multiple values /// public string SectionName { get;} /// /// Optional used to indicate this section is nested within another /// public string? Root { get; } /// /// Reference to used within application /// public IConfiguration Config { get; } /// Reference to config /// Section of interest /// (Optional) Indicates is nested within this name. Default value is DisCatSharp public ConfigSection(ref IConfiguration config, string sectionName, string? rootName = "DisCatSharp") { this.Config = config; this.SectionName = sectionName; this.Root = rootName; } /// /// Checks if key exists in /// /// Property / Key to search for in section - /// Config path to key /// True if key exists, otherwise false. Outputs path to config regardless public bool ContainsKey(string name) { string path = string.IsNullOrEmpty(this.Root) ? this.Config.ConfigPath(this.SectionName, name) : this.Config.ConfigPath(this.Root, this.SectionName, name); return !string.IsNullOrEmpty(this.Config[path]); } /// /// Attempts to get value associated to the config path.
Should be used in unison with ///
/// Config path to value /// Value found at public string GetValue(string propName) => this.Config[this.GetPath(propName)]; public string GetPath(string value) { if (string.IsNullOrEmpty(this.Root)) return this.Config.ConfigPath(this.SectionName, value); return this.Config.ConfigPath(this.Root, this.SectionName, value); } } }