From 7cfdeded6363adf5c7eb0d3902db528e92a17f17 Mon Sep 17 00:00:00 2001 From: Pavel Kravtsov <45625714+MikeAmputer@users.noreply.github.com> Date: Sat, 17 Aug 2024 20:14:34 +0300 Subject: [PATCH] Extend url parameters default formatting (#1781) * Refactor DefaultUrlParameterFormatter * Extend URL parameters formatting through DefaultUrlParameterFormatter * Add DefaultUrlParameterFormatterTests * Rename DefaultUrlParameterFormatterTests test methods * Union DefaultUrlParameterFormatterTestRequest * Update API * Add DefaultUrlParameterFormatter tests with URI building --------- Co-authored-by: Chris Pulman --- ...ApprovalTests.Refit.DotNet6_0.verified.txt | 2 + ...ApprovalTests.Refit.DotNet8_0.verified.txt | 2 + .../DefaultUrlParameterFormatterTest.cs | 303 ++++++++++++++++++ Refit/RefitSettings.cs | 104 ++++-- 4 files changed, 380 insertions(+), 31 deletions(-) create mode 100644 Refit.Tests/DefaultUrlParameterFormatterTest.cs diff --git a/Refit.Tests/API/ApiApprovalTests.Refit.DotNet6_0.verified.txt b/Refit.Tests/API/ApiApprovalTests.Refit.DotNet6_0.verified.txt index f5e519fed..faa5be175 100644 --- a/Refit.Tests/API/ApiApprovalTests.Refit.DotNet6_0.verified.txt +++ b/Refit.Tests/API/ApiApprovalTests.Refit.DotNet6_0.verified.txt @@ -119,6 +119,8 @@ namespace Refit public class DefaultUrlParameterFormatter : Refit.IUrlParameterFormatter { public DefaultUrlParameterFormatter() { } + public void AddFormat(string format) { } + public void AddFormat(string format) { } public virtual string? Format(object? parameterValue, System.Reflection.ICustomAttributeProvider attributeProvider, System.Type type) { } } public class DefaultUrlParameterKeyFormatter : Refit.IUrlParameterKeyFormatter diff --git a/Refit.Tests/API/ApiApprovalTests.Refit.DotNet8_0.verified.txt b/Refit.Tests/API/ApiApprovalTests.Refit.DotNet8_0.verified.txt index cbe45beb8..bf8a866b8 100644 --- a/Refit.Tests/API/ApiApprovalTests.Refit.DotNet8_0.verified.txt +++ b/Refit.Tests/API/ApiApprovalTests.Refit.DotNet8_0.verified.txt @@ -119,6 +119,8 @@ namespace Refit public class DefaultUrlParameterFormatter : Refit.IUrlParameterFormatter { public DefaultUrlParameterFormatter() { } + public void AddFormat(string format) { } + public void AddFormat(string format) { } public virtual string? Format(object? parameterValue, System.Reflection.ICustomAttributeProvider attributeProvider, System.Type type) { } } public class DefaultUrlParameterKeyFormatter : Refit.IUrlParameterKeyFormatter diff --git a/Refit.Tests/DefaultUrlParameterFormatterTest.cs b/Refit.Tests/DefaultUrlParameterFormatterTest.cs new file mode 100644 index 000000000..f799aa24c --- /dev/null +++ b/Refit.Tests/DefaultUrlParameterFormatterTest.cs @@ -0,0 +1,303 @@ +using System.Globalization; +using System.Reflection; +using Xunit; + +namespace Refit.Tests; + +public class DefaultUrlParameterFormatterTests +{ + class DefaultUrlParameterFormatterTestRequest + { + [Query(Format = "yyyy")] public DateTime? DateTimeWithAttributeFormatYear { get; set; } + + public DateTime? DateTime { get; set; } + + public IEnumerable DateTimeCollection { get; set; } + + public IDictionary DateTimeDictionary { get; set; } + + public IDictionary DateTimeKeyedDictionary { get; set; } + } + + [Fact] + public void NullParameterValue_ReturnsNull() + { + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTime = null + }; + + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + + var output = urlParameterFormatter.Format( + parameters.DateTime, + parameters.GetType().GetProperty(nameof(parameters.DateTime))!, + parameters.GetType()); + + Assert.Null(output); + } + + [Fact] + public void NoFormatters_UseDefaultFormat() + { + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTime = new DateTime(2023, 8, 21) + }; + + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + + var output = urlParameterFormatter.Format( + parameters.DateTime, + parameters.GetType().GetProperty(nameof(parameters.DateTime))!, + parameters.GetType()); + + Assert.Equal("08/21/2023 00:00:00", output); + } + + [Fact] + public void QueryAttributeFormatOnly_UseQueryAttributeFormat() + { + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTimeWithAttributeFormatYear = new DateTime(2023, 8, 21) + }; + + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + + var output = urlParameterFormatter.Format( + parameters.DateTimeWithAttributeFormatYear, + parameters.GetType().GetProperty(nameof(parameters.DateTimeWithAttributeFormatYear))!, + parameters.GetType()); + + Assert.Equal("2023", output); + } + + [Fact] + public void QueryAttributeAndGeneralFormat_UseQueryAttributeFormat() + { + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTimeWithAttributeFormatYear = new DateTime(2023, 8, 21) + }; + + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy-MM-dd"); + + var output = urlParameterFormatter.Format( + parameters.DateTimeWithAttributeFormatYear, + parameters.GetType().GetProperty(nameof(parameters.DateTimeWithAttributeFormatYear))!, + parameters.GetType()); + + Assert.Equal("2023", output); + } + + [Fact] + public void QueryAttributeAndSpecificFormat_UseQueryAttributeFormat() + { + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTimeWithAttributeFormatYear = new DateTime(2023, 8, 21) + }; + + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy-MM-dd"); + + var output = urlParameterFormatter.Format( + parameters.DateTimeWithAttributeFormatYear, + parameters.GetType().GetProperty(nameof(parameters.DateTimeWithAttributeFormatYear))!, + parameters.GetType()); + + Assert.Equal("2023", output); + } + + [Fact] + public void AllFormats_UseQueryAttributeFormat() + { + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTimeWithAttributeFormatYear = new DateTime(2023, 8, 21) + }; + + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy-MM-dd"); + urlParameterFormatter.AddFormat("yyyy-MM-dd"); + + var output = urlParameterFormatter.Format( + parameters.DateTimeWithAttributeFormatYear, + parameters.GetType().GetProperty(nameof(parameters.DateTimeWithAttributeFormatYear))!, + parameters.GetType()); + + Assert.Equal("2023", output); + } + + [Fact] + public void GeneralFormatOnly_UseGeneralFormat() + { + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTime = new DateTime(2023, 8, 21) + }; + + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy"); + + var output = urlParameterFormatter.Format( + parameters.DateTime, + parameters.GetType().GetProperty(nameof(parameters.DateTime))!, + parameters.GetType()); + + Assert.Equal("2023", output); + } + + [Fact] + public void SpecificFormatOnly_UseSpecificFormat() + { + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTime = new DateTime(2023, 8, 21) + }; + + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy"); + + var output = urlParameterFormatter.Format( + parameters.DateTime, + parameters.GetType().GetProperty(nameof(parameters.DateTime))!, + parameters.GetType()); + + Assert.Equal("2023", output); + } + + [Fact] + public void GeneralAndSpecificFormats_UseSpecificFormat() + { + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTime = new DateTime(2023, 8, 21) + }; + + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy-MM-dd"); + urlParameterFormatter.AddFormat("yyyy"); + + var output = urlParameterFormatter.Format( + parameters.DateTime, + parameters.GetType().GetProperty(nameof(parameters.DateTime))!, + parameters.GetType()); + + Assert.Equal("2023", output); + } + + [Fact] + public void RequestWithPlainDateTimeQueryParameter_ProducesCorrectQueryString() + { + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy"); + + var refitSettings = new RefitSettings { UrlParameterFormatter = urlParameterFormatter }; + var fixture = new RequestBuilderImplementation(refitSettings); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery) + ); + + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTime = new DateTime(2023, 8, 21), + }; + + var output = factory([parameters]); + var uri = new Uri(new Uri("http://api"), output.RequestUri); + + Assert.Equal( + "?DateTime=2023", + uri.Query + ); + } + + [Fact] + public void RequestWithDateTimeCollectionQueryParameter_ProducesCorrectQueryString() + { + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy"); + + var refitSettings = new RefitSettings { UrlParameterFormatter = urlParameterFormatter }; + var fixture = new RequestBuilderImplementation(refitSettings); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery) + ); + + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTimeCollection = [new DateTime(2023, 8, 21), new DateTime(2024, 8, 21)], + }; + + var output = factory([parameters]); + var uri = new Uri(new Uri("http://api"), output.RequestUri); + + Assert.Equal( + "?DateTimeCollection=2023%2C2024", + uri.Query + ); + } + + [Fact] + public void RequestWithDateTimeDictionaryQueryParameter_ProducesCorrectQueryString() + { + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy"); + + var refitSettings = new RefitSettings { UrlParameterFormatter = urlParameterFormatter }; + var fixture = new RequestBuilderImplementation(refitSettings); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery) + ); + + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTimeDictionary = new Dictionary + { + { 1, new DateTime(2023, 8, 21) }, + { 2, new DateTime(2024, 8, 21) }, + }, + }; + + var output = factory([parameters]); + var uri = new Uri(new Uri("http://api"), output.RequestUri); + + Assert.Equal( + "?DateTimeDictionary.1=2023&DateTimeDictionary.2=2024", + uri.Query + ); + } + + [Fact] + public void RequestWithDateTimeKeyedDictionaryQueryParameter_ProducesCorrectQueryString() + { + var urlParameterFormatter = new DefaultUrlParameterFormatter(); + urlParameterFormatter.AddFormat("yyyy"); + + var refitSettings = new RefitSettings { UrlParameterFormatter = urlParameterFormatter }; + var fixture = new RequestBuilderImplementation(refitSettings); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery) + ); + + var parameters = new DefaultUrlParameterFormatterTestRequest + { + DateTimeKeyedDictionary = new Dictionary + { + { new DateTime(2023, 8, 21), 1 }, + { new DateTime(2024, 8, 21), 2 }, + }, + }; + + var output = factory([parameters]); + var uri = new Uri(new Uri("http://api"), output.RequestUri); + + Assert.Equal( + "?DateTimeKeyedDictionary.2023=1&DateTimeKeyedDictionary.2024=2", + uri.Query + ); + } +} diff --git a/Refit/RefitSettings.cs b/Refit/RefitSettings.cs index 1180ff177..e7da59125 100644 --- a/Refit/RefitSettings.cs +++ b/Refit/RefitSettings.cs @@ -40,7 +40,8 @@ public RefitSettings( IFormUrlEncodedParameterFormatter? formUrlEncodedParameterFormatter ) : this(contentSerializer, urlParameterFormatter, formUrlEncodedParameterFormatter, null) - { } + { + } /// /// Creates a new instance with the specified parameters @@ -184,7 +185,7 @@ public interface IUrlParameterFormatter /// /// The value. /// The attribute provider. - /// The type. + /// Container class type. /// string? Format(object? value, ICustomAttributeProvider attributeProvider, Type type); } @@ -226,12 +227,39 @@ static readonly ConcurrentDictionary< ConcurrentDictionary > EnumMemberCache = new(); + Dictionary<(Type containerType, Type parameterType), string> SpecificFormats { get; } = new(); + + Dictionary GeneralFormats { get; } = new(); + + /// + /// Add format for specified parameter type contained within container class of specified type. + /// Might be suppressed by a QueryAttribute format. + /// + /// The format string. + /// Container class type. + /// Parameter type. + public void AddFormat(string format) + { + SpecificFormats.Add((typeof(TContainer), typeof(TParameter)), format); + } + + /// + /// Add format for specified parameter type. + /// Might be suppressed by a QueryAttribute format or a container specific format. + /// + /// The format string. + /// Parameter type. + public void AddFormat(string format) + { + GeneralFormats.Add(typeof(TParameter), format); + } + /// /// Formats the specified parameter value. /// /// The parameter value. /// The attribute provider. - /// The type. + /// Container class type. /// /// attributeProvider public virtual string? Format( @@ -245,6 +273,11 @@ Type type throw new ArgumentNullException(nameof(attributeProvider)); } + if (parameterValue == null) + { + return null; + } + // See if we have a format var formatString = attributeProvider .GetCustomAttributes(typeof(QueryAttribute), true) @@ -252,34 +285,41 @@ Type type .FirstOrDefault() ?.Format; - EnumMemberAttribute? enummember = null; - if (parameterValue != null) + EnumMemberAttribute? enumMember = null; + var parameterType = parameterValue.GetType(); + if (parameterType.IsEnum) + { + var cached = EnumMemberCache.GetOrAdd( + parameterType, + t => new ConcurrentDictionary() + ); + enumMember = cached.GetOrAdd( + parameterValue.ToString()!, + val => + parameterType + .GetMember(val) + .First() + .GetCustomAttribute() + ); + } + + if (string.IsNullOrWhiteSpace(formatString) && + SpecificFormats.TryGetValue((type, parameterType), out var specificFormat)) { - var parameterType = parameterValue.GetType(); - if (parameterType.IsEnum) - { - var cached = EnumMemberCache.GetOrAdd( - parameterType, - t => new ConcurrentDictionary() - ); - enummember = cached.GetOrAdd( - parameterValue.ToString()!, - val => - parameterType - .GetMember(val) - .First() - .GetCustomAttribute() - ); - } + formatString = specificFormat; } - return parameterValue == null - ? null - : string.Format( - CultureInfo.InvariantCulture, - string.IsNullOrWhiteSpace(formatString) ? "{0}" : $"{{0:{formatString}}}", - enummember?.Value ?? parameterValue - ); + if (string.IsNullOrWhiteSpace(formatString) && + GeneralFormats.TryGetValue(parameterType, out var generalFormat)) + { + formatString = generalFormat; + } + + return string.Format( + CultureInfo.InvariantCulture, + string.IsNullOrWhiteSpace(formatString) ? "{0}" : $"{{0:{formatString}}}", + enumMember?.Value ?? parameterValue + ); } } @@ -302,18 +342,20 @@ static readonly ConcurrentDictionary< public virtual string? Format(object? parameterValue, string? formatString) { if (parameterValue == null) + { return null; + } var parameterType = parameterValue.GetType(); - EnumMemberAttribute? enummember = null; + EnumMemberAttribute? enumMember = null; if (parameterType.GetTypeInfo().IsEnum) { var cached = EnumMemberCache.GetOrAdd( parameterType, t => new ConcurrentDictionary() ); - enummember = cached.GetOrAdd( + enumMember = cached.GetOrAdd( parameterValue.ToString()!, val => parameterType @@ -326,7 +368,7 @@ static readonly ConcurrentDictionary< return string.Format( CultureInfo.InvariantCulture, string.IsNullOrWhiteSpace(formatString) ? "{0}" : $"{{0:{formatString}}}", - enummember?.Value ?? parameterValue + enumMember?.Value ?? parameterValue ); } }