diff --git a/DragonFruit.Common.Data.Tests/QueryCompilationTests.cs b/DragonFruit.Common.Data.Tests/QueryCompilationTests.cs new file mode 100644 index 0000000..d22d292 --- /dev/null +++ b/DragonFruit.Common.Data.Tests/QueryCompilationTests.cs @@ -0,0 +1,49 @@ +// DragonFruit.Common Copyright 2020 DragonFruit Network +// Licensed under the MIT License. Please refer to the LICENSE file at the root of this project for details + +using System.Linq; +using DragonFruit.Common.Data.Parameters; +using NUnit.Framework; + +namespace DragonFruit.Common.Data.Tests +{ + [TestFixture] + public class QueryCompilationTests + { + [TestCase] + public void TestQueries() + { + var query = new TestRequest().FullUrl.Split('?').Last().Split('&'); + + for (int i = 0; i < TestRequest.TestDataset.Length; i++) + { + var testString = TestRequest.TestDataset[i]; + Assert.IsTrue(query.Contains($"{TestRequest.QueryName}={testString}")); + Assert.IsTrue(query.Contains($"{TestRequest.QueryName}[]={testString}")); + Assert.IsTrue(query.Contains($"{TestRequest.QueryName}[{i}]={testString}")); + } + + Assert.IsTrue(query.Contains($"{TestRequest.QueryName}={string.Join(":", TestRequest.TestDataset)}")); + } + } + + internal class TestRequest : ApiRequest + { + internal const string QueryName = "data"; + internal static readonly string[] TestDataset = { "a", "b", "c" }; + + public override string Path => "http://example.com"; + + [QueryParameter(QueryName, CollectionConversionMode.Recursive)] + public string[] RecursiveData { get; set; } = TestDataset; + + [QueryParameter(QueryName, CollectionConversionMode.Ordered)] + public string[] OrderedData { get; set; } = TestDataset; + + [QueryParameter(QueryName, CollectionConversionMode.Unordered)] + public string[] UnorderedData { get; set; } = TestDataset; + + [QueryParameter(QueryName, CollectionConversionMode.Concatenated, CollectionSeparator = ":")] + public string[] ConcatenatedData { get; set; } = TestDataset; + } +} diff --git a/DragonFruit.Common.Data/ApiRequest.cs b/DragonFruit.Common.Data/ApiRequest.cs index 734a058..5cfc6db 100644 --- a/DragonFruit.Common.Data/ApiRequest.cs +++ b/DragonFruit.Common.Data/ApiRequest.cs @@ -6,7 +6,6 @@ using System.Globalization; using System.Net.Http; using System.Net.Http.Headers; -using DragonFruit.Common.Data.Exceptions; using DragonFruit.Common.Data.Parameters; using DragonFruit.Common.Data.Serializers; using DragonFruit.Common.Data.Utils; diff --git a/DragonFruit.Common.Data/Methods.cs b/DragonFruit.Common.Data/Methods.cs index 0eda7f6..2ae94cd 100644 --- a/DragonFruit.Common.Data/Methods.cs +++ b/DragonFruit.Common.Data/Methods.cs @@ -38,4 +38,27 @@ public enum BodyType /// Custom } + + public enum CollectionConversionMode + { + /// + /// The query name is repeated and a new element created for each (a=1&a=2&a=3) + /// + Recursive, + + /// + /// The query name has indexer symbols appended with no order (a[]=1&a[]=2&a[]=3) + /// + Unordered, + + /// + /// The query name has indexer symbols appended explicit order inserted (a[0]=1&a[1]=2&a[2]=3) + /// + Ordered, + + /// + /// The query is concatenated with a string and merged with one key (a=1,2,3) + /// + Concatenated + } } diff --git a/DragonFruit.Common.Data/Parameters/FormParameter.cs b/DragonFruit.Common.Data/Parameters/FormParameter.cs index 84e2c76..7a35abd 100644 --- a/DragonFruit.Common.Data/Parameters/FormParameter.cs +++ b/DragonFruit.Common.Data/Parameters/FormParameter.cs @@ -3,16 +3,31 @@ using System; +#nullable enable + namespace DragonFruit.Common.Data.Parameters { [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] public class FormParameter : Attribute, IProperty { + public FormParameter() + { + } + public FormParameter(string name) { Name = name; } - public string Name { get; } + public FormParameter(string name, CollectionConversionMode collectionHandling) + : this(name) + { + CollectionHandling = collectionHandling; + } + + public string? Name { get; set; } + public CollectionConversionMode? CollectionHandling { get; set; } + + public string? CollectionSeparator { get; set; } } } diff --git a/DragonFruit.Common.Data/Parameters/IProperty.cs b/DragonFruit.Common.Data/Parameters/IProperty.cs index 49fde2d..2ea4744 100644 --- a/DragonFruit.Common.Data/Parameters/IProperty.cs +++ b/DragonFruit.Common.Data/Parameters/IProperty.cs @@ -1,10 +1,16 @@ // DragonFruit.Common Copyright 2020 DragonFruit Network // Licensed under the MIT License. Please refer to the LICENSE file at the root of this project for details +#nullable enable + namespace DragonFruit.Common.Data.Parameters { public interface IProperty { - string Name { get; } + string? Name { get; set; } + + CollectionConversionMode? CollectionHandling { get; set; } + + string? CollectionSeparator { get; set; } } } diff --git a/DragonFruit.Common.Data/Parameters/QueryParameter.cs b/DragonFruit.Common.Data/Parameters/QueryParameter.cs index b3cda73..0fb28bc 100644 --- a/DragonFruit.Common.Data/Parameters/QueryParameter.cs +++ b/DragonFruit.Common.Data/Parameters/QueryParameter.cs @@ -3,16 +3,30 @@ using System; +#nullable enable + namespace DragonFruit.Common.Data.Parameters { [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] public class QueryParameter : Attribute, IProperty { + public QueryParameter() + { + } + public QueryParameter(string name) { Name = name; } - public string Name { get; } + public QueryParameter(string name, CollectionConversionMode collectionConversionMode) + : this(name) + { + CollectionHandling = collectionConversionMode; + } + + public string? Name { get; set; } + public CollectionConversionMode? CollectionHandling { get; set; } + public string? CollectionSeparator { get; set; } } } diff --git a/DragonFruit.Common.Data/Utils/CultureUtils.cs b/DragonFruit.Common.Data/Utils/CultureUtils.cs index ae54940..08a268d 100644 --- a/DragonFruit.Common.Data/Utils/CultureUtils.cs +++ b/DragonFruit.Common.Data/Utils/CultureUtils.cs @@ -14,5 +14,13 @@ public static CultureInfo DefaultCulture get => _defaultCulture ?? CultureInfo.InvariantCulture; set => _defaultCulture = value; } + + internal static string AsString(this object value, CultureInfo culture = null) => value switch + { + bool boolVar => boolVar.ToString().ToLower(culture ?? DefaultCulture), + null => null, + + _ => value.ToString() + }; } } diff --git a/DragonFruit.Common.Data/Utils/ParameterUtils.cs b/DragonFruit.Common.Data/Utils/ParameterUtils.cs index 72de68a..609299c 100644 --- a/DragonFruit.Common.Data/Utils/ParameterUtils.cs +++ b/DragonFruit.Common.Data/Utils/ParameterUtils.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. Please refer to the LICENSE file at the root of this project for details using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -12,6 +13,8 @@ namespace DragonFruit.Common.Data.Utils { public static class ParameterUtils { + private const string DefaultConcatenationCharacter = ","; + /// /// Default to search for matching properties /// @@ -22,26 +25,45 @@ public static class ParameterUtils /// internal static IEnumerable> GetParameter(object host, CultureInfo culture) where T : IProperty { - var type = typeof(T); - foreach (var property in host.GetType().GetProperties(DefaultFlags)) { - if (!(Attribute.GetCustomAttribute(property, type) is T parameter)) + if (!property.CanRead || !(Attribute.GetCustomAttribute(property, typeof(T)) is T attribute)) + { + continue; + } + + var keyName = attribute.Name ?? property.Name; + var propertyValue = property.GetValue(host); + + if (propertyValue == null) { + // ignore null values continue; } - var value = property.GetValue(host, null); - string convertedValue = value switch + // check if the type we've got is an IEnumerable of anything AND we have a valid collection handler mode + if (attribute.CollectionHandling.HasValue && typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) { - bool boolVar => boolVar.ToString().ToLower(culture), - null => null, + Func, string, CultureInfo, IEnumerable>> entityConverter = attribute.CollectionHandling switch + { + CollectionConversionMode.Recursive => ApplyRecursiveConversion, + CollectionConversionMode.Unordered => ApplyUnorderedConversion, + CollectionConversionMode.Ordered => ApplyOrderedConversion, + CollectionConversionMode.Concatenated => (a, b, c) => ApplyConcatenation(a, b, c, attribute.CollectionSeparator ?? DefaultConcatenationCharacter), - _ => value.ToString() - }; + _ => throw new ArgumentOutOfRangeException() + }; - if (convertedValue != null) - yield return new KeyValuePair(parameter.Name, convertedValue); + foreach (var entry in entityConverter.Invoke((IEnumerable)propertyValue, keyName, culture)) + { + // we purposely keep nulls in here, as it might affect the ordering. + yield return entry; + } + } + else + { + yield return propertyValue.ToKeyValuePair(keyName, culture); + } } } @@ -50,10 +72,61 @@ internal static IEnumerable> GetParameter(object /// internal static object GetSingleParameterObject(object host) where T : Attribute { - return host.GetType() - .GetProperties(DefaultFlags) - .Single(x => Attribute.GetCustomAttribute(x, typeof(T)) is T) - .GetValue(host, null); + var targetType = typeof(T); + var attributedProperty = host.GetType() + .GetProperties(DefaultFlags) + .SingleOrDefault(x => Attribute.GetCustomAttribute(x, targetType) is T); + + if (attributedProperty == default) + { + throw new KeyNotFoundException($"No valid {targetType.Name} was attributed. There must be a single attributed property"); + } + + if (!attributedProperty.CanRead) + { + throw new MemberAccessException($"Unable to read contents of property {attributedProperty.Name}"); + } + + return attributedProperty.GetValue(host); } + + #region IEnumerable Converters + + private static IEnumerable> ApplyRecursiveConversion(IEnumerable values, string keyName, CultureInfo culture) + { + return values.Select(x => x.ToKeyValuePair(keyName, culture)); + } + + private static IEnumerable> ApplyUnorderedConversion(IEnumerable values, string keyName, CultureInfo culture) + { + return values.Select(x => x.ToKeyValuePair($"{keyName}[]", culture)); + } + + private static IEnumerable> ApplyOrderedConversion(IEnumerable values, string keyName, CultureInfo culture) + { + var counter = 0; + var enumerator = values.GetEnumerator(); + + while (enumerator.MoveNext()) + { + yield return enumerator.Current.ToKeyValuePair($"{keyName}[{counter}]", culture); + + counter++; + } + + enumerator.Dispose(); + } + + private static IEnumerable> ApplyConcatenation(IEnumerable values, string keyName, CultureInfo culture, string concatCharacter) + { + yield return new KeyValuePair(keyName, string.Join(concatCharacter, values.Select(x => x.AsString(culture)))); + } + + private static KeyValuePair ToKeyValuePair(this object value, string key, CultureInfo culture) + { + return new KeyValuePair(key, value.AsString(culture)); + } + + #endregion } }