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" },
{