From 8a01a1a3ea3161086e39d10e7c670e73a5173243 Mon Sep 17 00:00:00 2001 From: Marius Volkhart Date: Tue, 27 Nov 2018 22:20:38 +0700 Subject: [PATCH 1/4] Add support for url form encoding collections When form encoding objects that have a property that is an IEnumerable type, use the QueryAttribute and it's CollectionFormat to determine how to encode the object. In general, this feature could be fleshed out much more deeply. Future work could include respecting custom formats and delimiters specified on the QueryAttribute, as well as support for collections stored in dictionaries that are form encoded. --- Refit.Tests/FormValueDictionaryTests.cs | 43 +++++++++++++ Refit/FormValueDictionary.cs | 81 +++++++++++++++++++++++-- Refit/RefitSettings.cs | 2 +- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/Refit.Tests/FormValueDictionaryTests.cs b/Refit.Tests/FormValueDictionaryTests.cs index 411b0181e..954b02c25 100644 --- a/Refit.Tests/FormValueDictionaryTests.cs +++ b/Refit.Tests/FormValueDictionaryTests.cs @@ -49,6 +49,35 @@ public void LoadsFromObject() Assert.Equal(expected, actual); } + [Fact] + public void LoadFromObjectWithCollections() + { + var source = new ObjectWithRepeatedFieldsTestClass + { + A = new List { "list1", "list2" }, + B = new HashSet { "set1", "set2" }, + C = new HashSet { 1, 2 }, + D = new List { 0.1, 1.0 }, + E = new List { true, false } + }; + var expected = new List> { + new KeyValuePair("A", "list1"), + new KeyValuePair("A", "list2"), + new KeyValuePair("B", "set1,set2"), + new KeyValuePair("C", "1 2"), + + // The default behavior is to truncate perfectly round doubles. This is not a requirement. + new KeyValuePair("D", "0.1\t1"), + + // The default behavior is to capitalize booleans. This is not a requirement. + new KeyValuePair("E", "True|False") + }; + + var actual = new FormValueDictionary(source, settings); + + Assert.Equal(expected, actual); + } + public class ObjectTestClass { public string A { get; set; } @@ -56,6 +85,20 @@ public class ObjectTestClass public string C { get; set; } } + public class ObjectWithRepeatedFieldsTestClass + { + [Query(CollectionFormat.Multi)] + public IList A { get; set; } + [Query(CollectionFormat.Csv)] + public ISet B { get; set; } + [Query(CollectionFormat.Ssv)] + public HashSet C { get; set; } + [Query(CollectionFormat.Tsv)] + public IList D { get; set; } + [Query(CollectionFormat.Pipes)] + public IList E { get; set; } + } + [Fact] public void ExcludesPropertiesWithInaccessibleGetters() { diff --git a/Refit/FormValueDictionary.cs b/Refit/FormValueDictionary.cs index e22fc6eea..42b82fb2d 100644 --- a/Refit/FormValueDictionary.cs +++ b/Refit/FormValueDictionary.cs @@ -3,16 +3,17 @@ using System.Reflection; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using Newtonsoft.Json; namespace Refit { - class FormValueDictionary : Dictionary + class FormValueDictionary : IEnumerable> { static readonly Dictionary propertyCache = new Dictionary(); + private readonly IList> formEntries = new List>(); + public FormValueDictionary(object source, RefitSettings settings) { if (source == null) return; @@ -22,9 +23,8 @@ public FormValueDictionary(object source, RefitSettings settings) foreach (var key in dictionary.Keys) { var value = dictionary[key]; - if (value != null && key != null) - { - Add(key.ToString(), settings.FormUrlEncodedParameterFormatter.Format(value, null)); + if (value != null) { + Add(key.ToString(), settings.FormUrlEncodedParameterFormatter.Format(value, null)); } } @@ -45,15 +45,74 @@ public FormValueDictionary(object source, RefitSettings settings) var value = property.GetValue(source, null); if (value != null) { + var fieldName = GetFieldNameForProperty(property); + // see if there's a query attribute var attrib = property.GetCustomAttribute(true); - Add(GetFieldNameForProperty(property), settings.FormUrlEncodedParameterFormatter.Format(value, attrib?.Format)); + if (value is IEnumerable enumerable) { + switch (attrib?.CollectionFormat) { + case CollectionFormat.Multi: + foreach (var item in enumerable) { + Add(fieldName, settings.FormUrlEncodedParameterFormatter.Format(item, null)); + } + break; + case CollectionFormat.Csv: + case CollectionFormat.Ssv: + case CollectionFormat.Tsv: + case CollectionFormat.Pipes: + var delimiter = attrib.CollectionFormat == CollectionFormat.Csv ? "," + : attrib.CollectionFormat == CollectionFormat.Ssv ? " " + : attrib.CollectionFormat == CollectionFormat.Tsv ? "\t" : "|"; + + var formattedValues = enumerable + .Cast() + .Select(v => settings.FormUrlEncodedParameterFormatter.Format(v, null)); + Add(fieldName, string.Join(delimiter, formattedValues)); + break; + default: + Add(fieldName, settings.FormUrlEncodedParameterFormatter.Format(value, attrib?.Format)); + break; + } + } + else + { + Add(fieldName, settings.FormUrlEncodedParameterFormatter.Format(value, attrib?.Format)); + } + } } } } + /// + /// Returns a key for each entry. If multiple entries share the same key, the key is returned multiple times. + /// + public IEnumerable Keys => this.Select(it => it.Key); + + /// + /// Returns the value of the first entry found with the matching key. Multiple additional entries may use the + /// same key, but will not be considered. Returns null if no matching entry is found. + /// + public string this[string key] + { + get + { + foreach (var item in this) { + if (key == item.Key) { + return item.Value; + } + } + + return null; + } + } + + private void Add(string key, string value) + { + formEntries.Add(new KeyValuePair(key, value)); + } + string GetFieldNameForProperty(PropertyInfo propertyInfo) { var name = propertyInfo.GetCustomAttributes(true) @@ -77,5 +136,15 @@ PropertyInfo[] GetProperties(Type type) .Where(p => p.CanRead && p.GetMethod.IsPublic) .ToArray(); } + + public IEnumerator> GetEnumerator() + { + return formEntries.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } } } diff --git a/Refit/RefitSettings.cs b/Refit/RefitSettings.cs index 5c17fbe55..17d457395 100644 --- a/Refit/RefitSettings.cs +++ b/Refit/RefitSettings.cs @@ -78,7 +78,7 @@ public virtual string Format(object parameterValue, string formatString) var parameterType = parameterValue.GetType(); EnumMemberAttribute enummember = null; - if (parameterValue != null && parameterType.GetTypeInfo().IsEnum) + if (parameterType.GetTypeInfo().IsEnum) { var cached = enumMemberCache.GetOrAdd(parameterType, t => new ConcurrentDictionary()); enummember = cached.GetOrAdd(parameterValue.ToString(), val => parameterType.GetMember(val).First().GetCustomAttribute()); From dfdad56b01cd9fd8cdb314ded52ed5abfd4ad374 Mon Sep 17 00:00:00 2001 From: Marius Volkhart Date: Thu, 29 Nov 2018 12:30:34 +0700 Subject: [PATCH 2/4] !fixup Add support for url form encoding collections --- Refit.Tests/FormValueDictionaryTests.cs | 20 +++++++++----------- Refit/Attributes.cs | 18 +++++++++++++++++- Refit/FormValueDictionary.cs | 10 ++++++++-- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/Refit.Tests/FormValueDictionaryTests.cs b/Refit.Tests/FormValueDictionaryTests.cs index 954b02c25..5805e2662 100644 --- a/Refit.Tests/FormValueDictionaryTests.cs +++ b/Refit.Tests/FormValueDictionaryTests.cs @@ -54,20 +54,18 @@ public void LoadFromObjectWithCollections() { var source = new ObjectWithRepeatedFieldsTestClass { - A = new List { "list1", "list2" }, + A = new List { 1, 2 }, B = new HashSet { "set1", "set2" }, C = new HashSet { 1, 2 }, D = new List { 0.1, 1.0 }, E = new List { true, false } }; var expected = new List> { - new KeyValuePair("A", "list1"), - new KeyValuePair("A", "list2"), + new KeyValuePair("A", "01"), + new KeyValuePair("A", "02"), new KeyValuePair("B", "set1,set2"), - new KeyValuePair("C", "1 2"), - - // The default behavior is to truncate perfectly round doubles. This is not a requirement. - new KeyValuePair("D", "0.1\t1"), + new KeyValuePair("C", "01 02"), + new KeyValuePair("D", "0.10\t1.00"), // The default behavior is to capitalize booleans. This is not a requirement. new KeyValuePair("E", "True|False") @@ -87,13 +85,13 @@ public class ObjectTestClass public class ObjectWithRepeatedFieldsTestClass { - [Query(CollectionFormat.Multi)] - public IList A { get; set; } + [Query(CollectionFormat.Multi, Format = "00")] + public IList A { get; set; } [Query(CollectionFormat.Csv)] public ISet B { get; set; } - [Query(CollectionFormat.Ssv)] + [Query(CollectionFormat.Ssv, Format = "00")] public HashSet C { get; set; } - [Query(CollectionFormat.Tsv)] + [Query(CollectionFormat.Tsv, Format = "0.00")] public IList D { get; set; } [Query(CollectionFormat.Pipes)] public IList E { get; set; } diff --git a/Refit/Attributes.cs b/Refit/Attributes.cs index 542b2b0cc..0bfc68223 100644 --- a/Refit/Attributes.cs +++ b/Refit/Attributes.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -260,8 +260,24 @@ public QueryAttribute(CollectionFormat collectionFormat) public string Delimiter { get; protected set; } = "."; public string Prefix { get; protected set; } + /// + /// Used to customize the formatting of the encoded value. + /// + /// + /// + /// interface IServerApi + /// { + /// [Get("/expenses")] + /// Task addExpense([Query(Format="0.00")] double expense); + /// } + /// + /// Calling serverApi.addExpense(5) will result in a URI of {baseUri}/expenses?expense=5.00. + /// public string Format { get; set; } + /// + /// Specifies how the collection should be encoded. The default behavior is RefitParameterFormatter. + /// public CollectionFormat CollectionFormat { get; set; } = CollectionFormat.RefitParameterFormatter; } } diff --git a/Refit/FormValueDictionary.cs b/Refit/FormValueDictionary.cs index 42b82fb2d..119478afa 100644 --- a/Refit/FormValueDictionary.cs +++ b/Refit/FormValueDictionary.cs @@ -7,6 +7,12 @@ namespace Refit { + /// + /// Transforms a form source from a .NET representation to the appropriate HTTP form encoded representation. + /// + /// Performs field renaming and value formatting as specified in s and + /// . Note that this is not a true dictionary, rather, a + /// form of MultiMap. A given key may appear multiple times with the same or different values. class FormValueDictionary : IEnumerable> { static readonly Dictionary propertyCache @@ -54,7 +60,7 @@ public FormValueDictionary(object source, RefitSettings settings) switch (attrib?.CollectionFormat) { case CollectionFormat.Multi: foreach (var item in enumerable) { - Add(fieldName, settings.FormUrlEncodedParameterFormatter.Format(item, null)); + Add(fieldName, settings.FormUrlEncodedParameterFormatter.Format(item, attrib.Format)); } break; case CollectionFormat.Csv: @@ -67,7 +73,7 @@ public FormValueDictionary(object source, RefitSettings settings) var formattedValues = enumerable .Cast() - .Select(v => settings.FormUrlEncodedParameterFormatter.Format(v, null)); + .Select(v => settings.FormUrlEncodedParameterFormatter.Format(v, attrib.Format)); Add(fieldName, string.Join(delimiter, formattedValues)); break; default: From 303a3578bb11e1f5053360a367e029e6ab489306 Mon Sep 17 00:00:00 2001 From: Marius Volkhart Date: Thu, 29 Nov 2018 22:22:04 +0700 Subject: [PATCH 3/4] !fixup dfdad56b01cd9fd8cdb314ded52ed5abfd4ad374 - Braces on new lines - Rename FormValueDictionary -> FormValueMultimap --- ...naryTests.cs => FormValueMultimapTests.cs} | 26 +++++++++---------- ...alueDictionary.cs => FormValueMultimap.cs} | 17 +++++++----- Refit/RequestBuilderImplementation.cs | 2 +- 3 files changed, 24 insertions(+), 21 deletions(-) rename Refit.Tests/{FormValueDictionaryTests.cs => FormValueMultimapTests.cs} (88%) rename Refit/{FormValueDictionary.cs => FormValueMultimap.cs} (92%) diff --git a/Refit.Tests/FormValueDictionaryTests.cs b/Refit.Tests/FormValueMultimapTests.cs similarity index 88% rename from Refit.Tests/FormValueDictionaryTests.cs rename to Refit.Tests/FormValueMultimapTests.cs index 5805e2662..816ea902b 100644 --- a/Refit.Tests/FormValueDictionaryTests.cs +++ b/Refit.Tests/FormValueMultimapTests.cs @@ -5,14 +5,14 @@ namespace Refit.Tests { - public class FormValueDictionaryTests + public class FormValueMultimapTests { readonly RefitSettings settings = new RefitSettings(); [Fact] public void EmptyIfNullPassedIn() { - var target = new FormValueDictionary(null, settings); + var target = new FormValueMultimap(null, settings); Assert.Empty(target); } @@ -25,7 +25,7 @@ public void LoadsFromDictionary() { "xyz", "123" } }; - var target = new FormValueDictionary(source, settings); + var target = new FormValueMultimap(source, settings); Assert.Equal(source, target); } @@ -44,7 +44,7 @@ public void LoadsFromObject() { "B", "2" }, }; - var actual = new FormValueDictionary(source, settings); + var actual = new FormValueMultimap(source, settings); Assert.Equal(expected, actual); } @@ -71,7 +71,7 @@ public void LoadFromObjectWithCollections() new KeyValuePair("E", "True|False") }; - var actual = new FormValueDictionary(source, settings); + var actual = new FormValueMultimap(source, settings); Assert.Equal(expected, actual); } @@ -110,7 +110,7 @@ public void ExcludesPropertiesWithInaccessibleGetters() { "C", "FooBar" } }; - var actual = new FormValueDictionary(source, settings); + var actual = new FormValueMultimap(source, settings); Assert.Equal(expected, actual); } @@ -137,7 +137,7 @@ public void LoadsFromAnonymousType() { "xyz", "123" } }; - var actual = new FormValueDictionary(source, settings); + var actual = new FormValueMultimap(source, settings); Assert.Equal(expected, actual); @@ -151,7 +151,7 @@ public void UsesAliasAsAttribute() Foo = "abc" }; - var target = new FormValueDictionary(source, settings); + var target = new FormValueMultimap(source, settings); Assert.DoesNotContain("Foo", target.Keys); Assert.Contains("f", target.Keys); @@ -166,7 +166,7 @@ public void UsesJsonPropertyAttribute() Bar = "xyz" }; - var target = new FormValueDictionary(source, settings); + var target = new FormValueMultimap(source, settings); Assert.DoesNotContain("Bar", target.Keys); Assert.Contains("b", target.Keys); @@ -181,7 +181,7 @@ public void UsesQueryPropertyAttribute() Frob = 4 }; - var target = new FormValueDictionary(source, settings); + var target = new FormValueMultimap(source, settings); Assert.DoesNotContain("Bar", target.Keys); Assert.Contains("prefix-fr", target.Keys); @@ -197,7 +197,7 @@ public void GivesPrecedenceToAliasAs() Baz = "123" }; - var target = new FormValueDictionary(source, settings); + var target = new FormValueMultimap(source, settings); Assert.DoesNotContain("Bar", target.Keys); Assert.DoesNotContain("z", target.Keys); @@ -214,7 +214,7 @@ public void SkipsNullValuesFromDictionary() { "xyz", null } }; - var target = new FormValueDictionary(source, settings); + var target = new FormValueMultimap(source, settings); Assert.Single(target); Assert.Contains("foo", target.Keys); @@ -237,7 +237,7 @@ public void SerializesEnumWithEnumMemberAttribute() }; - var actual = new FormValueDictionary(source, settings); + var actual = new FormValueMultimap(source, settings); Assert.Equal(expected, actual); } diff --git a/Refit/FormValueDictionary.cs b/Refit/FormValueMultimap.cs similarity index 92% rename from Refit/FormValueDictionary.cs rename to Refit/FormValueMultimap.cs index 119478afa..f0ac4eba9 100644 --- a/Refit/FormValueDictionary.cs +++ b/Refit/FormValueMultimap.cs @@ -11,16 +11,16 @@ namespace Refit /// Transforms a form source from a .NET representation to the appropriate HTTP form encoded representation. /// /// Performs field renaming and value formatting as specified in s and - /// . Note that this is not a true dictionary, rather, a - /// form of MultiMap. A given key may appear multiple times with the same or different values. - class FormValueDictionary : IEnumerable> + /// . A given key may appear multiple times with the + /// same or different values. + class FormValueMultimap : IEnumerable> { static readonly Dictionary propertyCache = new Dictionary(); private readonly IList> formEntries = new List>(); - public FormValueDictionary(object source, RefitSettings settings) + public FormValueMultimap(object source, RefitSettings settings) { if (source == null) return; @@ -56,10 +56,13 @@ public FormValueDictionary(object source, RefitSettings settings) // see if there's a query attribute var attrib = property.GetCustomAttribute(true); - if (value is IEnumerable enumerable) { - switch (attrib?.CollectionFormat) { + if (value is IEnumerable enumerable) + { + switch (attrib?.CollectionFormat) + { case CollectionFormat.Multi: - foreach (var item in enumerable) { + foreach (var item in enumerable) + { Add(fieldName, settings.FormUrlEncodedParameterFormatter.Format(item, attrib.Format)); } break; diff --git a/Refit/RequestBuilderImplementation.cs b/Refit/RequestBuilderImplementation.cs index 6833e55ab..a8fa8666b 100644 --- a/Refit/RequestBuilderImplementation.cs +++ b/Refit/RequestBuilderImplementation.cs @@ -463,7 +463,7 @@ Func BuildRequestFactoryForMethod(RestMethodInfo r switch (restMethod.BodyParameterInfo.Item1) { case BodySerializationMethod.UrlEncoded: - ret.Content = paramList[i] is string str ? (HttpContent)new StringContent(Uri.EscapeDataString(str), Encoding.UTF8, "application/x-www-form-urlencoded") : new FormUrlEncodedContent(new FormValueDictionary(paramList[i], settings)); + ret.Content = paramList[i] is string str ? (HttpContent)new StringContent(Uri.EscapeDataString(str), Encoding.UTF8, "application/x-www-form-urlencoded") : new FormUrlEncodedContent(new FormValueMultimap(paramList[i], settings)); break; case BodySerializationMethod.Default: case BodySerializationMethod.Json: From c63b62c7164a70a32fa74c7dcfbd50fd4b23b534 Mon Sep 17 00:00:00 2001 From: Marius Volkhart Date: Thu, 29 Nov 2018 22:28:46 +0700 Subject: [PATCH 4/4] !fixup 303a3578bb11e1f5053360a367e029e6ab489306 - Remove FormValueMultimap indexer. This had a larger than necessary risk of being misused and introducing bugs. LINQ extensions can more expressively verify functionality in the tests. --- Refit.Tests/FormValueMultimapTests.cs | 9 +++++---- Refit/FormValueMultimap.cs | 18 ------------------ 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/Refit.Tests/FormValueMultimapTests.cs b/Refit.Tests/FormValueMultimapTests.cs index 816ea902b..70d0f6fa9 100644 --- a/Refit.Tests/FormValueMultimapTests.cs +++ b/Refit.Tests/FormValueMultimapTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; using Xunit; @@ -155,7 +156,7 @@ public void UsesAliasAsAttribute() Assert.DoesNotContain("Foo", target.Keys); Assert.Contains("f", target.Keys); - Assert.Equal("abc", target["f"]); + Assert.Equal("abc", target.FirstOrDefault(entry => entry.Key == "f").Value); } [Fact] @@ -170,7 +171,7 @@ public void UsesJsonPropertyAttribute() Assert.DoesNotContain("Bar", target.Keys); Assert.Contains("b", target.Keys); - Assert.Equal("xyz", target["b"]); + Assert.Equal("xyz", target.FirstOrDefault(entry => entry.Key == "b").Value); } [Fact] @@ -185,7 +186,7 @@ public void UsesQueryPropertyAttribute() Assert.DoesNotContain("Bar", target.Keys); Assert.Contains("prefix-fr", target.Keys); - Assert.Equal("4.0", target["prefix-fr"]); + Assert.Equal("4.0", target.FirstOrDefault(entry => entry.Key == "prefix-fr").Value); } @@ -202,7 +203,7 @@ public void GivesPrecedenceToAliasAs() Assert.DoesNotContain("Bar", target.Keys); Assert.DoesNotContain("z", target.Keys); Assert.Contains("a", target.Keys); - Assert.Equal("123", target["a"]); + Assert.Equal("123", target.FirstOrDefault(entry => entry.Key == "a").Value); } diff --git a/Refit/FormValueMultimap.cs b/Refit/FormValueMultimap.cs index f0ac4eba9..66fd3ef8b 100644 --- a/Refit/FormValueMultimap.cs +++ b/Refit/FormValueMultimap.cs @@ -99,24 +99,6 @@ public FormValueMultimap(object source, RefitSettings settings) /// public IEnumerable Keys => this.Select(it => it.Key); - /// - /// Returns the value of the first entry found with the matching key. Multiple additional entries may use the - /// same key, but will not be considered. Returns null if no matching entry is found. - /// - public string this[string key] - { - get - { - foreach (var item in this) { - if (key == item.Key) { - return item.Value; - } - } - - return null; - } - } - private void Add(string key, string value) { formEntries.Add(new KeyValuePair(key, value));