diff options
20 files changed, 1335 insertions, 51 deletions
diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/JsonTranscodingProviderServiceBinder.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/JsonTranscodingProviderServiceBinder.cs index 3941ea7b5e..4ea56f445d 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/JsonTranscodingProviderServiceBinder.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Binding/JsonTranscodingProviderServiceBinder.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Linq; using Google.Api; using Google.Protobuf.Reflection; using Grpc.AspNetCore.Server; @@ -228,20 +229,15 @@ internal sealed partial class JsonTranscodingProviderServiceBinder<TService> : S private static (RoutePattern routePattern, CallHandlerDescriptorInfo descriptorInfo) ParseRoute(string pattern, string body, string responseBody, MethodDescriptor methodDescriptor) { - if (!pattern.StartsWith('/')) - { - // This validation is consistent with grpc-gateway code generation. - // We should match their validation to be a good member of the eco-system. - throw new InvalidOperationException($"Path template '{pattern}' must start with a '/'."); - } + var httpRoutePattern = HttpRoutePattern.Parse(pattern); + var adapter = JsonTranscodingRouteAdapter.Parse(httpRoutePattern); - var routePattern = RoutePatternFactory.Parse(pattern); - return (RoutePatternFactory.Parse(pattern), CreateDescriptorInfo(body, responseBody, methodDescriptor, routePattern)); + return (RoutePatternFactory.Parse(adapter.ResolvedRouteTemplate), CreateDescriptorInfo(body, responseBody, methodDescriptor, adapter)); } - private static CallHandlerDescriptorInfo CreateDescriptorInfo(string body, string responseBody, MethodDescriptor methodDescriptor, RoutePattern routePattern) + private static CallHandlerDescriptorInfo CreateDescriptorInfo(string body, string responseBody, MethodDescriptor methodDescriptor, JsonTranscodingRouteAdapter routeAdapter) { - var routeParameterDescriptors = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routePattern, methodDescriptor.InputType); + var routeParameterDescriptors = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routeAdapter.HttpRoutePattern.Variables.Select(v => v.FieldPath).ToList(), methodDescriptor.InputType); var bodyDescriptor = ServiceDescriptorHelpers.ResolveBodyDescriptor(body, typeof(TService), methodDescriptor); @@ -260,7 +256,8 @@ internal sealed partial class JsonTranscodingProviderServiceBinder<TService> : S bodyDescriptor?.Descriptor, bodyDescriptor?.IsDescriptorRepeated ?? false, bodyDescriptor?.FieldDescriptors, - routeParameterDescriptors); + routeParameterDescriptors, + routeAdapter); return descriptorInfo; } diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs index fc31a28f01..022ff483fd 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/CallHandlerDescriptorInfo.cs @@ -15,13 +15,15 @@ internal sealed class CallHandlerDescriptorInfo MessageDescriptor? bodyDescriptor, bool bodyDescriptorRepeated, List<FieldDescriptor>? bodyFieldDescriptors, - Dictionary<string, List<FieldDescriptor>> routeParameterDescriptors) + Dictionary<string, List<FieldDescriptor>> routeParameterDescriptors, + JsonTranscodingRouteAdapter routeAdapter) { ResponseBodyDescriptor = responseBodyDescriptor; BodyDescriptor = bodyDescriptor; BodyDescriptorRepeated = bodyDescriptorRepeated; BodyFieldDescriptors = bodyFieldDescriptors; RouteParameterDescriptors = routeParameterDescriptors; + RouteAdapter = routeAdapter; if (BodyFieldDescriptors != null) { BodyFieldDescriptorsPath = string.Join('.', BodyFieldDescriptors.Select(d => d.Name)); @@ -35,6 +37,7 @@ internal sealed class CallHandlerDescriptorInfo public bool BodyDescriptorRepeated { get; } public List<FieldDescriptor>? BodyFieldDescriptors { get; } public Dictionary<string, List<FieldDescriptor>> RouteParameterDescriptors { get; } + public JsonTranscodingRouteAdapter RouteAdapter { get; } public ConcurrentDictionary<string, List<FieldDescriptor>?> PathDescriptorsCache { get; } public string? BodyFieldDescriptorsPath { get; } } diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerCallHandlerBase.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerCallHandlerBase.cs index aeb030dd77..5cfe984ac1 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerCallHandlerBase.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/ServerCallHandlerBase.cs @@ -36,6 +36,11 @@ internal abstract class ServerCallHandlerBase<TService, TRequest, TResponse> public Task HandleCallAsync(HttpContext httpContext) { + foreach (var rewriteAction in DescriptorInfo.RouteAdapter.RewriteVariableActions) + { + rewriteAction(httpContext); + } + var serverCallContext = new JsonTranscodingServerCallContext(httpContext, MethodInvoker.Options, MethodInvoker.Method, DescriptorInfo, Logger); httpContext.Features.Set<IServerCallContextFeature>(serverCallContext); diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs index 3507363c96..1dff5bb15d 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs @@ -338,7 +338,7 @@ internal static class JsonRequestHelpers { return serverCallContext.DescriptorInfo.PathDescriptorsCache.GetOrAdd(path, p => { - ServiceDescriptorHelpers.TryResolveDescriptors(requestMessage.Descriptor, p, out var pathDescriptors); + ServiceDescriptorHelpers.TryResolveDescriptors(requestMessage.Descriptor, p.Split('.'), out var pathDescriptors); return pathDescriptors; }); } diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonTranscodingRouteAdapter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonTranscodingRouteAdapter.cs new file mode 100644 index 0000000000..af9d3b8dbd --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonTranscodingRouteAdapter.cs @@ -0,0 +1,210 @@ +// 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 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. +/// - Constraints aren't supported. +/// - Optional parameters aren't supported. +/// - Parameters spanning multiple segments are supported. +/// +/// 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. +/// +/// For example, consider a multi-segment parameter route: +/// - Before: /v1/{book.name=shelves/*/books/*} +/// - After: /v1/shelves/{__Complex_book.name_2}/books/{__Complex_book.name_4} +/// +/// 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. +/// - Request URL: /v1/shelves/example-shelf/books/example-book +/// - Route parameter: book.name = shelves/example-self/books/example-book +/// </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 i = 0; + while (i < tempSegments.Count) + { + var segmentVariable = GetVariable(pattern, i); + if (segmentVariable != null) + { + var fullPath = string.Join(".", segmentVariable.FieldPath); + + var segmentCount = segmentVariable.EndSegment - segmentVariable.StartSegment; + if (segmentCount == 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 haveCatchAll = false; + 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 = string.Join("/", tempSegments.Skip(i + 1)); + catchAllSuffix = string.Join("/", tempSegments.Skip(i + segmentCount - 1)); + + // 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({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.Substring(0, finalValue.Length - catchAllSuffix.Length - 1); + } + 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. + tempSegments[i] = $"{{**__Discard_{i}}}"; + break; + } + + i++; + } + } + + return new JsonTranscodingRouteAdapter(pattern, "/" + string.Join("/", tempSegments), rewriteActions); + } + + private static SegmentType GetSegmentType(string segment) + { + if (segment.StartsWith("**", StringComparison.Ordinal)) + { + return SegmentType.CatchAll; + } + else if (segment.StartsWith("*", StringComparison.Ordinal)) + { + 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; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj index 956777c1e3..260bb02b3b 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Microsoft.AspNetCore.Grpc.JsonTranscoding.csproj @@ -23,6 +23,8 @@ <Compile Include="..\Shared\AuthContextHelpers.cs" Link="Internal\Shared\AuthContextHelpers.cs" /> <Compile Include="..\Shared\ServiceDescriptorHelpers.cs" Link="Internal\Shared\ServiceDescriptorHelpers.cs" /> <Compile Include="..\Shared\X509CertificateHelpers.cs" Link="Internal\Shared\X509CertificateHelpers.cs" /> + <Compile Include="..\Shared\HttpRoutePattern.cs" Link="Internal\Shared\HttpRoutePattern.cs" /> + <Compile Include="..\Shared\HttpRoutePatternParser.cs" Link="Internal\Shared\HttpRoutePatternParser.cs" /> <Compile Include="$(SharedSourceRoot)ValueTaskExtensions\**\*.cs" LinkBase="Internal\Shared" /> <Protobuf Include="Internal\Protos\errors.proto" Access="Internal" /> diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs index 1cc787de88..cff40e9d68 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Internal/GrpcJsonTranscodingDescriptionProvider.cs @@ -84,7 +84,8 @@ internal sealed class GrpcJsonTranscodingDescriptionProvider : IApiDescriptionPr } var methodMetadata = routeEndpoint.Metadata.GetMetadata<GrpcMethodMetadata>()!; - var routeParameters = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(routeEndpoint.RoutePattern, methodDescriptor.InputType); + var httpRoutePattern = HttpRoutePattern.Parse(pattern); + var routeParameters = ServiceDescriptorHelpers.ResolveRouteParameterDescriptors(httpRoutePattern.Variables.Select(v => v.FieldPath).ToList(), methodDescriptor.InputType); foreach (var routeParameter in routeParameters) { diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Microsoft.AspNetCore.Grpc.Swagger.csproj b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Microsoft.AspNetCore.Grpc.Swagger.csproj index 4ad6d024b9..f102223658 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Microsoft.AspNetCore.Grpc.Swagger.csproj +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.Swagger/Microsoft.AspNetCore.Grpc.Swagger.csproj @@ -11,6 +11,8 @@ <InternalsVisibleTo Include="Microsoft.AspNetCore.Grpc.Swagger.Tests" /> <Compile Include="..\Shared\ServiceDescriptorHelpers.cs" Link="Internal\Shared\ServiceDescriptorHelpers.cs" /> + <Compile Include="..\Shared\HttpRoutePattern.cs" Link="Internal\Shared\HttpRoutePattern.cs" /> + <Compile Include="..\Shared\HttpRoutePatternParser.cs" Link="Internal\Shared\HttpRoutePatternParser.cs" /> <Reference Include="Microsoft.AspNetCore.Grpc.JsonTranscoding" /> <Reference Include="Swashbuckle.AspNetCore" /> diff --git a/src/Grpc/JsonTranscoding/src/Shared/HttpRoutePattern.cs b/src/Grpc/JsonTranscoding/src/Shared/HttpRoutePattern.cs new file mode 100644 index 0000000000..0110856608 --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/HttpRoutePattern.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Grpc.Shared; + +internal sealed class HttpRoutePattern +{ + public List<string> Segments { get; } + public string? Verb { get; } + public List<HttpRouteVariable> Variables { get; } + + private HttpRoutePattern(List<string> segments, string? verb, List<HttpRouteVariable> variables) + { + Segments = segments; + Verb = verb; + Variables = variables; + } + + public static HttpRoutePattern Parse(string pattern) + { + var p = new HttpRoutePatternParser(pattern); + p.Parse(); + + return new HttpRoutePattern(p.Segments, p.Verb, p.Variables); + } +} + +internal sealed class HttpRouteVariable +{ + public int Index { get; set; } + public int StartSegment { get; set; } + public int EndSegment { get; set; } + public List<string> FieldPath { get; } = new List<string>(); + public bool HasCatchAllPath { get; set; } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/HttpRoutePatternParser.cs b/src/Grpc/JsonTranscoding/src/Shared/HttpRoutePatternParser.cs new file mode 100644 index 0000000000..d32e4d863e --- /dev/null +++ b/src/Grpc/JsonTranscoding/src/Shared/HttpRoutePatternParser.cs @@ -0,0 +1,349 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Grpc.Shared; + +// HTTP Template Grammar: +// +// Template = "/" | "/" Segments [ Verb ] ; +// Segments = Segment { "/" Segment } ; +// Segment = "*" | "**" | LITERAL | Variable ; +// Variable = "{" FieldPath [ "=" Segments ] "}" ; +// FieldPath = IDENT { "." IDENT } ; +// Verb = ":" LITERAL ; +internal class HttpRoutePatternParser +{ + private readonly string _input; + + // Token delimiter indexes + private int _tokenStart; + private int _tokenEnd; + + private bool _inVariable; + + private readonly List<string> _segments; + private string? _verb; + private readonly List<HttpRouteVariable> _variables; + private bool _hasCatchAllSegment; + + public List<string> Segments => _segments; + public string? Verb => _verb; + public List<HttpRouteVariable> Variables => _variables; + + public HttpRoutePatternParser(string input) + { + _input = input; + _segments = new List<string>(); + _variables = new List<HttpRouteVariable>(); + } + + public void Parse() + { + try + { + ParseTemplate(); + + if (_tokenStart < _input.Length) + { + throw new InvalidOperationException("Path template wasn't parsed to the end."); + } + } + catch (Exception ex) + { + throw new InvalidOperationException($"Error parsing path template '{_input}'.", ex); + } + } + + // Template = "/" Segments [ Verb ] ; + private void ParseTemplate() + { + if (!Consume('/')) + { + throw new InvalidOperationException("Path template must start with a '/'."); + } + ParseSegments(); + + if (EnsureCurrent()) + { + if (CurrentChar != ':') + { + throw new InvalidOperationException("Path segment must end with a '/'."); + } + ParseVerb(); + } + } + + // Segments = Segment { "/" Segment } ; + private void ParseSegments() + { + while (true) + { + if (!ParseSegment()) + { + // Support '/' template. + if (_segments.Count > 0) + { + throw new InvalidOperationException("Route template shouldn't end with a '/'."); + } + } + if (!Consume('/')) + { + break; + } + } + } + + // Segment = "*" | "**" | LITERAL | Variable ; + private bool ParseSegment() + { + if (!EnsureCurrent()) + { + return false; + } + switch (CurrentChar) + { + case '*': + { + if (_hasCatchAllSegment) + { + throw new InvalidOperationException("Only literal segments can follow a catch-all segment."); + } + + ConsumeAndAssert('*'); + + // Check for '**' + if (Consume('*')) + { + _segments.Add("**"); + _hasCatchAllSegment = true; + if (_inVariable) + { + CurrentVariable.HasCatchAllPath = true; + } + return true; + } + else + { + _segments.Add("*"); + return true; + } + } + + case '{': + if (_hasCatchAllSegment) + { + throw new InvalidOperationException("Only literal segments can follow a catch-all segment."); + } + + ParseVariable(); + return true; + default: + ParseLiteralSegment(); + return true; + } + } + + // Variable = "{" FieldPath [ "=" Segments ] "}" ; + private void ParseVariable() + { + ConsumeAndAssert('{'); + StartVariable(); + ParseFieldPath(); + if (Consume('=')) + { + ParseSegments(); + } + else + { + _segments.Add("*"); + } + EndVariable(); + ConsumeAndAssert('}'); + } + + private void ParseLiteralSegment() + { + if (!TryParseLiteral(out var literal)) + { + throw new InvalidOperationException("Empty literal segment."); + } + _segments.Add(literal); + } + + // FieldPath = IDENT { "." IDENT } ; + private void ParseFieldPath() + { + do + { + if (!ParseIdentifier()) + { + throw new InvalidOperationException("Incomplete or empty field path."); + } + } + while (Consume('.')); + } + + // Verb = ":" LITERAL ; + private void ParseVerb() + { + ConsumeAndAssert(':'); + if (!TryParseLiteral(out _verb)) + { + throw new InvalidOperationException("Empty verb."); + } + } + + private bool ParseIdentifier() + { + var identifier = string.Empty; + var hasEndChar = false; + + while (!hasEndChar && NextChar()) + { + var c = CurrentChar; + switch (c) + { + case '.': + case '}': + case '=': + hasEndChar = true; + break; + default: + Consume(c); + identifier += c; + break; + } + } + + if (string.IsNullOrEmpty(identifier)) + { + return false; + } + + CurrentVariable.FieldPath.Add(identifier); + return true; + } + + private bool TryParseLiteral([NotNullWhen(true)] out string? literal) + { + literal = null; + + if (!EnsureCurrent()) + { + return false; + } + + // Initialize to false in case we encounter an empty literal. + var result = false; + + while (true) + { + var c = CurrentChar; + switch (c) + { + case '/': + case ':': + case '}': + if (!result) + { + throw new InvalidOperationException("Path template has an empty segment."); + } + return result; + default: + Consume(c); + literal += c; + break; + } + + result = true; + + if (!NextChar()) + { + break; + } + } + + return result; + } + + private void ConsumeAndAssert(char? c) + { + if (!Consume(c)) + { + throw new InvalidOperationException($"Expected '{c}' when parsing path template."); + } + } + + private bool Consume(char? c) + { + if (!EnsureCurrent()) + { + return false; + } + if (CurrentChar != c) + { + return false; + } + _tokenStart++; + return true; + } + + private bool EnsureCurrent() => _tokenStart < _tokenEnd || NextChar(); + + private bool NextChar() + { + if (_tokenEnd < _input.Length) + { + _tokenEnd++; + return true; + } + else + { + return false; + } + } + + private char? CurrentChar => _tokenStart < _tokenEnd && _tokenEnd <= _input.Length ? _input[_tokenEnd - 1] : null; + + private HttpRouteVariable CurrentVariable + { + get + { + if (!_inVariable || _variables.LastOrDefault() is not HttpRouteVariable variable) + { + throw new InvalidOperationException("Unexpected error when updating variable."); + } + + return variable; + } + + } + + private void StartVariable() + { + if (_inVariable) + { + throw new InvalidOperationException("Variable can't be nested."); + } + + _variables.Add(new HttpRouteVariable()); + _inVariable = true; + CurrentVariable.StartSegment = _segments.Count; + CurrentVariable.HasCatchAllPath = false; + } + + private void EndVariable() + { + CurrentVariable.EndSegment = _segments.Count; + + Debug.Assert(CurrentVariable.FieldPath.Any()); + Debug.Assert(CurrentVariable.StartSegment < CurrentVariable.EndSegment); + Debug.Assert(CurrentVariable.EndSegment <= _segments.Count); + + _inVariable = false; + } +} diff --git a/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs b/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs index f18b0a2aec..07b68b74d6 100644 --- a/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs +++ b/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs @@ -25,6 +25,7 @@ using Google.Api; using Google.Protobuf; using Google.Protobuf.Reflection; using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.Primitives; @@ -65,28 +66,13 @@ internal static class ServiceDescriptorHelpers throw new InvalidOperationException($"Get not find Descriptor property on {serviceReflectionType.Name}."); } - public static bool TryResolveDescriptors(MessageDescriptor messageDescriptor, string variable, [NotNullWhen(true)]out List<FieldDescriptor>? fieldDescriptors) + public static bool TryResolveDescriptors(MessageDescriptor messageDescriptor, IList<string> path, [NotNullWhen(true)]out List<FieldDescriptor>? fieldDescriptors) { fieldDescriptors = null; - var path = variable.AsSpan(); MessageDescriptor? currentDescriptor = messageDescriptor; - while (path.Length > 0) + foreach (var fieldName in path) { - var separator = path.IndexOf('.'); - - string fieldName; - if (separator != -1) - { - fieldName = path.Slice(0, separator).ToString(); - path = path.Slice(separator + 1); - } - else - { - fieldName = path.ToString(); - path = ReadOnlySpan<char>.Empty; - } - var field = currentDescriptor?.FindFieldByName(fieldName); if (field == null) { @@ -108,7 +94,6 @@ internal static class ServiceDescriptorHelpers { currentDescriptor = null; } - } return fieldDescriptors != null; @@ -303,17 +288,18 @@ internal static class ServiceDescriptorHelpers } } - public static Dictionary<string, List<FieldDescriptor>> ResolveRouteParameterDescriptors(RoutePattern pattern, MessageDescriptor messageDescriptor) + public static Dictionary<string, List<FieldDescriptor>> ResolveRouteParameterDescriptors(List<List<string>> parameters, MessageDescriptor messageDescriptor) { var routeParameterDescriptors = new Dictionary<string, List<FieldDescriptor>>(StringComparer.Ordinal); - foreach (var routeParameter in pattern.Parameters) + foreach (var routeParameter in parameters) { - if (!TryResolveDescriptors(messageDescriptor, routeParameter.Name, out var fieldDescriptors)) + var completeFieldPath = string.Join(".", routeParameter); + if (!TryResolveDescriptors(messageDescriptor, routeParameter, out var fieldDescriptors)) { - throw new InvalidOperationException($"Couldn't find matching field for route parameter '{routeParameter.Name}' on {messageDescriptor.Name}."); + throw new InvalidOperationException($"Couldn't find matching field for route parameter '{completeFieldPath}' on {messageDescriptor.Name}."); } - routeParameterDescriptors.Add(routeParameter.Name, fieldDescriptors); + routeParameterDescriptors.Add(completeFieldPath, fieldDescriptors); } return routeParameterDescriptors; @@ -325,7 +311,8 @@ internal static class ServiceDescriptorHelpers { if (!string.Equals(body, "*", StringComparison.Ordinal)) { - if (!TryResolveDescriptors(methodDescriptor.InputType, body, out var bodyFieldDescriptors)) + var bodyFieldPath = body.Split('.'); + if (!TryResolveDescriptors(methodDescriptor.InputType, bodyFieldPath, out var bodyFieldDescriptors)) { throw new InvalidOperationException($"Couldn't find matching field for body '{body}' on {methodDescriptor.InputType.Name}."); } diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs index c2319e38bf..adc3c9e111 100644 --- a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/Infrastructure/TestHelpers.cs @@ -5,6 +5,8 @@ using System.Net; using Google.Protobuf.Reflection; using Grpc.AspNetCore.Server; using Grpc.Core.Interceptors; +using Grpc.Shared; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -67,6 +69,7 @@ internal static class TestHelpers bodyDescriptor, bodyDescriptorRepeated ?? false, bodyFieldDescriptors, - routeParameterDescriptors ?? new Dictionary<string, List<FieldDescriptor>>()); + routeParameterDescriptors ?? new Dictionary<string, List<FieldDescriptor>>(), + JsonTranscodingRouteAdapter.Parse(HttpRoutePattern.Parse("/"))); } } diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/RouteTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/RouteTests.cs new file mode 100644 index 0000000000..6ccaeffd65 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests/RouteTests.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Grpc.Core; +using IntegrationTestsWebsite; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.Infrastructure; +using Microsoft.AspNetCore.Testing; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.IntegrationTests; + +public class RouteTests : IntegrationTestBase +{ + public RouteTests(GrpcTestFixture<Startup> fixture, ITestOutputHelper outputHelper) + : base(fixture, outputHelper) + { + } + + [Fact] + public async Task ComplexParameter_MatchUrl_SuccessResult() + { + // Arrange + Task<HelloReply> UnaryMethod(HelloRequest request, ServerCallContext context) + { + return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}!" }); + } + var method = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>( + UnaryMethod, + Greeter.Descriptor.FindMethodByName("SayHelloComplex")); + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + // Act + var response = await client.GetAsync("/v1/greeter/from/test").DefaultTimeout(); + var responseStream = await response.Content.ReadAsStreamAsync(); + using var result = await JsonDocument.ParseAsync(responseStream); + + // Assert + Assert.Equal("Hello from/test!", result.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task MultipleComplexCatchAll_MatchUrl_SuccessResult() + { + // Arrange + Task<HelloReply> UnaryMethod1(HelloRequest request, ServerCallContext context) + { + return Task.FromResult(new HelloReply { Message = $"One - Hello {request.Name}!" }); + } + Task<HelloReply> UnaryMethod2(HelloRequest request, ServerCallContext context) + { + return Task.FromResult(new HelloReply { Message = $"Two - Hello {request.Name}!" }); + } + var method1 = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>( + UnaryMethod1, + Greeter.Descriptor.FindMethodByName("SayHelloComplexCatchAll1")); + var method2 = Fixture.DynamicGrpc.AddUnaryMethod<HelloRequest, HelloReply>( + UnaryMethod2, + Greeter.Descriptor.FindMethodByName("SayHelloComplexCatchAll2")); + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + // Act 1 + var response1 = await client.GetAsync("/v1/greeter/test1/b/c/d/one").DefaultTimeout(); + var responseStream1 = await response1.Content.ReadAsStreamAsync(); + using var result1 = await JsonDocument.ParseAsync(responseStream1); + + // Assert 1 + Assert.Equal("One - Hello v1/greeter/test1/b/c!", result1.RootElement.GetProperty("message").GetString()); + + // Act 2 + var response2 = await client.GetAsync("/v1/greeter/test2/b/c/d/two").DefaultTimeout(); + var responseStream2 = await response2.Content.ReadAsStreamAsync(); + using var result2 = await JsonDocument.ParseAsync(responseStream2); + + // Assert 2 + Assert.Equal("Two - Hello v1/greeter/test2/b/c!", result2.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task ComplexCatchAllParameter_NestedField_MatchUrl_SuccessResult() + { + // Arrange + Task<HelloReply> UnaryMethod(ComplextHelloRequest request, ServerCallContext context) + { + return Task.FromResult(new HelloReply { Message = $"Hello {request.Name.FirstName} {request.Name.LastName}!" }); + } + var method = Fixture.DynamicGrpc.AddUnaryMethod<ComplextHelloRequest, HelloReply>( + UnaryMethod, + Greeter.Descriptor.FindMethodByName("SayHelloComplexCatchAll3")); + + var client = new HttpClient(Fixture.Handler) { BaseAddress = new Uri("http://localhost") }; + + // Act + var response = await client.GetAsync("/v1/last_name/complex_greeter/test2/b/c/d/two").DefaultTimeout(); + var responseStream = await response.Content.ReadAsStreamAsync(); + using var result = await JsonDocument.ParseAsync(responseStream); + + // Assert + Assert.Equal("Hello complex_greeter/test2/b last_name!", result.RootElement.GetProperty("message").GetString()); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpRoutePatternParserTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpRoutePatternParserTests.cs new file mode 100644 index 0000000000..3fb4a80d1f --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/HttpRoutePatternParserTests.cs @@ -0,0 +1,325 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Grpc.Shared; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests; + +public class HttpRoutePatternParserTests +{ + [Fact] + public void ParseMultipleVariables() + { + var pattern = HttpRoutePattern.Parse("/shelves/{shelf}/books/{book}"); + Assert.Null(pattern.Verb); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("shelves", s), + s => Assert.Equal("*", s), + s => Assert.Equal("books", s), + s => Assert.Equal("*", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(1, v.StartSegment); + Assert.Equal(2, v.EndSegment); + Assert.Equal("shelf", string.Join(".", v.FieldPath)); + Assert.False(v.HasCatchAllPath); + }, + v => + { + Assert.Equal(3, v.StartSegment); + Assert.Equal(4, v.EndSegment); + Assert.Equal("book", string.Join(".", v.FieldPath)); + Assert.False(v.HasCatchAllPath); + }); + } + + [Fact] + public void ParseComplexVariable() + { + var pattern = HttpRoutePattern.Parse("/v1/{book.name=shelves/*/books/*}"); + Assert.Null(pattern.Verb); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("v1", s), + s => Assert.Equal("shelves", s), + s => Assert.Equal("*", s), + s => Assert.Equal("books", s), + s => Assert.Equal("*", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(1, v.StartSegment); + Assert.Equal(5, v.EndSegment); + Assert.Equal("book.name", string.Join(".", v.FieldPath)); + Assert.False(v.HasCatchAllPath); + }); + } + + [Fact] + public void ParseCatchAllSegment() + { + var pattern = HttpRoutePattern.Parse("/shelves/**"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("shelves", s), + s => Assert.Equal("**", s)); + Assert.Empty(pattern.Variables); + } + + [Fact] + public void ParseCatchAllSegment2() + { + var pattern = HttpRoutePattern.Parse("/**"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("**", s)); + Assert.Empty(pattern.Variables); + } + + [Fact] + public void ParseAnySegment() + { + var pattern = HttpRoutePattern.Parse("/*"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("*", s)); + Assert.Empty(pattern.Variables); + } + + [Fact] + public void ParseSlash() + { + var pattern = HttpRoutePattern.Parse("/"); + Assert.Empty(pattern.Segments); + Assert.Empty(pattern.Variables); + } + + [Fact] + public void ParseVerb() + { + var pattern = HttpRoutePattern.Parse("/a:foo"); + Assert.Equal("foo", pattern.Verb); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("a", s)); + Assert.Empty(pattern.Variables); + } + + [Fact] + public void ParseAnyAndCatchAllSegment() + { + var pattern = HttpRoutePattern.Parse("/*/**"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("*", s), + s => Assert.Equal("**", s)); + Assert.Empty(pattern.Variables); + } + + [Fact] + public void ParseAnyAndCatchAllSegment2() + { + var pattern = HttpRoutePattern.Parse("/*/a/**"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("*", s), + s => Assert.Equal("a", s), + s => Assert.Equal("**", s)); + Assert.Empty(pattern.Variables); + } + + [Fact] + public void ParseNestedFieldPath() + { + var pattern = HttpRoutePattern.Parse("/a/{a.b.c}"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("a", s), + s => Assert.Equal("*", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(1, v.StartSegment); + Assert.Equal(2, v.EndSegment); + Assert.Equal("a.b.c", string.Join(".", v.FieldPath)); + Assert.False(v.HasCatchAllPath); + }); + } + + [Fact] + public void ParseComplexNestedFieldPath() + { + var pattern = HttpRoutePattern.Parse("/a/{a.b.c=*}"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("a", s), + s => Assert.Equal("*", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(1, v.StartSegment); + Assert.Equal(2, v.EndSegment); + Assert.Equal("a.b.c", string.Join(".", v.FieldPath)); + Assert.False(v.HasCatchAllPath); + }); + } + + [Fact] + public void ParseComplexCatchAll() + { + var pattern = HttpRoutePattern.Parse("/a/{b=**}"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("a", s), + s => Assert.Equal("**", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(1, v.StartSegment); + Assert.Equal(2, v.EndSegment); + Assert.Equal("b", string.Join(".", v.FieldPath)); + Assert.True(v.HasCatchAllPath); + }); + } + + [Fact] + public void ParseComplexPrefixSegment() + { + var pattern = HttpRoutePattern.Parse("/a/{b=c/*}"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("a", s), + s => Assert.Equal("c", s), + s => Assert.Equal("*", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(1, v.StartSegment); + Assert.Equal(3, v.EndSegment); + Assert.Equal("b", string.Join(".", v.FieldPath)); + Assert.False(v.HasCatchAllPath); + }); + } + + [Fact] + public void ParseComplexPrefixSuffixSegment() + { + var pattern = HttpRoutePattern.Parse("/a/{b=c/*/d}"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("a", s), + s => Assert.Equal("c", s), + s => Assert.Equal("*", s), + s => Assert.Equal("d", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(1, v.StartSegment); + Assert.Equal(4, v.EndSegment); + Assert.Equal("b", string.Join(".", v.FieldPath)); + Assert.False(v.HasCatchAllPath); + }); + } + + [Fact] + public void ParseComplexPathCatchAll() + { + var pattern = HttpRoutePattern.Parse("/a/{b=c/**}"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("a", s), + s => Assert.Equal("c", s), + s => Assert.Equal("**", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(1, v.StartSegment); + Assert.Equal(3, v.EndSegment); + Assert.Equal("b", string.Join(".", v.FieldPath)); + Assert.True(v.HasCatchAllPath); + }); + } + + [Fact] + public void ParseComplexPrefixSuffixCatchAll() + { + var pattern = HttpRoutePattern.Parse("/{x.y.z=a/**/b}/c/d"); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("a", s), + s => Assert.Equal("**", s), + s => Assert.Equal("b", s), + s => Assert.Equal("c", s), + s => Assert.Equal("d", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(0, v.StartSegment); + Assert.Equal(3, v.EndSegment); + Assert.Equal("x.y.z", string.Join(".", v.FieldPath)); + Assert.True(v.HasCatchAllPath); + }); + } + + [Fact] + public void ParseCatchAllVerb() + { + var pattern = HttpRoutePattern.Parse("/a/{b=*}/**:verb"); + Assert.Equal("verb", pattern.Verb); + Assert.Collection( + pattern.Segments, + s => Assert.Equal("a", s), + s => Assert.Equal("*", s), + s => Assert.Equal("**", s)); + Assert.Collection( + pattern.Variables, + v => + { + Assert.Equal(1, v.StartSegment); + Assert.Equal(2, v.EndSegment); + Assert.Equal("b", string.Join(".", v.FieldPath)); + Assert.False(v.HasCatchAllPath); + }); + } + + [Theory] + [InlineData("", "Path template must start with a '/'.")] + [InlineData("//", "Path template has an empty segment.")] + [InlineData("/{}", "Incomplete or empty field path.")] + [InlineData("/a/", "Route template shouldn't end with a '/'.")] + [InlineData(":verb", "Path template must start with a '/'.")] + [InlineData(":", "Path template must start with a '/'.")] + [InlineData("/:", "Path template has an empty segment.")] + [InlineData("/{var}:", "Empty verb.")] + [InlineData("/{", "Incomplete or empty field path.")] + [InlineData("/a{x}", "Path segment must end with a '/'.")] + [InlineData("/{x}a", "Path segment must end with a '/'.")] + [InlineData("/{x}{y}", "Path segment must end with a '/'.")] + [InlineData("/{var=a/{nested=b}}", "Variable can't be nested.")] + [InlineData("/{x=**}/*", "Only literal segments can follow a catch-all segment.")] + [InlineData("/{x=}", "Path template has an empty segment.")] + [InlineData("/**/*", "Only literal segments can follow a catch-all segment.")] + [InlineData("/{x", "Expected '}' when parsing path template.")] + public void Error(string pattern, string errorMessage) + { + var ex = Assert.Throws<InvalidOperationException>(() => HttpRoutePattern.Parse(pattern)); + Assert.Equal(errorMessage, ex.InnerException!.Message); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs index 03a26331d6..85550a7a82 100644 --- a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Infrastructure/TestHelpers.cs @@ -5,6 +5,8 @@ using System.Net; using Google.Protobuf.Reflection; using Grpc.AspNetCore.Server; using Grpc.Core.Interceptors; +using Grpc.Shared; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -68,6 +70,7 @@ internal static class TestHelpers bodyDescriptor, bodyDescriptorRepeated ?? false, bodyFieldDescriptors, - routeParameterDescriptors ?? new Dictionary<string, List<FieldDescriptor>>()); + routeParameterDescriptors ?? new Dictionary<string, List<FieldDescriptor>>(), + JsonTranscodingRouteAdapter.Parse(HttpRoutePattern.Parse("/"))); } } diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingRouteAdapterTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingRouteAdapterTests.cs new file mode 100644 index 0000000000..c27ffe47e6 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingRouteAdapterTests.cs @@ -0,0 +1,223 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Grpc.Shared; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.FileSystemGlobbing.Internal; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests; + +public class JsonTranscodingRouteAdapterTests +{ + [Fact] + public void ParseMultipleVariables() + { + var pattern = HttpRoutePattern.Parse("/shelves/{shelf}/books/{book}"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/shelves/{shelf}/books/{book}", adapter.ResolvedRouteTemplate); + Assert.Empty(adapter.RewriteVariableActions); + } + + [Fact] + public void ParseComplexVariable() + { + var route = HttpRoutePattern.Parse("/v1/{book.name=shelves/*/books/*}"); + var adapter = JsonTranscodingRouteAdapter.Parse(route); + + Assert.Equal("/v1/shelves/{__Complex_book.name_2}/books/{__Complex_book.name_4}", adapter.ResolvedRouteTemplate); + Assert.Single(adapter.RewriteVariableActions); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues = new RouteValueDictionary + { + { "__Complex_book.name_2", "first" }, + { "__Complex_book.name_4", "second" } + }; + + adapter.RewriteVariableActions[0](httpContext); + + Assert.Equal("shelves/first/books/second", httpContext.Request.RouteValues["book.name"]); + } + + [Fact] + public void ParseCatchAllSegment() + { + var pattern = HttpRoutePattern.Parse("/shelves/**"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/shelves/{**__Discard_1}", adapter.ResolvedRouteTemplate); + Assert.Empty(adapter.RewriteVariableActions); + } + + [Fact] + public void ParseAnySegment() + { + var pattern = HttpRoutePattern.Parse("/*")!; + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/{__Discard_0}", adapter.ResolvedRouteTemplate); + Assert.Empty(adapter.RewriteVariableActions); + } + + [Fact] + public void ParseVerb() + { + var pattern = HttpRoutePattern.Parse("/a:foo"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/a", adapter.ResolvedRouteTemplate); + Assert.Empty(adapter.RewriteVariableActions); + } + + [Fact] + public void ParseAnyAndCatchAllSegment() + { + var pattern = HttpRoutePattern.Parse("/*/**"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/{__Discard_0}/{**__Discard_1}", adapter.ResolvedRouteTemplate); + Assert.Empty(adapter.RewriteVariableActions); + } + + [Fact] + public void ParseAnyAndCatchAllSegment2() + { + var pattern = HttpRoutePattern.Parse("/*/a/**"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/{__Discard_0}/a/{**__Discard_2}", adapter.ResolvedRouteTemplate); + Assert.Empty(adapter.RewriteVariableActions); + } + + [Fact] + public void ParseNestedFieldPath() + { + var pattern = HttpRoutePattern.Parse("/a/{a.b.c}"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/a/{a.b.c}", adapter.ResolvedRouteTemplate); + Assert.Empty(adapter.RewriteVariableActions); + } + + [Fact] + public void ParseComplexNestedFieldPath() + { + var pattern = HttpRoutePattern.Parse("/a/{a.b.c=*}"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/a/{a.b.c}", adapter.ResolvedRouteTemplate); + Assert.Empty(adapter.RewriteVariableActions); + } + + [Fact] + public void ParseComplexCatchAll() + { + var pattern = HttpRoutePattern.Parse("/a/{b=**}"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/a/{**b}", adapter.ResolvedRouteTemplate); + Assert.Empty(adapter.RewriteVariableActions); + } + + [Fact] + public void ParseComplexPrefixSuffixCatchAll() + { + var pattern = HttpRoutePattern.Parse("/{x.y.z=a/**/b}/c/d"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/a/{**__Complex_x.y.z_1:regex(b/c/d$)}", adapter.ResolvedRouteTemplate); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues = new RouteValueDictionary + { + { "__Complex_x.y.z_1", "my/value/b/c/d" } + }; + + adapter.RewriteVariableActions[0](httpContext); + + Assert.Equal("a/my/value/b", httpContext.Request.RouteValues["x.y.z"]); + } + + [Fact] + public void ParseComplexPrefixSegment() + { + var pattern = HttpRoutePattern.Parse("/a/{b=c/*}"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/a/c/{__Complex_b_2}", adapter.ResolvedRouteTemplate); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues = new RouteValueDictionary + { + { "__Complex_b_2", "value" } + }; + + adapter.RewriteVariableActions[0](httpContext); + + Assert.Equal("c/value", httpContext.Request.RouteValues["b"]); + } + + [Fact] + public void ParseComplexPrefixSuffixSegment() + { + var pattern = HttpRoutePattern.Parse("/a/{b=c/*/d}"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/a/c/{__Complex_b_2}/d", adapter.ResolvedRouteTemplate); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues = new RouteValueDictionary + { + { "__Complex_b_2", "value" } + }; + + adapter.RewriteVariableActions[0](httpContext); + + Assert.Equal("c/value/d", httpContext.Request.RouteValues["b"]); + } + + [Fact] + public void ParseComplexPathCatchAll() + { + var pattern = HttpRoutePattern.Parse("/a/{b=c/**}"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/a/c/{**__Complex_b_2}", adapter.ResolvedRouteTemplate); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues = new RouteValueDictionary + { + { "__Complex_b_2", "value" } + }; + + adapter.RewriteVariableActions[0](httpContext); + + Assert.Equal("c/value", httpContext.Request.RouteValues["b"]); + } + + [Fact] + public void ParseManyVariables() + { + var pattern = HttpRoutePattern.Parse("/{a}/{b}/{c}"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/{a}/{b}/{c}", adapter.ResolvedRouteTemplate); + } + + [Fact] + public void ParseCatchAllVerb() + { + var pattern = HttpRoutePattern.Parse("/a/{b=*}/**:verb"); + var adapter = JsonTranscodingRouteAdapter.Parse(pattern); + + Assert.Equal("/a/{b}/{**__Discard_2}", adapter.ResolvedRouteTemplate); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServerCallContextTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServerCallContextTests.cs index 2e3d6def02..9365412e9d 100644 --- a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServerCallContextTests.cs +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServerCallContextTests.cs @@ -5,6 +5,7 @@ using System.Net; using Google.Protobuf.Reflection; using Grpc.AspNetCore.Server; using Grpc.Core; +using Grpc.Shared; using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.CallHandlers; using Microsoft.AspNetCore.Http; @@ -99,7 +100,8 @@ public class JsonTranscodingServerCallContextTests null, false, null, - new Dictionary<string, List<FieldDescriptor>>()), + new Dictionary<string, List<FieldDescriptor>>(), + JsonTranscodingRouteAdapter.Parse(HttpRoutePattern.Parse("/")!)), NullLogger.Instance); } } diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServiceMethodProviderTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServiceMethodProviderTests.cs index 470db8b06d..4c1e3c65e9 100644 --- a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServiceMethodProviderTests.cs +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/JsonTranscodingServiceMethodProviderTests.cs @@ -173,7 +173,8 @@ public class JsonTranscodingServiceMethodProviderTests // Assert Assert.Equal("Error binding gRPC service 'JsonTranscodingInvalidPatternGreeterService'.", ex.Message); Assert.Equal("Error binding BadPattern on JsonTranscodingInvalidPatternGreeterService to HTTP API.", ex.InnerException!.InnerException!.Message); - Assert.Equal("Path template 'v1/greeter/{name}' must start with a '/'.", ex.InnerException!.InnerException!.InnerException!.Message); + Assert.Equal("Error parsing path template 'v1/greeter/{name}'.", ex.InnerException!.InnerException!.InnerException!.Message); + Assert.Equal("Path template must start with a '/'.", ex.InnerException!.InnerException!.InnerException!.InnerException!.Message); } private static RouteEndpoint FindGrpcEndpoint(IReadOnlyList<Endpoint> endpoints, string methodName) diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs index 21855fb25f..722a9410eb 100644 --- a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs @@ -222,7 +222,7 @@ public class UnaryServerCallHandlerTests : LoggedTest return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); }; - ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "sub", out var bodyFieldDescriptors); + ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, new[] { "sub" }, out var bodyFieldDescriptors); var descriptorInfo = TestHelpers.CreateDescriptorInfo( bodyDescriptor: HelloRequest.Types.SubMessage.Descriptor, @@ -264,7 +264,7 @@ public class UnaryServerCallHandlerTests : LoggedTest return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); }; - ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "repeated_strings", out var bodyFieldDescriptors); + ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, new[] { "repeated_strings" }, out var bodyFieldDescriptors); var descriptorInfo = TestHelpers.CreateDescriptorInfo( bodyDescriptor: HelloRequest.Types.SubMessage.Descriptor, @@ -318,7 +318,7 @@ public class UnaryServerCallHandlerTests : LoggedTest return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); }; - ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "sub.subfields", out var bodyFieldDescriptors); + ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, new[] { "sub", "subfields" }, out var bodyFieldDescriptors); var descriptorInfo = TestHelpers.CreateDescriptorInfo( bodyDescriptor: HelloRequest.Types.SubMessage.Descriptor, @@ -463,7 +463,7 @@ public class UnaryServerCallHandlerTests : LoggedTest return Task.FromResult(new HelloReply()); }; - ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "repeated_strings", out var bodyFieldDescriptors); + ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, new[] { "repeated_strings" }, out var bodyFieldDescriptors); var descriptorInfo = TestHelpers.CreateDescriptorInfo( bodyDescriptor: HelloRequest.Types.SubMessage.Descriptor, @@ -503,7 +503,7 @@ public class UnaryServerCallHandlerTests : LoggedTest invoker, descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HelloRequest.Descriptor)); var httpContext = TestHelpers.CreateHttpContext(); - httpContext.Request.Body = new MemoryStream("{}"u8); + httpContext.Request.Body = new MemoryStream("{}"u8.ToArray()); httpContext.Request.ContentType = contentType; // Act await unaryServerCallHandler.HandleCallAsync(httpContext); @@ -707,7 +707,7 @@ public class UnaryServerCallHandlerTests : LoggedTest var httpContext = TestHelpers.CreateHttpContext(); httpContext.Request.ContentType = "application/json"; - httpContext.Request.Body = new MemoryStream("null"u8); + httpContext.Request.Body = new MemoryStream("null"u8.ToArray()); // Act await unaryServerCallHandler.HandleCallAsync(httpContext); @@ -733,7 +733,7 @@ public class UnaryServerCallHandlerTests : LoggedTest return Task.FromResult(new HelloReply()); }; - Assert.True(ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "wrappers.float_value", out var bodyFieldDescriptors)); + Assert.True(ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, new[] { "wrappers", "float_value" }, out var bodyFieldDescriptors)); var descriptorInfo = TestHelpers.CreateDescriptorInfo( bodyDescriptor: FloatValue.Descriptor, @@ -795,7 +795,7 @@ public class UnaryServerCallHandlerTests : LoggedTest return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); }; - ServiceDescriptorHelpers.TryResolveDescriptors(HttpBodySubField.Descriptor, "sub", out var bodyFieldDescriptors); + ServiceDescriptorHelpers.TryResolveDescriptors(HttpBodySubField.Descriptor, new[] { "sub" }, out var bodyFieldDescriptors); var descriptorInfo = TestHelpers.CreateDescriptorInfo( bodyDescriptor: HttpBody.Descriptor, @@ -834,7 +834,7 @@ public class UnaryServerCallHandlerTests : LoggedTest return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); }; - ServiceDescriptorHelpers.TryResolveDescriptors(NestedHttpBodySubField.Descriptor, "sub.sub", out var bodyFieldDescriptors); + ServiceDescriptorHelpers.TryResolveDescriptors(NestedHttpBodySubField.Descriptor, new[] { "sub", "sub" }, out var bodyFieldDescriptors); var descriptorInfo = TestHelpers.CreateDescriptorInfo( bodyDescriptor: HttpBody.Descriptor, diff --git a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Protos/greet.proto b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Protos/greet.proto index 97f8543145..3038d68f95 100644 --- a/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Protos/greet.proto +++ b/src/Grpc/JsonTranscoding/test/testassets/IntegrationTestsWebsite/Protos/greet.proto @@ -20,6 +20,26 @@ service Greeter { body: "*" }; } + rpc SayHelloComplex (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name=from/*}" + }; + } + rpc SayHelloComplexCatchAll1 (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/{name=v1/greeter/**/b}/c/d/one" + }; + } + rpc SayHelloComplexCatchAll2 (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/{name=v1/greeter/**/b}/c/d/two" + }; + } + rpc SayHelloComplexCatchAll3 (ComplextHelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/{name.last_name}/{name.first_name=complex_greeter/**/b}/c/d/two" + }; + } } // The request message containing the user's name. @@ -27,6 +47,14 @@ message HelloRequest { string name = 1; } +message ComplextHelloRequest { + NameDetail name = 1; + message NameDetail { + string first_name = 1; + string last_name = 2; + } +} + // The response message containing the greetings. message HelloReply { string message = 1; |