| File: Internal\JsonTranscodingRouteAdapter.cs | Web Access |
| Project: src\aspnetcore\src\Grpc\JsonTranscoding\src\Microsoft.AspNetCore.Grpc.JsonTranscoding\Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj (Microsoft.AspNetCore.Grpc.JsonTranscoding) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using Grpc.Shared; using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; /// <summary> /// Routes on HTTP rule are similar to ASP.NET Core routes but add and remove some features. /// <list type="bullet"> /// <item><description>Constraints aren't supported.</description></item> /// <item><description>Optional parameters aren't supported.</description></item> /// <item><description>Parameters spanning multiple segments are supported.</description></item> /// </list> /// /// The purpose of this type is to add support for parameters spanning multiple segments and /// anonymous any or catch-all segments. This type transforms an HTTP route into an ASP.NET Core /// route by rewriting it to a compatible format and providing actions to reconstruct parameters /// that span multiple segments. /// /// <para> /// For example, consider a multi-segment parameter route: /// <list type="bullet"> /// <item><description>Before: /v1/{book.name=shelves/*/books/*}</description></item> /// <item><description>After: /v1/shelves/{__Complex_book.name_2}/books/{__Complex_book.name_4}</description></item> /// </list> /// </para> /// /// It is rewritten so that any * or ** segments become ASP.NET Core route parameters. These parameter /// names are never used by the user, and instead they're reconstructed into the final value by the /// adapter and then added to the HttpRequest.RouteValues collection. /// <list type="bullet"> /// <item><description>Request URL: /v1/shelves/example-shelf/books/example-book</description></item> /// <item><description>Route parameter: book.name = shelves/example-self/books/example-book</description></item> /// </list> /// </summary> internal sealed class JsonTranscodingRouteAdapter { public HttpRoutePattern HttpRoutePattern { get; } public string ResolvedRouteTemplate { get; } public List<Action<HttpContext>> RewriteVariableActions { get; } private JsonTranscodingRouteAdapter(HttpRoutePattern httpRoutePattern, string resolvedRoutePattern, List<Action<HttpContext>> rewriteVariableActions) { HttpRoutePattern = httpRoutePattern; ResolvedRouteTemplate = resolvedRoutePattern; RewriteVariableActions = rewriteVariableActions; } public static JsonTranscodingRouteAdapter Parse(HttpRoutePattern pattern) { var rewriteActions = new List<Action<HttpContext>>(); var tempSegments = pattern.Segments.ToList(); var haveCatchAll = false; var i = 0; while (i < tempSegments.Count) { var segmentVariable = GetVariable(pattern, i); if (segmentVariable != null) { var fullPath = string.Join(".", segmentVariable.FieldPath); var remainingSegmentCount = segmentVariable.EndSegment - segmentVariable.StartSegment; // Handle situation where the last segment is catch all but there is a verb. if (remainingSegmentCount == 1 && segmentVariable.HasCatchAllPath && pattern.Verb != null) { // Move past the catch all so the regex added below just includes the verb. remainingSegmentCount++; } if (remainingSegmentCount == 1) { // Single segment parameter. Include in route with its default name. tempSegments[i] = segmentVariable.HasCatchAllPath ? $"{{**{fullPath}}}" : $"{{{fullPath}}}"; i++; } else { var routeParameterParts = new List<string>(); var routeValueFormatTemplateParts = new List<string>(); var variableParts = new List<string>(); var catchAllSuffix = string.Empty; while (i < segmentVariable.EndSegment && !haveCatchAll) { var segment = tempSegments[i]; var segmentType = GetSegmentType(segment); switch (segmentType) { case SegmentType.Literal: routeValueFormatTemplateParts.Add(segment); break; case SegmentType.Any: { var parameterName = $"__Complex_{fullPath}_{i}"; tempSegments[i] = $"{{{parameterName}}}"; routeValueFormatTemplateParts.Add($"{{{variableParts.Count}}}"); variableParts.Add(parameterName); break; } case SegmentType.CatchAll: { var parameterName = $"__Complex_{fullPath}_{i}"; var suffix = BuildSuffix(tempSegments.Skip(i + 1), pattern.Verb); catchAllSuffix = BuildSuffix(tempSegments.Skip(i + remainingSegmentCount - 1), pattern.Verb); // It's possible to have multiple routes with catch-all parameters that have different suffixes. // For example: // - /{name=v1/**/b}/one // - /{name=v1/**/b}/two // The suffix is added as a route constraint to avoid matching multiple routes to a request. var constraint = suffix.Length > 0 ? $":regex({Regex.Escape(suffix)}$)" : string.Empty; tempSegments[i] = $"{{**{parameterName}{constraint}}}"; routeValueFormatTemplateParts.Add($"{{{variableParts.Count}}}"); variableParts.Add(parameterName); haveCatchAll = true; // Remove remaining segments. They have been added in the route constraint. while (i < tempSegments.Count - 1) { tempSegments.RemoveAt(tempSegments.Count - 1); } break; } } i++; } var routeValueFormatTemplate = string.Join("/", routeValueFormatTemplateParts); // Add an action to reconstruct the multiple segment parameter from ASP.NET Core // request route values. This should be called when the request is received. rewriteActions.Add(context => { var values = new object?[variableParts.Count]; for (var i = 0; i < values.Length; i++) { values[i] = context.Request.RouteValues[variableParts[i]]; } var finalValue = string.Format(CultureInfo.InvariantCulture, routeValueFormatTemplate, values); // Catch-all route parameter is always the last parameter. The original HTTP pattern could specify a // literal suffix after the catch-all, e.g. /{param=**}/suffix. Because ASP.NET Core routing provides // the entire remainder of the URL in the route value, we must trim the suffix from that route value. if (!string.IsNullOrEmpty(catchAllSuffix)) { finalValue = finalValue[..^catchAllSuffix.Length]; } context.Request.RouteValues[fullPath] = finalValue; }); } } else { // HTTP route can match any value in a segment without a parameter. // For example, v1/*/books. Add a parameter to match this behavior logic. // Parameter value is never used. var segmentType = GetSegmentType(tempSegments[i]); switch (segmentType) { case SegmentType.Literal: // Literal is unchanged. break; case SegmentType.Any: // Ignore any segment value. tempSegments[i] = $"{{__Discard_{i}}}"; break; case SegmentType.CatchAll: // Ignore remaining segment values. if (pattern.Verb != null) { tempSegments[i] = $"{{**__Discard_{i}:regex({Regex.Escape($":{pattern.Verb}")}$)}}"; } else { tempSegments[i] = $"{{**__Discard_{i}}}"; } haveCatchAll = true; break; } i++; } } string resolvedRoutePattern = "/" + string.Join("/", tempSegments); // If the route has a catch all then the verb is included in the catch all regex constraint. if (pattern.Verb != null && !haveCatchAll) { resolvedRoutePattern += ":" + pattern.Verb; } return new JsonTranscodingRouteAdapter(pattern, resolvedRoutePattern, rewriteActions); static string BuildSuffix(IEnumerable<string> segments, string? verb) { var pattern = string.Join("/", segments); if (!string.IsNullOrEmpty(pattern)) { pattern = "/" + pattern; } if (verb != null) { pattern += ":" + verb; } return pattern; } } private static SegmentType GetSegmentType(string segment) { if (segment.StartsWith("**", StringComparison.Ordinal)) { return SegmentType.CatchAll; } else if (segment.StartsWith('*')) { return SegmentType.Any; } else { return SegmentType.Literal; } } private enum SegmentType { Literal, Any, CatchAll } private static HttpRouteVariable? GetVariable(HttpRoutePattern pattern, int i) { foreach (var variable in pattern.Variables) { if (i >= variable.StartSegment && i < variable.EndSegment) { return variable; } } return null; } }