diff --git a/docs/docfx/articles/transforms.md b/docs/docfx/articles/transforms.md index 29ecf2a08..34df70f82 100644 --- a/docs/docfx/articles/transforms.md +++ b/docs/docfx/articles/transforms.md @@ -79,10 +79,10 @@ Here is an example of common transforms: "route2" : { "ClusterId": "cluster1", "Match": { - "Path": "/api/{plugin}/stuff/{*remainder}" + "Path": "/api/{plugin}/stuff/{**remainder}" }, "Transforms": [ - { "PathPattern": "/foo/{plugin}/bar/{remainder}" }, + { "PathPattern": "/foo/{plugin}/bar/{**remainder}" }, { "QueryStringParameter": "q", "Append": "plugin" @@ -235,27 +235,27 @@ This will set the request path with the given value. Config: ```JSON -{ "PathPattern": "/my/{plugin}/api/{remainder}" } +{ "PathPattern": "/my/{plugin}/api/{**remainder}" } ``` Code: ```csharp -routeConfig = routeConfig.WithTransformPathRouteValues(pattern: new PathString("/my/{plugin}/api/{remainder}")); +routeConfig = routeConfig.WithTransformPathRouteValues(pattern: new PathString("/my/{plugin}/api/{**remainder}")); ``` ```C# -transformBuilderContext.AddPathRouteValues(pattern: new PathString("/my/{plugin}/api/{remainder}")); +transformBuilderContext.AddPathRouteValues(pattern: new PathString("/my/{plugin}/api/{**remainder}")); ``` -This will set the request path with the given value and replace any `{}` segments with the associated route value. `{}` segments without a matching route value are removed. See ASP.NET Core's [routing docs](https://docs.microsoft.com/aspnet/core/fundamentals/routing#route-template-reference) for more information about route templates. +This will set the request path with the given value and replace any `{}` segments with the associated route value. `{}` segments without a matching route value are removed. The final `{}` segment can be marked as `{**remainder}` to indicate this is a catch-all segment that may contain multiple path segments. See ASP.NET Core's [routing docs](https://docs.microsoft.com/aspnet/core/fundamentals/routing#route-template-reference) for more information about route templates. Example: | Step | Value | |------|-------| -| Route definition | `/api/{plugin}/stuff/{*remainder}` | +| Route definition | `/api/{plugin}/stuff/{**remainder}` | | Request path | `/api/v1/stuff/more/stuff` | | Plugin value | `v1` | | Remainder value | `more/stuff` | -| PathPattern | `/my/{plugin}/api/{remainder}` | +| PathPattern | `/my/{plugin}/api/{**remainder}` | | Result | `/my/v1/api/more/stuff` | ### QueryValueParameter diff --git a/src/ReverseProxy/Transforms/PathRouteValuesTransform.cs b/src/ReverseProxy/Transforms/PathRouteValuesTransform.cs index 905df6a15..c401c4192 100644 --- a/src/ReverseProxy/Transforms/PathRouteValuesTransform.cs +++ b/src/ReverseProxy/Transforms/PathRouteValuesTransform.cs @@ -4,6 +4,7 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.AspNetCore.Routing.Template; namespace Yarp.ReverseProxy.Transforms @@ -24,10 +25,10 @@ public PathRouteValuesTransform(string pattern, TemplateBinderFactory binderFact { _ = pattern ?? throw new ArgumentNullException(nameof(pattern)); _binderFactory = binderFactory ?? throw new ArgumentNullException(nameof(binderFactory)); - Template = TemplateParser.Parse(pattern); + Pattern = RoutePatternFactory.Parse(pattern); } - internal RouteTemplate Template { get; } + internal RoutePattern Pattern { get; } /// public override ValueTask ApplyAsync(RequestTransformContext context) @@ -39,11 +40,20 @@ public override ValueTask ApplyAsync(RequestTransformContext context) // TemplateBinder.BindValues will modify the RouteValueDictionary // We make a copy so that the original request is not modified by the transform - var routeValues = new RouteValueDictionary(context.HttpContext.Request.RouteValues); + var routeValues = context.HttpContext.Request.RouteValues; + var routeValuesCopy = new RouteValueDictionary(); - // Route values that are not considered defaults will be appended as query parameters. Make them all defaults. - var binder = _binderFactory.Create(Template, defaults: routeValues); - context.Path = binder.BindValues(acceptedValues: routeValues); + // Only copy route values used in the pattern, otherwise they'll be added as query parameters. + foreach (var pattern in Pattern.Parameters) + { + if (routeValues.TryGetValue(pattern.Name, out var value)) + { + routeValuesCopy[pattern.Name] = value; + } + } + + var binder = _binderFactory.Create(Pattern); + context.Path = binder.BindValues(acceptedValues: routeValuesCopy); return default; } diff --git a/test/ReverseProxy.Tests/Transforms/PathRouteValuesTransformTests.cs b/test/ReverseProxy.Tests/Transforms/PathRouteValuesTransformTests.cs index b3c2b9308..da4aebdff 100644 --- a/test/ReverseProxy.Tests/Transforms/PathRouteValuesTransformTests.cs +++ b/test/ReverseProxy.Tests/Transforms/PathRouteValuesTransformTests.cs @@ -17,7 +17,7 @@ public class PathRouteValuesTransformTests [InlineData("/{a}/{b}/{c}", "/6/7/8")] [InlineData("/{a}/foo/{b}/{c}/{d}", "/6/foo/7/8")] // Unknown value (d) dropped [InlineData("/{a}/foo/{b}", "/6/foo/7")] // Extra values (c) dropped - public async Task Set_PathPattern_ReplacesPathWithRouteValues(string transformValue, string expected) + public async Task ReplacesPatternWithRouteValues(string transformValue, string expected) { var serviceCollection = new ServiceCollection(); serviceCollection.AddOptions(); @@ -45,5 +45,35 @@ public async Task Set_PathPattern_ReplacesPathWithRouteValues(string transformVa // The transform should not modify the original request's route values Assert.Equal(routeValues, httpContext.Request.RouteValues); } + + [Fact] + public async Task RouteValuesWithSlashesNotEncoded() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddOptions(); + serviceCollection.AddRouting(); + using var services = serviceCollection.BuildServiceProvider(); + + var routeValues = new Dictionary + { + { "a", "abc" }, + { "b", "def" }, + { "remainder", "klm/nop/qrs" }, + }; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues = new RouteValueDictionary(routeValues); + var context = new RequestTransformContext() + { + Path = "/", + HttpContext = httpContext + }; + var transform = new PathRouteValuesTransform("/{a}/{b}/{**remainder}", services.GetRequiredService()); + await transform.ApplyAsync(context); + Assert.Equal("/abc/def/klm/nop/qrs", context.Path.Value); + + // The transform should not modify the original request's route values + Assert.Equal(routeValues, httpContext.Request.RouteValues); + } } } diff --git a/test/ReverseProxy.Tests/Transforms/PathTransformExtensionsTests.cs b/test/ReverseProxy.Tests/Transforms/PathTransformExtensionsTests.cs index 928f1f853..1f852a957 100644 --- a/test/ReverseProxy.Tests/Transforms/PathTransformExtensionsTests.cs +++ b/test/ReverseProxy.Tests/Transforms/PathTransformExtensionsTests.cs @@ -137,7 +137,7 @@ private static void ValidatePathRouteValues(TransformBuilderContext builderConte { var requestTransform = Assert.Single(builderContext.RequestTransforms); var pathRouteValuesTransform = Assert.IsType(requestTransform); - Assert.Equal("/path#", pathRouteValuesTransform.Template.TemplateText); + Assert.Equal("/path#", pathRouteValuesTransform.Pattern.RawText); } } } diff --git a/testassets/ReverseProxy.Config/appsettings.json b/testassets/ReverseProxy.Config/appsettings.json index 0f65f824c..e23f592f9 100644 --- a/testassets/ReverseProxy.Config/appsettings.json +++ b/testassets/ReverseProxy.Config/appsettings.json @@ -78,10 +78,10 @@ "ClusterId": "cluster2", "Match": { "Hosts": [ "localhost" ], - "Path": "/api/{plugin}/stuff/{*remainder}" + "Path": "/api/{plugin}/stuff/{**remainder}" }, "Transforms": [ - { "PathPattern": "/foo/{plugin}/bar/{remainder}" }, + { "PathPattern": "/foo/{plugin}/bar/{**remainder}" }, { "X-Forwarded": "Append", "HeaderPrefix": "X-Forwarded-" @@ -93,10 +93,6 @@ }, { "ClientCert": "X-Client-Cert" }, - { "PathSet": "/apis" }, - { "PathPrefix": "/apis" }, - { "PathRemovePrefix": "/apis" }, - { "RequestHeadersCopy": "true" }, { "RequestHeaderOriginalHost": "true" }, {