Skip to content

Commit 7340c27

Browse files
authored
Add support for multi-segment parameters to gRPC JSON transcoding (#42315)
1 parent 7c68e77 commit 7340c27

File tree

20 files changed

+1335
-51
lines changed

20 files changed

+1335
-51
lines changed

src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/JsonTranscodingProviderServiceBinder.cs

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics.CodeAnalysis;
5+
using System.Linq;
56
using Google.Api;
67
using Google.Protobuf.Reflection;
78
using Grpc.AspNetCore.Server;
@@ -228,20 +229,15 @@ private void AddMethodCore<TRequest, TResponse>(
228229

229230
private static (RoutePattern routePattern, CallHandlerDescriptorInfo descriptorInfo) ParseRoute(string pattern, string body, string responseBody, MethodDescriptor methodDescriptor)
230231
{
231-
if (!pattern.StartsWith('/'))
232-
{
233-
// This validation is consistent with grpc-gateway code generation.
234-
// We should match their validation to be a good member of the eco-system.
235-
throw new InvalidOperationException($"Path template '{pattern}' must start with a '/'.");
236-
}
232+
var httpRoutePattern = HttpRoutePattern.Parse(pattern);
233+
var adapter = JsonTranscodingRouteAdapter.Parse(httpRoutePattern);
237234

238-
var routePattern = RoutePatternFactory.Parse(pattern);
239-
return (RoutePatternFactory.Parse(pattern), CreateDescriptorInfo(body, responseBody, methodDescriptor, routePattern));
235+
return (RoutePatternFactory.Parse(adapter.ResolvedRouteTemplate), CreateDescriptorInfo(body, responseBody, methodDescriptor, adapter));
240236
}
241237

242-
private static CallHandlerDescriptorInfo CreateDescriptorInfo(string body, string responseBody, MethodDescriptor methodDescriptor, RoutePattern routePattern)
238+
private static CallHandlerDescriptorInfo CreateDescriptorInfo(string body, string responseBody, MethodDescriptor methodDescriptor, JsonTranscodingRouteAdapter routeAdapter)
243239
{
244-
var routeParameterDescriptors = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routePattern, methodDescriptor.InputType);
240+
var routeParameterDescriptors = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routeAdapter.HttpRoutePattern.Variables.Select(v => v.FieldPath).ToList(), methodDescriptor.InputType);
245241

246242
var bodyDescriptor = ServiceDescriptorHelpers.ResolveBodyDescriptor(body, typeof(TService), methodDescriptor);
247243

@@ -260,7 +256,8 @@ private static CallHandlerDescriptorInfo CreateDescriptorInfo(string body, strin
260256
bodyDescriptor?.Descriptor,
261257
bodyDescriptor?.IsDescriptorRepeated ?? false,
262258
bodyDescriptor?.FieldDescriptors,
263-
routeParameterDescriptors);
259+
routeParameterDescriptors,
260+
routeAdapter);
264261
return descriptorInfo;
265262
}
266263

src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ public CallHandlerDescriptorInfo(
1515
MessageDescriptor? bodyDescriptor,
1616
bool bodyDescriptorRepeated,
1717
List<FieldDescriptor>? bodyFieldDescriptors,
18-
Dictionary<string, List<FieldDescriptor>> routeParameterDescriptors)
18+
Dictionary<string, List<FieldDescriptor>> routeParameterDescriptors,
19+
JsonTranscodingRouteAdapter routeAdapter)
1920
{
2021
ResponseBodyDescriptor = responseBodyDescriptor;
2122
BodyDescriptor = bodyDescriptor;
2223
BodyDescriptorRepeated = bodyDescriptorRepeated;
2324
BodyFieldDescriptors = bodyFieldDescriptors;
2425
RouteParameterDescriptors = routeParameterDescriptors;
26+
RouteAdapter = routeAdapter;
2527
if (BodyFieldDescriptors != null)
2628
{
2729
BodyFieldDescriptorsPath = string.Join('.', BodyFieldDescriptors.Select(d => d.Name));
@@ -35,6 +37,7 @@ public CallHandlerDescriptorInfo(
3537
public bool BodyDescriptorRepeated { get; }
3638
public List<FieldDescriptor>? BodyFieldDescriptors { get; }
3739
public Dictionary<string, List<FieldDescriptor>> RouteParameterDescriptors { get; }
40+
public JsonTranscodingRouteAdapter RouteAdapter { get; }
3841
public ConcurrentDictionary<string, List<FieldDescriptor>?> PathDescriptorsCache { get; }
3942
public string? BodyFieldDescriptorsPath { get; }
4043
}

src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerCallHandlerBase.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ protected ServerCallHandlerBase(
3636

3737
public Task HandleCallAsync(HttpContext httpContext)
3838
{
39+
foreach (var rewriteAction in DescriptorInfo.RouteAdapter.RewriteVariableActions)
40+
{
41+
rewriteAction(httpContext);
42+
}
43+
3944
var serverCallContext = new JsonTranscodingServerCallContext(httpContext, MethodInvoker.Options, MethodInvoker.Method, DescriptorInfo, Logger);
4045
httpContext.Features.Set<IServerCallContextFeature>(serverCallContext);
4146

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ private static async ValueTask<byte[]> ReadDataAsync(JsonTranscodingServerCallCo
338338
{
339339
return serverCallContext.DescriptorInfo.PathDescriptorsCache.GetOrAdd(path, p =>
340340
{
341-
ServiceDescriptorHelpers.TryResolveDescriptors(requestMessage.Descriptor, p, out var pathDescriptors);
341+
ServiceDescriptorHelpers.TryResolveDescriptors(requestMessage.Descriptor, p.Split('.'), out var pathDescriptors);
342342
return pathDescriptors;
343343
});
344344
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Globalization;
5+
using System.Linq;
6+
using Grpc.Shared;
7+
using Microsoft.AspNetCore.Http;
8+
9+
namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal;
10+
11+
/// <summary>
12+
/// Routes on HTTP rule are similar to ASP.NET Core routes but add and remove some features.
13+
/// - Constraints aren't supported.
14+
/// - Optional parameters aren't supported.
15+
/// - Parameters spanning multiple segments are supported.
16+
///
17+
/// The purpose of this type is to add support for parameters spanning multiple segments and
18+
/// anonymous any or catch-all segments. This type transforms an HTTP route into an ASP.NET Core
19+
/// route by rewritting it to a compatible format and providing actions to reconstruct parameters
20+
/// that span multiple segments.
21+
///
22+
/// For example, consider a multi-segment parameter route:
23+
/// - Before: /v1/{book.name=shelves/*/books/*}
24+
/// - After: /v1/shelves/{__Complex_book.name_2}/books/{__Complex_book.name_4}
25+
///
26+
/// It is rewritten so that any * or ** segments become ASP.NET Core route parameters. These parameter
27+
/// names are never used by the user, and instead they're reconstructed into the final value by the
28+
/// adapter and then added to the HttpRequest.RouteValues collection.
29+
/// - Request URL: /v1/shelves/example-shelf/books/example-book
30+
/// - Route parameter: book.name = shelves/example-self/books/example-book
31+
/// </summary>
32+
internal sealed class JsonTranscodingRouteAdapter
33+
{
34+
public HttpRoutePattern HttpRoutePattern { get; }
35+
public string ResolvedRouteTemplate { get; }
36+
public List<Action<HttpContext>> RewriteVariableActions { get; }
37+
38+
private JsonTranscodingRouteAdapter(HttpRoutePattern httpRoutePattern, string resolvedRoutePattern, List<Action<HttpContext>> rewriteVariableActions)
39+
{
40+
HttpRoutePattern = httpRoutePattern;
41+
ResolvedRouteTemplate = resolvedRoutePattern;
42+
RewriteVariableActions = rewriteVariableActions;
43+
}
44+
45+
public static JsonTranscodingRouteAdapter Parse(HttpRoutePattern pattern)
46+
{
47+
var rewriteActions = new List<Action<HttpContext>>();
48+
49+
var tempSegments = pattern.Segments.ToList();
50+
var i = 0;
51+
while (i < tempSegments.Count)
52+
{
53+
var segmentVariable = GetVariable(pattern, i);
54+
if (segmentVariable != null)
55+
{
56+
var fullPath = string.Join(".", segmentVariable.FieldPath);
57+
58+
var segmentCount = segmentVariable.EndSegment - segmentVariable.StartSegment;
59+
if (segmentCount == 1)
60+
{
61+
// Single segment parameter. Include in route with its default name.
62+
tempSegments[i] = segmentVariable.HasCatchAllPath
63+
? $"{{**{fullPath}}}"
64+
: $"{{{fullPath}}}";
65+
i++;
66+
}
67+
else
68+
{
69+
var routeParameterParts = new List<string>();
70+
var routeValueFormatTemplateParts = new List<string>();
71+
var variableParts = new List<string>();
72+
var haveCatchAll = false;
73+
var catchAllSuffix = string.Empty;
74+
75+
while (i < segmentVariable.EndSegment && !haveCatchAll)
76+
{
77+
var segment = tempSegments[i];
78+
var segmentType = GetSegmentType(segment);
79+
switch (segmentType)
80+
{
81+
case SegmentType.Literal:
82+
routeValueFormatTemplateParts.Add(segment);
83+
break;
84+
case SegmentType.Any:
85+
{
86+
var parameterName = $"__Complex_{fullPath}_{i}";
87+
tempSegments[i] = $"{{{parameterName}}}";
88+
89+
routeValueFormatTemplateParts.Add($"{{{variableParts.Count}}}");
90+
variableParts.Add(parameterName);
91+
break;
92+
}
93+
case SegmentType.CatchAll:
94+
{
95+
var parameterName = $"__Complex_{fullPath}_{i}";
96+
var suffix = string.Join("/", tempSegments.Skip(i + 1));
97+
catchAllSuffix = string.Join("/", tempSegments.Skip(i + segmentCount - 1));
98+
99+
// It's possible to have multiple routes with catch-all parameters that have different suffixes.
100+
// For example:
101+
// - /{name=v1/**/b}/one
102+
// - /{name=v1/**/b}/two
103+
// 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;
105+
tempSegments[i] = $"{{**{parameterName}{constraint}}}";
106+
107+
routeValueFormatTemplateParts.Add($"{{{variableParts.Count}}}");
108+
variableParts.Add(parameterName);
109+
haveCatchAll = true;
110+
111+
// Remove remaining segments. They have been added in the route constraint.
112+
while (i < tempSegments.Count - 1)
113+
{
114+
tempSegments.RemoveAt(tempSegments.Count - 1);
115+
}
116+
break;
117+
}
118+
}
119+
i++;
120+
}
121+
122+
var routeValueFormatTemplate = string.Join("/", routeValueFormatTemplateParts);
123+
124+
// Add an action to reconstruct the multiple segment parameter from ASP.NET Core
125+
// request route values. This should be called when the request is received.
126+
rewriteActions.Add(context =>
127+
{
128+
var values = new object?[variableParts.Count];
129+
for (var i = 0; i < values.Length; i++)
130+
{
131+
values[i] = context.Request.RouteValues[variableParts[i]];
132+
}
133+
var finalValue = string.Format(CultureInfo.InvariantCulture, routeValueFormatTemplate, values);
134+
135+
// Catch-all route parameter is always the last parameter. The original HTTP pattern could specify a
136+
// literal suffix after the catch-all, e.g. /{param=**}/suffix. Because ASP.NET Core routing provides
137+
// the entire remainder of the URL in the route value, we must trim the suffix from that route value.
138+
if (!string.IsNullOrEmpty(catchAllSuffix))
139+
{
140+
finalValue = finalValue.Substring(0, finalValue.Length - catchAllSuffix.Length - 1);
141+
}
142+
context.Request.RouteValues[fullPath] = finalValue;
143+
});
144+
}
145+
}
146+
else
147+
{
148+
// HTTP route can match any value in a segment without a parameter.
149+
// For example, v1/*/books. Add a parameter to match this behavior logic.
150+
// Parameter value is never used.
151+
152+
var segmentType = GetSegmentType(tempSegments[i]);
153+
switch (segmentType)
154+
{
155+
case SegmentType.Literal:
156+
// Literal is unchanged.
157+
break;
158+
case SegmentType.Any:
159+
// Ignore any segment value.
160+
tempSegments[i] = $"{{__Discard_{i}}}";
161+
break;
162+
case SegmentType.CatchAll:
163+
// Ignore remaining segment values.
164+
tempSegments[i] = $"{{**__Discard_{i}}}";
165+
break;
166+
}
167+
168+
i++;
169+
}
170+
}
171+
172+
return new JsonTranscodingRouteAdapter(pattern, "/" + string.Join("/", tempSegments), rewriteActions);
173+
}
174+
175+
private static SegmentType GetSegmentType(string segment)
176+
{
177+
if (segment.StartsWith("**", StringComparison.Ordinal))
178+
{
179+
return SegmentType.CatchAll;
180+
}
181+
else if (segment.StartsWith("*", StringComparison.Ordinal))
182+
{
183+
return SegmentType.Any;
184+
}
185+
else
186+
{
187+
return SegmentType.Literal;
188+
}
189+
}
190+
191+
private enum SegmentType
192+
{
193+
Literal,
194+
Any,
195+
CatchAll
196+
}
197+
198+
private static HttpRouteVariable? GetVariable(HttpRoutePattern pattern, int i)
199+
{
200+
foreach (var variable in pattern.Variables)
201+
{
202+
if (i >= variable.StartSegment && i < variable.EndSegment)
203+
{
204+
return variable;
205+
}
206+
}
207+
208+
return null;
209+
}
210+
}

src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
<Compile Include="..\Shared\AuthContextHelpers.cs" Link="Internal\Shared\AuthContextHelpers.cs" />
2424
<Compile Include="..\Shared\ServiceDescriptorHelpers.cs" Link="Internal\Shared\ServiceDescriptorHelpers.cs" />
2525
<Compile Include="..\Shared\X509CertificateHelpers.cs" Link="Internal\Shared\X509CertificateHelpers.cs" />
26+
<Compile Include="..\Shared\HttpRoutePattern.cs" Link="Internal\Shared\HttpRoutePattern.cs" />
27+
<Compile Include="..\Shared\HttpRoutePatternParser.cs" Link="Internal\Shared\HttpRoutePatternParser.cs" />
2628
<Compile Include="$(SharedSourceRoot)ValueTaskExtensions\**\*.cs" LinkBase="Internal\Shared" />
2729

2830
<Protobuf Include="Internal\Protos\errors.proto" Access="Internal" />

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ private static ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint,
8484
}
8585

8686
var methodMetadata = routeEndpoint.Metadata.GetMetadata<GrpcMethodMetadata>()!;
87-
var routeParameters = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routeEndpoint.RoutePattern, methodDescriptor.InputType);
87+
var httpRoutePattern = HttpRoutePattern.Parse(pattern);
88+
var routeParameters = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(httpRoutePattern.Variables.Select(v => v.FieldPath).ToList(), methodDescriptor.InputType);
8889

8990
foreach (var routeParameter in routeParameters)
9091
{

src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Microsoft.AspNetCore.Grpc.Swagger.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
<InternalsVisibleTo Include="Microsoft.AspNetCore.Grpc.Swagger.Tests" />
1212

1313
<Compile Include="..\Shared\ServiceDescriptorHelpers.cs" Link="Internal\Shared\ServiceDescriptorHelpers.cs" />
14+
<Compile Include="..\Shared\HttpRoutePattern.cs" Link="Internal\Shared\HttpRoutePattern.cs" />
15+
<Compile Include="..\Shared\HttpRoutePatternParser.cs" Link="Internal\Shared\HttpRoutePatternParser.cs" />
1416

1517
<Reference Include="Microsoft.AspNetCore.Grpc.JsonTranscoding" />
1618
<Reference Include="Swashbuckle.AspNetCore" />
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Grpc.Shared;
5+
6+
internal sealed class HttpRoutePattern
7+
{
8+
public List<string> Segments { get; }
9+
public string? Verb { get; }
10+
public List<HttpRouteVariable> Variables { get; }
11+
12+
private HttpRoutePattern(List<string> segments, string? verb, List<HttpRouteVariable> variables)
13+
{
14+
Segments = segments;
15+
Verb = verb;
16+
Variables = variables;
17+
}
18+
19+
public static HttpRoutePattern Parse(string pattern)
20+
{
21+
var p = new HttpRoutePatternParser(pattern);
22+
p.Parse();
23+
24+
return new HttpRoutePattern(p.Segments, p.Verb, p.Variables);
25+
}
26+
}
27+
28+
internal sealed class HttpRouteVariable
29+
{
30+
public int Index { get; set; }
31+
public int StartSegment { get; set; }
32+
public int EndSegment { get; set; }
33+
public List<string> FieldPath { get; } = new List<string>();
34+
public bool HasCatchAllPath { get; set; }
35+
}

0 commit comments

Comments
 (0)