diff --git a/Refit/RequestBuilderImplementation.cs b/Refit/RequestBuilderImplementation.cs index 0d5dece6..bcf708a6 100644 --- a/Refit/RequestBuilderImplementation.cs +++ b/Refit/RequestBuilderImplementation.cs @@ -1,9 +1,9 @@ using System.Collections; using System.Collections.Concurrent; +using System.Diagnostics; using System.Net.Http; using System.Reflection; using System.Text; -using System.Text.RegularExpressions; using System.Web; namespace Refit @@ -635,9 +635,6 @@ bool paramsContainsCancellationToken multiPartContent = new MultipartFormDataContent(restMethod.MultipartBoundary); ret.Content = multiPartContent; } - - var urlTarget = - (basePath == "/" ? string.Empty : basePath) + restMethod.RelativePath; var queryParamsToAdd = new List>(); var headersToAdd = new Dictionary(restMethod.Headers); var propertiesToAdd = new Dictionary(); @@ -652,69 +649,8 @@ bool paramsContainsCancellationToken if (restMethod.ParameterMap.TryGetValue(i, out var parameterMapValue)) { parameterInfo = parameterMapValue; - 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 + if (!parameterMapValue.IsObjectPropertyParameter) { - 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.ParameterInfoMap[i], - restMethod.ParameterInfoMap[i].ParameterType - ) ?? string.Empty - ) - ) - ); - } - else - { - pattern = "{" + parameterMapValue.Name + "}"; - replacement = Uri.EscapeDataString( - settings.UrlParameterFormatter.Format( - param, - restMethod.ParameterInfoMap[i], - restMethod.ParameterInfoMap[i].ParameterType - ) ?? string.Empty - ); - } - - urlTarget = Regex.Replace( - urlTarget, - pattern, - replacement, - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant - ); - isParameterMappedToRequest = true; } } @@ -976,6 +912,7 @@ await content restMethod.ToRestMethodInfo(); #endif + 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. @@ -1220,6 +1157,93 @@ RestMethodInfoInternal restMethod }; } + 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(); + } + + private string GetPathFragmentValue(RestMethodInfoInternal restMethod, object[] paramList, ParameterFragment fragment) + { + if (fragment.IsConstant) + { + return fragment.Value!; + } + + restMethod.ParameterMap.TryGetValue(fragment.ArgumentIndex, out var parameterMapValue); + Debug.Assert(parameterMapValue is not null); + + 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) + { + var paramValue = (string)param; + return string.Join( + "/", + paramValue + .Split('/') + .Select( + s => + Uri.EscapeDataString( + settings.UrlParameterFormatter.Format( + s, + restMethod.ParameterInfoMap[fragment.ArgumentIndex], + restMethod.ParameterInfoMap[fragment.ArgumentIndex].ParameterType + ) ?? string.Empty + ) + ) + ); + } + else + { + return Uri.EscapeDataString( + settings.UrlParameterFormatter.Format( + param, + restMethod.ParameterInfoMap[fragment.ArgumentIndex], + restMethod.ParameterInfoMap[fragment.ArgumentIndex].ParameterType + ) ?? string.Empty + ); + } + } + + return string.Empty; + } + private static bool IsBodyBuffered( RestMethodInfoInternal restMethod, HttpRequestMessage? request diff --git a/Refit/RestMethodInfo.cs b/Refit/RestMethodInfo.cs index 3716cb0d..856ef550 100644 --- a/Refit/RestMethodInfo.cs +++ b/Refit/RestMethodInfo.cs @@ -45,6 +45,9 @@ internal class RestMethodInfoInternal public Dictionary> AttachmentNameMap { get; set; } public Dictionary ParameterInfoMap { 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; } @@ -52,7 +55,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. @@ -92,7 +95,7 @@ internal class RestMethodInfoInternal ParameterInfoMap = parameterList .Select((parameter, index) => new { index, parameter }) .ToDictionary(x => x.index, x => x.parameter); - ParameterMap = BuildParameterMap(RelativePath, parameterList); + (ParameterMap, FragmentPath) = BuildParameterMap(RelativePath, parameterList); BodyParameterInfo = FindBodyParameter(parameterList, IsMultipart, hma.Method); AuthorizeParameterInfo = FindAuthorizationParameter(parameterList); @@ -252,7 +255,7 @@ static void VerifyUrlPathIsSane(string relativePath) ); } - static Dictionary BuildParameterMap( + static (Dictionary ret, List fragmentList) BuildParameterMap( string relativePath, List parameterInfo ) @@ -260,123 +263,138 @@ List 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) + { + 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 = parameterInfo.IndexOf(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 = parameterInfo.IndexOf(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 = parameterInfo.IndexOf(restMethodParameterInfo.ParameterInfo); + fragmentList.Add(ParameterFragment.DynamicObject(idx, 0)); #if NET6_0_OR_GREATER ret.TryAdd( - parameterInfo.IndexOf(restMethodParameterInfo.ParameterInfo), + idx, restMethodParameterInfo ); #else - var idx = parameterInfo.IndexOf(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 = parameterInfo.IndexOf(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( - parameterInfo.IndexOf(restMethodParameterInfo.ParameterInfo), - restMethodParameterInfo - ); -#else - // Do the contains check - var idx = parameterInfo.IndexOf(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" + ); } } - return ret; + + // add trailing string + if (index < relativePath.Length - 1) + { + var s = relativePath.Substring(index, relativePath.Length - index); + fragmentList.Add(ParameterFragment.Constant(s)); + } + return (ret, fragmentList); } static string GetUrlNameForParameter(ParameterInfo paramInfo) @@ -656,4 +674,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); + } }