Skip to content

Commit

Permalink
Merge pull request #658 from iron9light/round_tripping_route
Browse files Browse the repository at this point in the history
Support round-tripping route parameter syntax
  • Loading branch information
Oren Novotny authored May 4, 2019
2 parents 5b4e14a + 97b7701 commit d58df76
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 23 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ Task<List<User>> GroupList([AliasAs("id")] int groupId, [AliasAs("sort")] string
GroupList(4, "desc");
>>> "/group/4/users?sort=desc"
```

Round-tripping route parameter syntax: Forward slashes aren't encoded when using a double-asterisk (\*\*) catch-all parameter syntax.

During link generation, the routing system encodes the value captured in a double-asterisk (\*\*) catch-all parameter (for example, {**myparametername}) except the forward slashes.

The type of round-tripping route parameter must be string.

```csharp
[Get("/search/{**page}")]
Task<List<Page>> Search(string page);

Search("admin/products");
>>> "/search/admin/products"
```
### Dynamic Querystring Parameters

If you specify an `object` as a query parameter, all public properties which are not null are used as query parameters.
Expand Down
77 changes: 63 additions & 14 deletions Refit.Tests/RequestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ public interface IRestMethodInfoTests
[Get("/foo/bar/{id}")]
Task<string> FetchSomeStuff(int id);

[Get("/foo/bar/{**path}/{id}")]
Task<string> FetchSomeStuffWithRoundTrippingParam(string path, int id);

[Get("/foo/bar/{**path}/{id}")]
Task<string> FetchSomeStuffWithNonStringRoundTrippingParam(int path, int id);

[Get("/foo/bar/{id}?baz=bamf")]
Task<string> FetchSomeStuffWithHardcodedQueryParam(int id);

Expand Down Expand Up @@ -205,17 +211,41 @@ public void ParameterMappingSmokeTest()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "FetchSomeStuff"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Null(fixture.BodyParameterInfo);
}

[Fact]
public void ParameterMappingWithRoundTrippingSmokeTest()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "FetchSomeStuffWithRoundTrippingParam"));
Assert.Equal(Tuple.Create("path", ParameterType.RoundTripping), fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[1]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Null(fixture.BodyParameterInfo);
}

[Fact]
public void ParameterMappingWithNonStringRoundTrippingShouldThrow()
{
var input = typeof(IRestMethodInfoTests);
Assert.Throws<ArgumentException>(() =>
{
var fixture = new RestMethodInfo(
input,
input.GetMethods().First(x => x.Name == "FetchSomeStuffWithNonStringRoundTrippingParam")
);
});
}

[Fact]
public void ParameterMappingWithQuerySmokeTest()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "FetchSomeStuffWithQueryParam"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Equal("search", fixture.QueryParameterMap[1]);
Assert.Null(fixture.BodyParameterInfo);
}
Expand All @@ -225,7 +255,7 @@ public void ParameterMappingWithHardcodedQuerySmokeTest()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "FetchSomeStuffWithHardcodedQueryParam"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Null(fixture.BodyParameterInfo);
}
Expand All @@ -235,7 +265,7 @@ public void AliasMappingShouldWork()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "FetchSomeStuffWithAlias"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Null(fixture.BodyParameterInfo);
}
Expand All @@ -245,8 +275,8 @@ public void MultipleParametersPerSegmentShouldWork()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "FetchAnImage"));
Assert.Equal("width", fixture.ParameterMap[0]);
Assert.Equal("height", fixture.ParameterMap[1]);
Assert.Equal(Tuple.Create("width", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("height", ParameterType.Normal), fixture.ParameterMap[1]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Null(fixture.BodyParameterInfo);
}
Expand All @@ -256,7 +286,7 @@ public void FindTheBodyParameter()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "FetchSomeStuffWithBody"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);

Assert.NotNull(fixture.BodyParameterInfo);
Assert.Empty(fixture.QueryParameterMap);
Expand All @@ -268,7 +298,7 @@ public void AllowUrlEncodedContent()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "PostSomeUrlEncodedStuff"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);

Assert.NotNull(fixture.BodyParameterInfo);
Assert.Empty(fixture.QueryParameterMap);
Expand All @@ -280,7 +310,7 @@ public void HardcodedHeadersShouldWork()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "FetchSomeStuffWithHardcodedHeaders"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Null(fixture.BodyParameterInfo);

Expand All @@ -296,7 +326,7 @@ public void DynamicHeadersShouldWork()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "FetchSomeStuffWithDynamicHeader"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Null(fixture.BodyParameterInfo);

Expand All @@ -311,7 +341,7 @@ public void ValueTypesDontBlowUpBuffered()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "OhYeahValueTypes"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Equal(BodySerializationMethod.Default, fixture.BodyParameterInfo.Item1);
Assert.True(fixture.BodyParameterInfo.Item2); // buffered default
Expand All @@ -325,7 +355,7 @@ public void ValueTypesDontBlowUpUnBuffered()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "OhYeahValueTypesUnbuffered"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Equal(BodySerializationMethod.Default, fixture.BodyParameterInfo.Item1);
Assert.False(fixture.BodyParameterInfo.Item2); // unbuffered specified
Expand All @@ -339,7 +369,7 @@ public void StreamMethodPullWorks()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "PullStreamMethod"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);
Assert.Empty(fixture.QueryParameterMap);
Assert.Equal(BodySerializationMethod.Default, fixture.BodyParameterInfo.Item1);
Assert.True(fixture.BodyParameterInfo.Item2);
Expand All @@ -353,7 +383,7 @@ public void ReturningTaskShouldWork()
{
var input = typeof(IRestMethodInfoTests);
var fixture = new RestMethodInfo(input, input.GetMethods().First(x => x.Name == "VoidPost"));
Assert.Equal("id", fixture.ParameterMap[0]);
Assert.Equal(Tuple.Create("id", ParameterType.Normal), fixture.ParameterMap[0]);

Assert.Equal(typeof(Task), fixture.ReturnType);
Assert.Equal(typeof(void), fixture.SerializedReturnType);
Expand Down Expand Up @@ -423,6 +453,9 @@ public interface IDummyHttpApi
[Get("/foo/bar/{id}")]
Task<string> FetchSomeStuff(int id);

[Get("/foo/bar/{**path}/{id}")]
Task<string> FetchSomeStuffWithRoundTrippingParam(string path, int id);

[Get("/foo/bar/{id}?baz=bamf")]
Task<string> FetchSomeStuffWithHardcodedQueryParameter(int id);

Expand Down Expand Up @@ -825,6 +858,22 @@ public void ParameterizedQueryParamsShouldBeInUrl()
Assert.Equal("/foo/bar/6?baz=bamf&search_for=foo", uri.PathAndQuery);
}

[Theory]
[InlineData("aaa/bbb", "/foo/bar/aaa/bbb/1")]
[InlineData("aaa/bbb/ccc", "/foo/bar/aaa/bbb/ccc/1")]
[InlineData("aaa", "/foo/bar/aaa/1")]
[InlineData("aa a/bb-b", "/foo/bar/aa%20a/bb-b/1")]
public void RoundTrippingParameterizedQueryParamsShouldBeInUrl(string path, string expectedQuery)
{
var fixture = new RequestBuilderImplementation<IDummyHttpApi>();

var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithRoundTrippingParam");
var output = factory(new object[] { path, 1 });

var uri = new Uri(new Uri("http://api"), output.RequestUri);
Assert.Equal(expectedQuery, uri.PathAndQuery);
}

[Fact]
public void ParameterizedNullQueryParamsShouldBeBlankInUrl()
{
Expand Down
28 changes: 25 additions & 3 deletions Refit/RequestBuilderImplementation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -445,11 +445,33 @@ Func<object[], Task<HttpRequestMessage>> BuildRequestFactoryForMethod(RestMethod
// if part of REST resource URL, substitute it in
if (restMethod.ParameterMap.ContainsKey(i))
{
string pattern;
string replacement;
if (restMethod.ParameterMap[i].Item2 == ParameterType.RoundTripping)
{
pattern = $@"{{\*\*{restMethod.ParameterMap[i].Item1}}}";
var paramValue = paramList[i] as string;
replacement = string.Join(
"/",
paramValue.Split('/')
.Select(s =>
Uri.EscapeDataString(
settings.UrlParameterFormatter.Format(s, restMethod.ParameterInfoMap[i]) ?? string.Empty
)
)
);
}
else
{
pattern = "{" + restMethod.ParameterMap[i].Item1 + "}";
replacement = Uri.EscapeDataString(settings.UrlParameterFormatter
.Format(paramList[i], restMethod.ParameterInfoMap[i]) ?? string.Empty);
}

urlTarget = Regex.Replace(
urlTarget,
"{" + restMethod.ParameterMap[i] + "}",
Uri.EscapeDataString(settings.UrlParameterFormatter
.Format(paramList[i], restMethod.ParameterInfoMap[i]) ?? string.Empty),
pattern,
replacement,
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
continue;
}
Expand Down
36 changes: 30 additions & 6 deletions Refit/RestMethodInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@

namespace Refit
{
public enum ParameterType
{
Normal,
RoundTripping
}

[DebuggerDisplay("{MethodInfo}")]
public class RestMethodInfo
{
Expand All @@ -19,7 +25,7 @@ public class RestMethodInfo
public HttpMethod HttpMethod { get; set; }
public string RelativePath { get; set; }
public bool IsMultipart { get; private set; }
public Dictionary<int, string> ParameterMap { get; set; }
public Dictionary<int, Tuple<string, ParameterType>> ParameterMap { get; set; }
public ParameterInfo CancellationToken { get; set; }
public Dictionary<string, string> Headers { get; set; }
public Dictionary<int, string> HeaderParameterMap { get; set; }
Expand Down Expand Up @@ -132,9 +138,9 @@ void VerifyUrlPathIsSane(string relativePath)
throw new ArgumentException($"URL path {relativePath} must be of the form '/foo/bar/baz'");
}

Dictionary<int, string> BuildParameterMap(string relativePath, List<ParameterInfo> parameterInfo)
Dictionary<int, Tuple<string, ParameterType>> BuildParameterMap(string relativePath, List<ParameterInfo> parameterInfo)
{
var ret = new Dictionary<int, string>();
var ret = new Dictionary<int, Tuple<string, ParameterType>>();

var parameterizedParts = relativePath.Split('/', '?')
.SelectMany(x => ParameterRegex.Matches(x).Cast<Match>())
Expand All @@ -149,13 +155,31 @@ Dictionary<int, string> BuildParameterMap(string relativePath, List<ParameterInf

foreach (var match in parameterizedParts)
{
var name = match.Groups[1].Value.ToLowerInvariant();
var rawName = match.Groups[1].Value.ToLowerInvariant();
var isRoundTripping = rawName.StartsWith("**");
string name;
if (isRoundTripping)
{
name = rawName.Substring(2);
}
else
{
name = rawName;
}

if (!paramValidationDict.ContainsKey(name))
{
throw new ArgumentException($"URL {relativePath} has parameter {name}, but no method parameter matches");
throw new ArgumentException($"URL {relativePath} has parameter {rawName}, but no method parameter matches");
}

var paramType = paramValidationDict[name].ParameterType;
if (isRoundTripping && paramType != typeof(string))
{
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.");
}

ret.Add(parameterInfo.IndexOf(paramValidationDict[name]), name);
var parameterType = isRoundTripping ? ParameterType.RoundTripping : ParameterType.Normal;
ret.Add(parameterInfo.IndexOf(paramValidationDict[name]), Tuple.Create(name, parameterType));
}

return ret;
Expand Down

0 comments on commit d58df76

Please sign in to comment.