From 94bf0a5f521e0ae8dc57e11cd2eb475ff9e003d6 Mon Sep 17 00:00:00 2001 From: Timothy Makkison Date: Sat, 6 Jul 2024 15:09:22 +0100 Subject: [PATCH] feat: create parameter map on first call. --- ...ApprovalTests.Refit.DotNet6_0.verified.txt | 5 + ...ApprovalTests.Refit.DotNet8_0.verified.txt | 5 + Refit/RequestBuilderImplementation.cs | 159 +++++++------ Refit/RestMethodInfo.cs | 218 ++++++++++-------- Refit/UnreachableException.cs | 21 ++ 5 files changed, 248 insertions(+), 160 deletions(-) create mode 100644 Refit/UnreachableException.cs diff --git a/Refit.Tests/API/ApiApprovalTests.Refit.DotNet6_0.verified.txt b/Refit.Tests/API/ApiApprovalTests.Refit.DotNet6_0.verified.txt index 15d17e534..d093c0e78 100644 --- a/Refit.Tests/API/ApiApprovalTests.Refit.DotNet6_0.verified.txt +++ b/Refit.Tests/API/ApiApprovalTests.Refit.DotNet6_0.verified.txt @@ -393,6 +393,11 @@ namespace Refit public System.Net.Http.HttpContent ToHttpContent(T item) { } public static System.Text.Json.JsonSerializerOptions GetDefaultJsonSerializerOptions() { } } + public class UnreachableException : System.Exception + { + public UnreachableException() { } + public UnreachableException(string message) { } + } [System.Serializable] public class ValidationApiException : Refit.ApiException { diff --git a/Refit.Tests/API/ApiApprovalTests.Refit.DotNet8_0.verified.txt b/Refit.Tests/API/ApiApprovalTests.Refit.DotNet8_0.verified.txt index 8c9b3cce6..891e220a3 100644 --- a/Refit.Tests/API/ApiApprovalTests.Refit.DotNet8_0.verified.txt +++ b/Refit.Tests/API/ApiApprovalTests.Refit.DotNet8_0.verified.txt @@ -393,6 +393,11 @@ namespace Refit public System.Net.Http.HttpContent ToHttpContent(T item) { } public static System.Text.Json.JsonSerializerOptions GetDefaultJsonSerializerOptions() { } } + public class UnreachableException : System.Exception + { + public UnreachableException() { } + public UnreachableException(string message) { } + } [System.Serializable] public class ValidationApiException : Refit.ApiException { diff --git a/Refit/RequestBuilderImplementation.cs b/Refit/RequestBuilderImplementation.cs index 10df8d5ac..bc19a7f9f 100644 --- a/Refit/RequestBuilderImplementation.cs +++ b/Refit/RequestBuilderImplementation.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Collections.Concurrent; +using System.Diagnostics; using System.Net.Http; using System.Reflection; using System.Text; @@ -630,8 +631,6 @@ bool paramsContainsCancellationToken ret.Content = multiPartContent; } - var urlTarget = - (basePath == "/" ? string.Empty : basePath) + restMethod.RelativePath; var queryParamsToAdd = new List>(); var headersToAdd = restMethod.Headers.Count > 0 ? new Dictionary(restMethod.Headers) @@ -647,69 +646,10 @@ bool paramsContainsCancellationToken if (restMethod.ParameterMap.TryGetValue(i, out var parameterMapValue)) { parameterInfo = parameterMapValue; - if (parameterInfo.IsObjectPropertyParameter) + if (!parameterInfo.IsObjectPropertyParameter) { - foreach (var propertyInfo in parameterInfo.ParameterProperties) - { - var propertyObject = propertyInfo.PropertyInfo.GetValue(param); - urlTarget = Regex.Replace( - urlTarget, - "{" + propertyInfo.Name + "}", - Uri.EscapeDataString( - settings.UrlParameterFormatter.Format( - propertyObject, - propertyInfo.PropertyInfo, - propertyInfo.PropertyInfo.PropertyType - ) ?? string.Empty - ), - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant - ); - } - //don't continue here as we want it to fall through so any parameters on this object not bound here get passed as query parameters - } - else - { - string pattern; - string replacement; - if (parameterMapValue.Type == ParameterType.RoundTripping) - { - pattern = $@"{{\*\*{parameterMapValue.Name}}}"; - var paramValue = (string)param; - replacement = string.Join( - "/", - paramValue - .Split('/') - .Select( - s => - Uri.EscapeDataString( - settings.UrlParameterFormatter.Format( - s, - restMethod.ParameterInfoArray[i], - restMethod.ParameterInfoArray[i].ParameterType - ) ?? string.Empty - ) - ) - ); - } - else - { - pattern = "{" + parameterMapValue.Name + "}"; - replacement = Uri.EscapeDataString( - settings.UrlParameterFormatter.Format( - param, - restMethod.ParameterInfoArray[i], - restMethod.ParameterInfoArray[i].ParameterType - ) ?? string.Empty - ); - } - - urlTarget = Regex.Replace( - urlTarget, - pattern, - replacement, - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant - ); - + // mark parameter mapped if not an object + // we want objects to fall through so any parameters on this object not bound here get passed as query parameters isParameterMappedToRequest = true; } } @@ -721,7 +661,6 @@ bool paramsContainsCancellationToken ) { AddBodyToRequest(restMethod, param, ret); - isParameterMappedToRequest = true; } @@ -795,6 +734,8 @@ bool paramsContainsCancellationToken AddPropertiesToRequest(restMethod, ret, paramList); + var urlTarget = BuildRelativePath(basePath, restMethod, paramList); + // NB: The URI methods in .NET are dumb. Also, we do this // UriBuilder business so that we preserve any hardcoded query // parameters as well as add the parameterized ones. @@ -818,6 +759,94 @@ bool paramsContainsCancellationToken }; } + string BuildRelativePath(string basePath, RestMethodInfoInternal restMethod, object[] paramList) + { + basePath = basePath == "/" ? string.Empty : basePath; + var pathFragments = restMethod.FragmentPath; + if (pathFragments.Count == 0) + { + return basePath; + } + if (string.IsNullOrEmpty(basePath) && pathFragments.Count == 1) + { + return GetPathFragmentValue(restMethod, paramList, pathFragments[0]); + } + +#pragma warning disable CA2000 + var vsb = new ValueStringBuilder(stackalloc char[512]); +#pragma warning restore CA2000 + vsb.Append(basePath); + + foreach (var fragment in pathFragments) + { + vsb.Append(GetPathFragmentValue(restMethod, paramList, fragment)); + } + + return vsb.ToString(); + } + + string GetPathFragmentValue(RestMethodInfoInternal restMethod, object[] paramList, + ParameterFragment fragment) + { + if (fragment.IsConstant) + { + return fragment.Value!; + } + + var contains = restMethod.ParameterMap.TryGetValue(fragment.ArgumentIndex, out var parameterMapValue); + if (!contains || parameterMapValue is null) + throw new UnreachableException($"{restMethod.ParameterMap} should contain parameter."); + + if (fragment.IsObjectProperty) + { + var param = paramList[fragment.ArgumentIndex]; + var property = parameterMapValue.ParameterProperties[fragment.PropertyIndex]; + var propertyObject = property.PropertyInfo.GetValue(param); + + return Uri.EscapeDataString(settings.UrlParameterFormatter.Format( + propertyObject, + property.PropertyInfo, + property.PropertyInfo.PropertyType + ) ?? string.Empty); + } + + if (fragment.IsDynamicRoute) + { + var param = paramList[fragment.ArgumentIndex]; + + if (parameterMapValue.Type != ParameterType.RoundTripping) + { + return Uri.EscapeDataString( + settings.UrlParameterFormatter.Format( + param, + restMethod.ParameterInfoArray[fragment.ArgumentIndex], + restMethod.ParameterInfoArray[fragment.ArgumentIndex].ParameterType + ) ?? string.Empty + ); + } + + var paramValue = (string)param; + return string.Join( + "/", + paramValue + .Split('/') + .Select( + s => + Uri.EscapeDataString( + settings.UrlParameterFormatter.Format( + s, + restMethod.ParameterInfoArray[fragment.ArgumentIndex], + restMethod.ParameterInfoArray[fragment.ArgumentIndex].ParameterType + ) ?? string.Empty + ) + ) + ); + + } + + throw new UnreachableException($"{nameof(ParameterFragment)} is in an invalid form."); + } + void AddBodyToRequest(RestMethodInfoInternal restMethod, object param, HttpRequestMessage ret) { if (param is HttpContent httpContentParam) diff --git a/Refit/RestMethodInfo.cs b/Refit/RestMethodInfo.cs index a22dd5262..4a6848e6c 100644 --- a/Refit/RestMethodInfo.cs +++ b/Refit/RestMethodInfo.cs @@ -46,6 +46,7 @@ internal class RestMethodInfoInternal public Dictionary> AttachmentNameMap { get; set; } public ParameterInfo[] ParameterInfoArray { get; set; } public Dictionary ParameterMap { get; set; } + public List FragmentPath { get ; set ; } public Type ReturnType { get; set; } public Type ReturnResultType { get; set; } public Type DeserializedResultType { get; set; } @@ -53,7 +54,7 @@ internal class RestMethodInfoInternal public bool IsApiResponse { get; } public bool ShouldDisposeResponse { get; private set; } - static readonly Regex ParameterRegex = new(@"{(.*?)}"); + static readonly Regex ParameterRegex = new(@"{(([^/?\r\n])*?)}"); static readonly HttpMethod PatchMethod = new("PATCH"); #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. @@ -90,7 +91,7 @@ public RestMethodInfoInternal( .GetParameters() .Where(static p => p.ParameterType != typeof(CancellationToken)) .ToArray(); - ParameterMap = BuildParameterMap(RelativePath, ParameterInfoArray); + (ParameterMap, FragmentPath) = BuildParameterMap(RelativePath, ParameterInfoArray); BodyParameterInfo = FindBodyParameter(ParameterInfoArray, IsMultipart, hma.Method); AuthorizeParameterInfo = FindAuthorizationParameter(ParameterInfoArray); @@ -261,7 +262,7 @@ static void VerifyUrlPathIsSane(string relativePath) ); } - static Dictionary BuildParameterMap( + static (Dictionary ret, List fragmentList) BuildParameterMap( string relativePath, ParameterInfo[] parameterInfo ) @@ -269,123 +270,139 @@ ParameterInfo[] parameterInfo var ret = new Dictionary(); // This section handles pattern matching in the URL. We also need it to add parameter key/values for any attribute with a [Query] - var parameterizedParts = relativePath - .Split('/', '?') - .SelectMany(x => ParameterRegex.Matches(x).Cast()) - .ToList(); + var parameterizedParts = ParameterRegex.Matches(relativePath).Cast().ToArray(); - if (parameterizedParts.Count > 0) + if (parameterizedParts.Length == 0) { - var paramValidationDict = parameterInfo.ToDictionary( - k => GetUrlNameForParameter(k).ToLowerInvariant(), - v => v - ); - //if the param is an lets make a dictionary for all it's potential parameters - var objectParamValidationDict = parameterInfo - .Where(x => x.ParameterType.GetTypeInfo().IsClass) - .SelectMany(x => GetParameterProperties(x).Select(p => Tuple.Create(x, p))) - .GroupBy( - i => $"{i.Item1.Name}.{GetUrlNameForProperty(i.Item2)}".ToLowerInvariant() - ) - .ToDictionary(k => k.Key, v => v.First()); - foreach (var match in parameterizedParts) + if(string.IsNullOrEmpty(relativePath)) + return (ret, new List()); + + return (ret, new List(){ParameterFragment.Constant(relativePath)}); + } + + var paramValidationDict = parameterInfo.ToDictionary( + k => GetUrlNameForParameter(k).ToLowerInvariant(), + v => v + ); + //if the param is an lets make a dictionary for all it's potential parameters + var objectParamValidationDict = parameterInfo + .Where(x => x.ParameterType.GetTypeInfo().IsClass) + .SelectMany(x => GetParameterProperties(x).Select(p => Tuple.Create(x, p))) + .GroupBy( + i => $"{i.Item1.Name}.{GetUrlNameForProperty(i.Item2)}".ToLowerInvariant() + ) + .ToDictionary(k => k.Key, v => v.First()); + + var fragmentList = new List(); + var index = 0; + foreach (var match in parameterizedParts) + { + // Add constant value from given http path + if (match.Index != index) { - var rawName = match.Groups[1].Value.ToLowerInvariant(); - var isRoundTripping = rawName.StartsWith("**"); - string name; - if (isRoundTripping) + fragmentList.Add(ParameterFragment.Constant(relativePath.Substring(index, match.Index - index))); + } + index = match.Index + match.Length; + + var rawName = match.Groups[1].Value.ToLowerInvariant(); + var isRoundTripping = rawName.StartsWith("**"); + var name = isRoundTripping ? rawName.Substring(2) : rawName; + + if (paramValidationDict.TryGetValue(name, out var value)) //if it's a standard parameter + { + var paramType = value.ParameterType; + if (isRoundTripping && paramType != typeof(string)) { - name = rawName.Substring(2); + throw new ArgumentException( + $"URL {relativePath} has round-tripping parameter {rawName}, but the type of matched method parameter is {paramType.FullName}. It must be a string." + ); } - else + var parameterType = isRoundTripping + ? ParameterType.RoundTripping + : ParameterType.Normal; + var restMethodParameterInfo = new RestMethodParameterInfo(name, value) { - name = rawName; - } + Type = parameterType + }; - if (paramValidationDict.TryGetValue(name, out var value)) //if it's a standard parameter + var parameterIndex = Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo); + fragmentList.Add(ParameterFragment.Dynamic(parameterIndex)); +#if NET6_0_OR_GREATER + ret.TryAdd( + parameterIndex, + restMethodParameterInfo + ); +#else + if (!ret.ContainsKey(parameterIndex)) + { + ret.Add(parameterIndex, restMethodParameterInfo); + } +#endif + } + //else if it's a property on a object parameter + else if ( + objectParamValidationDict.TryGetValue(name, out var value1) + && !isRoundTripping + ) + { + var property = value1; + var parameterIndex = Array.IndexOf(parameterInfo, property.Item1); + //If we already have this parameter, add additional ParameterProperty + if (ret.TryGetValue(parameterIndex, out var value2)) { - var paramType = value.ParameterType; - if (isRoundTripping && paramType != typeof(string)) + if (!value2.IsObjectPropertyParameter) { throw new ArgumentException( - $"URL {relativePath} has round-tripping parameter {rawName}, but the type of matched method parameter is {paramType.FullName}. It must be a string." + $"Parameter {property.Item1.Name} matches both a parameter and nested parameter on a parameter object" ); } - var parameterType = isRoundTripping - ? ParameterType.RoundTripping - : ParameterType.Normal; - var restMethodParameterInfo = new RestMethodParameterInfo(name, value) - { - Type = parameterType - }; + + value2.ParameterProperties.Add( + new RestMethodParameterProperty(name, property.Item2) + ); + fragmentList.Add(ParameterFragment.DynamicObject(parameterIndex, value2.ParameterProperties.Count - 1)); + } + else + { + var restMethodParameterInfo = new RestMethodParameterInfo( + true, + property.Item1 + ); + restMethodParameterInfo.ParameterProperties.Add( + new RestMethodParameterProperty(name, property.Item2) + ); + + var idx = Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo); + fragmentList.Add(ParameterFragment.DynamicObject(idx, 0)); #if NET6_0_OR_GREATER ret.TryAdd( - Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo), + idx, restMethodParameterInfo ); #else - var idx = Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo); + // Do the contains check if (!ret.ContainsKey(idx)) { ret.Add(idx, restMethodParameterInfo); } #endif } - //else if it's a property on a object parameter - else if ( - objectParamValidationDict.TryGetValue(name, out var value1) - && !isRoundTripping - ) - { - var property = value1; - var parameterIndex = Array.IndexOf(parameterInfo, property.Item1); - //If we already have this parameter, add additional ParameterProperty - if (ret.TryGetValue(parameterIndex, out var value2)) - { - if (!value2.IsObjectPropertyParameter) - { - throw new ArgumentException( - $"Parameter {property.Item1.Name} matches both a parameter and nested parameter on a parameter object" - ); - } - - value2.ParameterProperties.Add( - new RestMethodParameterProperty(name, property.Item2) - ); - } - else - { - var restMethodParameterInfo = new RestMethodParameterInfo( - true, - property.Item1 - ); - restMethodParameterInfo.ParameterProperties.Add( - new RestMethodParameterProperty(name, property.Item2) - ); -#if NET6_0_OR_GREATER - ret.TryAdd( - Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo), - restMethodParameterInfo - ); -#else - // Do the contains check - var idx = Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo); - if (!ret.ContainsKey(idx)) - { - ret.Add(idx, restMethodParameterInfo); - } -#endif - } - } - else - { - throw new ArgumentException( - $"URL {relativePath} has parameter {rawName}, but no method parameter matches" - ); - } } + else + { + throw new ArgumentException( + $"URL {relativePath} has parameter {rawName}, but no method parameter matches" + ); + } + } + + // add trailing string + if (index < relativePath.Length - 1) + { + var s = relativePath.Substring(index, relativePath.Length - index); + fragmentList.Add(ParameterFragment.Constant(s)); } - return ret; + return (ret, fragmentList); } static string GetUrlNameForParameter(ParameterInfo paramInfo) @@ -664,4 +681,15 @@ void DetermineIfResponseMustBeDisposed() && DeserializedResultType != typeof(Stream); } } + + internal record struct ParameterFragment(string? Value, int ArgumentIndex, int PropertyIndex) + { + public bool IsConstant => Value != null; + public bool IsDynamicRoute => ArgumentIndex >= 0 && PropertyIndex < 0; + public bool IsObjectProperty => ArgumentIndex >= 0 && PropertyIndex >= 0; + + public static ParameterFragment Constant(string value) => new (value, -1, -1); + public static ParameterFragment Dynamic(int index) => new (null, index, -1); + public static ParameterFragment DynamicObject(int index, int propertyIndex) => new (null, index, propertyIndex); + } } diff --git a/Refit/UnreachableException.cs b/Refit/UnreachableException.cs new file mode 100644 index 000000000..221d33b0a --- /dev/null +++ b/Refit/UnreachableException.cs @@ -0,0 +1,21 @@ +namespace Refit; + +/// +/// The exception that is thrown when the program executes an instruction that was thought to be unreachable. +/// +public class UnreachableException : Exception +{ + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// Specified error message. + public UnreachableException(string message) : base(message){} + + /// + /// Initializes a new instance of the class with the default error message. + /// + public UnreachableException() + {} +} + +