File: Internal\JsonTranscodingRouteAdapter.cs
Web Access
Project: src\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 rewritting 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;
    }
}