Skip to content

Commit 5c477ff

Browse files
[release/7.0] Fix verb in route template with gRPC transcoding (#47162)
* Fix verb in route template with gRPC transcoding * Clean up * Clean up * Clean up * PR feedback * Fix tests * Include verb in swagger path --------- Co-authored-by: James Newton-King <[email protected]>
1 parent 569741c commit 5c477ff

File tree

10 files changed

+398
-15
lines changed

10 files changed

+398
-15
lines changed

src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonTranscodingRouteAdapter.cs

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Globalization;
55
using System.Linq;
6+
using System.Text.RegularExpressions;
67
using Grpc.Shared;
78
using Microsoft.AspNetCore.Http;
89

@@ -47,6 +48,8 @@ public static JsonTranscodingRouteAdapter Parse(HttpRoutePattern pattern)
4748
var rewriteActions = new List<Action<HttpContext>>();
4849

4950
var tempSegments = pattern.Segments.ToList();
51+
var haveCatchAll = false;
52+
5053
var i = 0;
5154
while (i < tempSegments.Count)
5255
{
@@ -55,8 +58,16 @@ public static JsonTranscodingRouteAdapter Parse(HttpRoutePattern pattern)
5558
{
5659
var fullPath = string.Join(".", segmentVariable.FieldPath);
5760

58-
var segmentCount = segmentVariable.EndSegment - segmentVariable.StartSegment;
59-
if (segmentCount == 1)
61+
var remainingSegmentCount = segmentVariable.EndSegment - segmentVariable.StartSegment;
62+
63+
// Handle situation where the last segment is catch all but there is a verb.
64+
if (remainingSegmentCount == 1 && segmentVariable.HasCatchAllPath && pattern.Verb != null)
65+
{
66+
// Move past the catch all so the regex added below just includes the verb.
67+
remainingSegmentCount++;
68+
}
69+
70+
if (remainingSegmentCount == 1)
6071
{
6172
// Single segment parameter. Include in route with its default name.
6273
tempSegments[i] = segmentVariable.HasCatchAllPath
@@ -69,7 +80,6 @@ public static JsonTranscodingRouteAdapter Parse(HttpRoutePattern pattern)
6980
var routeParameterParts = new List<string>();
7081
var routeValueFormatTemplateParts = new List<string>();
7182
var variableParts = new List<string>();
72-
var haveCatchAll = false;
7383
var catchAllSuffix = string.Empty;
7484

7585
while (i < segmentVariable.EndSegment && !haveCatchAll)
@@ -93,15 +103,15 @@ public static JsonTranscodingRouteAdapter Parse(HttpRoutePattern pattern)
93103
case SegmentType.CatchAll:
94104
{
95105
var parameterName = $"__Complex_{fullPath}_{i}";
96-
var suffix = string.Join("/", tempSegments.Skip(i + 1));
97-
catchAllSuffix = string.Join("/", tempSegments.Skip(i + segmentCount - 1));
106+
var suffix = BuildSuffix(tempSegments.Skip(i + 1), pattern.Verb);
107+
catchAllSuffix = BuildSuffix(tempSegments.Skip(i + remainingSegmentCount - 1), pattern.Verb);
98108

99109
// It's possible to have multiple routes with catch-all parameters that have different suffixes.
100110
// For example:
101111
// - /{name=v1/**/b}/one
102112
// - /{name=v1/**/b}/two
103113
// The suffix is added as a route constraint to avoid matching multiple routes to a request.
104-
var constraint = suffix.Length > 0 ? $":regex({suffix}$)" : string.Empty;
114+
var constraint = suffix.Length > 0 ? $":regex({Regex.Escape(suffix)}$)" : string.Empty;
105115
tempSegments[i] = $"{{**{parameterName}{constraint}}}";
106116

107117
routeValueFormatTemplateParts.Add($"{{{variableParts.Count}}}");
@@ -137,7 +147,7 @@ public static JsonTranscodingRouteAdapter Parse(HttpRoutePattern pattern)
137147
// the entire remainder of the URL in the route value, we must trim the suffix from that route value.
138148
if (!string.IsNullOrEmpty(catchAllSuffix))
139149
{
140-
finalValue = finalValue.Substring(0, finalValue.Length - catchAllSuffix.Length - 1);
150+
finalValue = finalValue[..^catchAllSuffix.Length];
141151
}
142152
context.Request.RouteValues[fullPath] = finalValue;
143153
});
@@ -161,15 +171,43 @@ public static JsonTranscodingRouteAdapter Parse(HttpRoutePattern pattern)
161171
break;
162172
case SegmentType.CatchAll:
163173
// Ignore remaining segment values.
164-
tempSegments[i] = $"{{**__Discard_{i}}}";
174+
if (pattern.Verb != null)
175+
{
176+
tempSegments[i] = $"{{**__Discard_{i}:regex({Regex.Escape($":{pattern.Verb}")}$)}}";
177+
}
178+
else
179+
{
180+
tempSegments[i] = $"{{**__Discard_{i}}}";
181+
}
182+
haveCatchAll = true;
165183
break;
166184
}
167185

168186
i++;
169187
}
170188
}
171189

172-
return new JsonTranscodingRouteAdapter(pattern, "/" + string.Join("/", tempSegments), rewriteActions);
190+
string resolvedRoutePattern = "/" + string.Join("/", tempSegments);
191+
// If the route has a catch all then the verb is included in the catch all regex constraint.
192+
if (pattern.Verb != null && !haveCatchAll)
193+
{
194+
resolvedRoutePattern += ":" + pattern.Verb;
195+
}
196+
return new JsonTranscodingRouteAdapter(pattern, resolvedRoutePattern, rewriteActions);
197+
198+
static string BuildSuffix(IEnumerable<string> segments, string? verb)
199+
{
200+
var pattern = string.Join("/", segments);
201+
if (!string.IsNullOrEmpty(pattern))
202+
{
203+
pattern = "/" + pattern;
204+
}
205+
if (verb != null)
206+
{
207+
pattern += ":" + verb;
208+
}
209+
return pattern;
210+
}
173211
}
174212

175213
private static SegmentType GetSegmentType(string segment)

src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ private static string ResolvePath(HttpRoutePattern httpRoutePattern, Dictionary<
182182
sb.Append(httpRoutePattern.Segments[i]);
183183
}
184184
}
185+
if (httpRoutePattern.Verb != null)
186+
{
187+
sb.Append(':');
188+
sb.Append(httpRoutePattern.Verb);
189+
}
185190
return sb.ToString();
186191
}
187192

src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/RouteTests.cs

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Net;
45
using System.Net.Http;
56
using System.Net.Http.Headers;
67
using System.Text;
78
using System.Text.Json;
89
using Grpc.Core;
910
using IntegrationTestsWebsite;
1011
using Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.Infrastructure;
11-
using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal;
12-
using Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure;
1312
using Microsoft.AspNetCore.Testing;
1413
using Xunit.Abstractions;
1514

@@ -105,4 +104,163 @@ Task<HelloReply> UnaryMethod(ComplextHelloRequest request, ServerCallContext con
105104
// Assert
106105
Assert.Equal("Hello complex_greeter/test2/b last_name!", result.RootElement.GetProperty("message").GetString());
107106
}
107+
108+
[Fact]
109+
public async Task SimpleCatchAllParameter_PrefixSuffixSlashes_MatchUrl_SuccessResult()
110+
{
111+
// Arrange
112+
Task<HelloReply> UnaryMethod(HelloRequest request, ServerCallContext context)
113+
{
114+
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}!" });
115+
}
116+
var method = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>(
117+
UnaryMethod,
118+
Greeter.Descriptor.FindMethodByName("SayHelloComplexCatchAll4"));
119+
120+
var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") };
121+
122+
// Act
123+
var response = await client.GetAsync("/v1/greeter//name/one/two//").DefaultTimeout();
124+
var responseStream = await response.Content.ReadAsStreamAsync();
125+
using var result = await JsonDocument.ParseAsync(responseStream);
126+
127+
// Assert
128+
Assert.Equal("Hello /name/one/two//!", result.RootElement.GetProperty("message").GetString());
129+
}
130+
131+
[Fact]
132+
public async Task ParameterVerb_MatchUrl_SuccessResult()
133+
{
134+
// Arrange
135+
Task<HelloReply> UnaryMethod1(HelloRequest request, ServerCallContext context)
136+
{
137+
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name} one!" });
138+
}
139+
Task<HelloReply> UnaryMethod2(HelloRequest request, ServerCallContext context)
140+
{
141+
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name} two!" });
142+
}
143+
var method1 = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>(
144+
UnaryMethod1,
145+
Greeter.Descriptor.FindMethodByName("SayHelloCustomVerbOne"));
146+
var method2 = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>(
147+
UnaryMethod2,
148+
Greeter.Descriptor.FindMethodByName("SayHelloCustomVerbTwo"));
149+
150+
var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") };
151+
152+
// Act 1
153+
var response1 = await client.GetAsync("/v1/greeter_custom/test:one").DefaultTimeout();
154+
var responseStream1 = await response1.Content.ReadAsStreamAsync();
155+
using var result1 = await JsonDocument.ParseAsync(responseStream1);
156+
157+
// Assert 2
158+
Assert.Equal("Hello test one!", result1.RootElement.GetProperty("message").GetString());
159+
160+
// Act 2
161+
var response2 = await client.GetAsync("/v1/greeter_custom/test:two").DefaultTimeout();
162+
var responseStream2 = await response2.Content.ReadAsStreamAsync();
163+
using var result2 = await JsonDocument.ParseAsync(responseStream2);
164+
165+
// Assert 2
166+
Assert.Equal("Hello test two!", result2.RootElement.GetProperty("message").GetString());
167+
168+
// Act 3
169+
var response3 = await client.GetAsync("/v1/greeter_custom/test").DefaultTimeout();
170+
171+
// Assert 3
172+
Assert.Equal(HttpStatusCode.NotFound, response3.StatusCode);
173+
}
174+
175+
[Fact]
176+
public async Task CatchAllVerb_MatchUrl_SuccessResult()
177+
{
178+
// Arrange
179+
Task<HelloReply> UnaryMethod1(HelloRequest request, ServerCallContext context)
180+
{
181+
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name} one!" });
182+
}
183+
Task<HelloReply> UnaryMethod2(HelloRequest request, ServerCallContext context)
184+
{
185+
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name} two!" });
186+
}
187+
var method1 = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>(
188+
UnaryMethod1,
189+
Greeter.Descriptor.FindMethodByName("SayHelloCatchAllCustomVerbOne"));
190+
var method2 = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>(
191+
UnaryMethod2,
192+
Greeter.Descriptor.FindMethodByName("SayHelloCatchAllCustomVerbTwo"));
193+
194+
var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") };
195+
196+
// Act 1
197+
var response1 = await client.GetAsync("/v1/greeter_customcatchall/test/name:one").DefaultTimeout();
198+
var responseStream1 = await response1.Content.ReadAsStreamAsync();
199+
using var result1 = await JsonDocument.ParseAsync(responseStream1);
200+
201+
// Assert 2
202+
Assert.Equal("Hello test/name one!", result1.RootElement.GetProperty("message").GetString());
203+
204+
// Act 2
205+
var response2 = await client.GetAsync("/v1/greeter_customcatchall/test/name:two").DefaultTimeout();
206+
var responseStream2 = await response2.Content.ReadAsStreamAsync();
207+
using var result2 = await JsonDocument.ParseAsync(responseStream2);
208+
209+
// Assert 2
210+
Assert.Equal("Hello test/name two!", result2.RootElement.GetProperty("message").GetString());
211+
212+
// Act 3
213+
var response3 = await client.GetAsync("/v1/greeter_customcatchall/test/name").DefaultTimeout();
214+
215+
// Assert 3
216+
Assert.Equal(HttpStatusCode.NotFound, response3.StatusCode);
217+
}
218+
219+
[Fact]
220+
public async Task PostVerb_MatchUrl_SuccessResult()
221+
{
222+
// Arrange
223+
Task<HelloReply> UnaryMethod1(HelloRequest request, ServerCallContext context)
224+
{
225+
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name} one!" });
226+
}
227+
Task<HelloReply> UnaryMethod2(HelloRequest request, ServerCallContext context)
228+
{
229+
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name} two!" });
230+
}
231+
var method1 = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>(
232+
UnaryMethod1,
233+
Greeter.Descriptor.FindMethodByName("SayHelloPostCustomVerbOne"));
234+
var method2 = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>(
235+
UnaryMethod2,
236+
Greeter.Descriptor.FindMethodByName("SayHelloPostCustomVerbTwo"));
237+
238+
var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") };
239+
240+
var requestMessage = new HelloRequest { Name = "test" };
241+
var content = new ByteArrayContent(Encoding.UTF8.GetBytes(requestMessage.ToString()));
242+
content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
243+
244+
// Act 1
245+
var response1 = await client.PostAsync("/v1/greeter_custompost:one", content).DefaultTimeout();
246+
var responseStream1 = await response1.Content.ReadAsStreamAsync();
247+
using var result1 = await JsonDocument.ParseAsync(responseStream1);
248+
249+
// Assert 2
250+
Assert.Equal("Hello test one!", result1.RootElement.GetProperty("message").GetString());
251+
252+
// Act 2
253+
var response2 = await client.PostAsync("/v1/greeter_custompost:two", content).DefaultTimeout();
254+
var responseStream2 = await response2.Content.ReadAsStreamAsync();
255+
using var result2 = await JsonDocument.ParseAsync(responseStream2);
256+
257+
// Assert 2
258+
Assert.Equal("Hello test two!", result2.RootElement.GetProperty("message").GetString());
259+
260+
// Act 3
261+
var response3 = await client.PostAsync("/v1/greeter_custompost", content).DefaultTimeout();
262+
263+
// Assert 3
264+
Assert.Equal(HttpStatusCode.NotFound, response3.StatusCode);
265+
}
108266
}

0 commit comments

Comments
 (0)