Skip to content

Commit c932c53

Browse files
committed
Added new "QueryParam", which enables far more flexibility with variable substitution in the forwarded path.
e.g. "https://site/img/cache/classifieds/photo.jpg" => http://api/v1/render?path=img/cache/classifieds/photo.jpg" via: { "QueryParam": "path", "Set": "img/{category}/{remainder}" } "OptimiseImageRoute": { "ClusterId": "ImageServer", "Match": { "Path": "/img/cache/{category}/{**remainder}" }, "Transforms": [ { "PathSet": "/v1/images/optimise" }, { "QueryParam": "path", "Set": "img/{category}/{remainder}" } ] }
1 parent ee67fd2 commit c932c53

File tree

8 files changed

+366
-2
lines changed

8 files changed

+366
-2
lines changed

docs/designs/config.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,31 @@ Update: The custom rule system was modified by [#24](https://github.com/dotnet/y
5858
]
5959
```
6060

61+
## Transforms
62+
63+
Transforms can be used to modify request paths and query parameters. Some transforms use templates that substitute route or query values. Tokens like `{name}` or `{**name}` are resolved from route values first, then query values. If a token is missing, the transform is skipped.
64+
65+
Example:
66+
```json
67+
"Routes": {
68+
"DarkWeatherImagesRoute": {
69+
"ClusterId": "SeabreezeApi",
70+
"Match": {
71+
"Path": "/img/cache/{category}/{**remainder}"
72+
},
73+
"Transforms": [
74+
{
75+
"PathSet": "/v1/weather/render"
76+
},
77+
{
78+
"QueryParameter": "path",
79+
"Set": "img/{category}/{remainder}"
80+
}
81+
]
82+
}
83+
}
84+
```
85+
6186
## Backend configuration
6287

6388
The proxy code defines the types [Backend](https://github.com/dotnet/yarp/blob/b2cf5bdddf7962a720672a75f2e93913d16dfee7/src/IslandGateway.Core/Abstractions/BackendDiscovery/Contract/Backend.cs) and [BackendEndpoint](https://github.com/dotnet/yarp/blob/b2cf5bdddf7962a720672a75f2e93913d16dfee7/src/IslandGateway.Core/Abstractions/BackendEndpointDiscovery/Contract/BackendEndpoint.cs) and allows these to be defined via config and referenced by name from routes.

global.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"sdk": {
3-
"version": "11.0.100-preview.1.26078.121"
3+
"version": "10.0.102"
44
},
55
"tools": {
6-
"dotnet": "11.0.100-preview.1.26078.121",
6+
"dotnet": "10.0.102",
77
"runtimes": {
88
"dotnet": [
99
"8.0.13",

src/ReverseProxy/ConfigurationSchema.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@
230230
"QueryRouteParameter": { "not": {} },
231231
"PathPattern": { "not": {} },
232232
"QueryValueParameter": { "not": {} },
233+
"QueryParameter": { "not": {} },
233234
"QueryRemoveParameter": { "not": {} },
234235
"HttpMethodChange": { "not": {} },
235236
"RequestHeaderRouteValue": { "not": {} },
@@ -480,6 +481,25 @@
480481
}
481482
]
482483
},
484+
{
485+
"type": "object",
486+
"description": "Adds or replaces a query string parameter using template substitutions from route and query values.",
487+
"properties": {
488+
"QueryParameter": {
489+
"type": "string",
490+
"description": "Name of a query string parameter."
491+
},
492+
"Set": {
493+
"type": "string",
494+
"description": "Template used to set the given query parameter. Supports {token} and {**token} substitutions."
495+
}
496+
},
497+
"additionalProperties": false,
498+
"required": [
499+
"QueryParameter",
500+
"Set"
501+
]
502+
},
483503
{
484504
"type": "object",
485505
"description": "Removes the specified parameter from the request query string.",
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Text;
7+
8+
namespace Yarp.ReverseProxy.Transforms;
9+
10+
/// <summary>
11+
/// Creates a query parameter value by substituting template tokens from route or query values.
12+
/// </summary>
13+
public sealed class QueryParameterTemplateTransform : QueryParameterTransform
14+
{
15+
private readonly TemplateSegment[] _segments;
16+
17+
public QueryParameterTemplateTransform(QueryStringTransformMode mode, string key, string template)
18+
: base(mode, key)
19+
{
20+
if (string.IsNullOrEmpty(key))
21+
{
22+
throw new ArgumentException($"'{nameof(key)}' cannot be null or empty.", nameof(key));
23+
}
24+
25+
ArgumentNullException.ThrowIfNull(template);
26+
27+
Template = template;
28+
_segments = TemplateParser.Parse(template);
29+
}
30+
31+
internal string Template { get; }
32+
33+
/// <inheritdoc/>
34+
protected override string? GetValue(RequestTransformContext context)
35+
{
36+
var builder = new StringBuilder();
37+
foreach (var segment in _segments)
38+
{
39+
if (!segment.IsToken)
40+
{
41+
builder.Append(segment.Value);
42+
continue;
43+
}
44+
45+
if (!TryResolveToken(context, segment.Value, out var tokenValue))
46+
{
47+
return null;
48+
}
49+
50+
builder.Append(tokenValue);
51+
}
52+
53+
return builder.ToString();
54+
}
55+
56+
private static bool TryResolveToken(RequestTransformContext context, string tokenName, out string? value)
57+
{
58+
var routeValues = context.HttpContext.Request.RouteValues;
59+
if (routeValues.TryGetValue(tokenName, out var routeValue) && routeValue is not null)
60+
{
61+
value = routeValue.ToString();
62+
return true;
63+
}
64+
65+
if (context.Query.Collection.TryGetValue(tokenName, out var queryValue))
66+
{
67+
value = queryValue.ToString();
68+
return true;
69+
}
70+
71+
value = null;
72+
return false;
73+
}
74+
75+
private readonly record struct TemplateSegment(bool IsToken, string Value);
76+
77+
private static class TemplateParser
78+
{
79+
public static TemplateSegment[] Parse(string template)
80+
{
81+
var segments = new List<TemplateSegment>();
82+
var index = 0;
83+
while (index < template.Length)
84+
{
85+
var openIndex = template.IndexOf('{', index);
86+
if (openIndex < 0)
87+
{
88+
if (index < template.Length)
89+
{
90+
segments.Add(new TemplateSegment(false, template.Substring(index)));
91+
}
92+
break;
93+
}
94+
95+
if (openIndex > index)
96+
{
97+
segments.Add(new TemplateSegment(false, template.Substring(index, openIndex - index)));
98+
}
99+
100+
var closeIndex = template.IndexOf('}', openIndex + 1);
101+
if (closeIndex < 0)
102+
{
103+
segments.Add(new TemplateSegment(false, template.Substring(openIndex)));
104+
break;
105+
}
106+
107+
var rawToken = template.Substring(openIndex + 1, closeIndex - openIndex - 1);
108+
var tokenName = NormalizeTokenName(rawToken);
109+
if (string.IsNullOrEmpty(tokenName))
110+
{
111+
segments.Add(new TemplateSegment(false, template.Substring(openIndex, closeIndex - openIndex + 1)));
112+
}
113+
else
114+
{
115+
segments.Add(new TemplateSegment(true, tokenName));
116+
}
117+
118+
index = closeIndex + 1;
119+
}
120+
121+
return segments.ToArray();
122+
}
123+
124+
private static string? NormalizeTokenName(string token)
125+
{
126+
if (string.IsNullOrWhiteSpace(token))
127+
{
128+
return null;
129+
}
130+
131+
token = token.Trim();
132+
133+
while (token.Length > 0 && token[0] == '*')
134+
{
135+
token = token.Substring(1);
136+
}
137+
138+
var constraintIndex = token.IndexOf(':', StringComparison.Ordinal);
139+
if (constraintIndex >= 0)
140+
{
141+
token = token.Substring(0, constraintIndex);
142+
}
143+
144+
return string.IsNullOrWhiteSpace(token) ? null : token;
145+
}
146+
}
147+
}

src/ReverseProxy/Transforms/QueryTransformExtensions.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,29 @@ public static TransformBuilderContext AddQueryRouteValue(this TransformBuilderCo
5959
return context;
6060
}
6161

62+
/// <summary>
63+
/// Clones the route and adds the transform that will set the query parameter using template substitutions.
64+
/// Tokens like {name} and {**name} are resolved from route values first, then query values.
65+
/// </summary>
66+
public static RouteConfig WithTransformQueryParameter(this RouteConfig route, string queryKey, string valueTemplate)
67+
{
68+
return route.WithTransform(transform =>
69+
{
70+
transform[QueryTransformFactory.QueryParameterKey] = queryKey;
71+
transform[QueryTransformFactory.SetKey] = valueTemplate;
72+
});
73+
}
74+
75+
/// <summary>
76+
/// Adds the transform that will set the query parameter using template substitutions.
77+
/// Tokens like {name} and {**name} are resolved from route values first, then query values.
78+
/// </summary>
79+
public static TransformBuilderContext AddQueryParameter(this TransformBuilderContext context, string queryKey, string valueTemplate)
80+
{
81+
context.RequestTransforms.Add(new QueryParameterTemplateTransform(QueryStringTransformMode.Set, queryKey, valueTemplate));
82+
return context;
83+
}
84+
6285
/// <summary>
6386
/// Clones the route and adds the transform that will remove the given query key.
6487
/// </summary>

src/ReverseProxy/Transforms/QueryTransformFactory.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal sealed class QueryTransformFactory : ITransformFactory
1111
{
1212
internal const string QueryValueParameterKey = "QueryValueParameter";
1313
internal const string QueryRouteParameterKey = "QueryRouteParameter";
14+
internal const string QueryParameterKey = "QueryParameter";
1415
internal const string QueryRemoveParameterKey = "QueryRemoveParameter";
1516
internal const string AppendKey = "Append";
1617
internal const string SetKey = "Set";
@@ -33,6 +34,14 @@ public bool Validate(TransformRouteValidationContext context, IReadOnlyDictionar
3334
context.Errors.Add(new ArgumentException($"Unexpected parameters for QueryRouteParameter: {string.Join(';', transformValues.Keys)}. Expected 'Append' or 'Set'."));
3435
}
3536
}
37+
else if (transformValues.TryGetValue(QueryParameterKey, out var queryParameter))
38+
{
39+
TransformHelpers.TryCheckTooManyParameters(context, transformValues, expected: 2);
40+
if (!transformValues.TryGetValue(SetKey, out var _) || transformValues.TryGetValue(AppendKey, out var _))
41+
{
42+
context.Errors.Add(new ArgumentException($"Unexpected parameters for QueryParameter: {string.Join(';', transformValues.Keys)}. Expected 'Set'."));
43+
}
44+
}
3645
else if (transformValues.TryGetValue(QueryRemoveParameterKey, out var removeQueryParameter))
3746
{
3847
TransformHelpers.TryCheckTooManyParameters(context, transformValues, expected: 1);
@@ -79,6 +88,18 @@ public bool Build(TransformBuilderContext context, IReadOnlyDictionary<string, s
7988
throw new NotSupportedException(string.Join(";", transformValues.Keys));
8089
}
8190
}
91+
else if (transformValues.TryGetValue(QueryParameterKey, out var queryParameter))
92+
{
93+
TransformHelpers.CheckTooManyParameters(transformValues, expected: 2);
94+
if (transformValues.TryGetValue(SetKey, out var setValue) && !transformValues.TryGetValue(AppendKey, out var _))
95+
{
96+
context.AddQueryParameter(queryParameter, setValue);
97+
}
98+
else
99+
{
100+
throw new NotSupportedException(string.Join(";", transformValues.Keys));
101+
}
102+
}
82103
else if (transformValues.TryGetValue(QueryRemoveParameterKey, out var removeQueryParameter))
83104
{
84105
TransformHelpers.CheckTooManyParameters(transformValues, expected: 1);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Threading.Tasks;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Routing;
7+
using Xunit;
8+
9+
namespace Yarp.ReverseProxy.Transforms.Tests;
10+
11+
public class QueryParameterTemplateTransformTests
12+
{
13+
[Fact]
14+
public async Task Set_UsesRouteAndQueryValues()
15+
{
16+
var routeValues = new RouteValueDictionary
17+
{
18+
["remainder"] = "7/8",
19+
["plugin"] = "dark"
20+
};
21+
22+
var context = CreateContext(routeValues, new QueryString("?size=small"));
23+
var transform = new QueryParameterTemplateTransform(QueryStringTransformMode.Set, "path", "img/{plugin}/{**remainder}/{size}");
24+
25+
await transform.ApplyAsync(context);
26+
27+
Assert.Equal("img/dark/7/8/small", context.Query.Collection["path"].ToString());
28+
Assert.Equal("small", context.Query.Collection["size"].ToString());
29+
}
30+
31+
[Fact]
32+
public async Task Set_SkipsWhenTokenMissing()
33+
{
34+
var routeValues = new RouteValueDictionary
35+
{
36+
["remainder"] = "7/8"
37+
};
38+
39+
var context = CreateContext(routeValues, QueryString.Empty);
40+
var transform = new QueryParameterTemplateTransform(QueryStringTransformMode.Set, "path", "img/{missing}");
41+
42+
await transform.ApplyAsync(context);
43+
44+
Assert.False(context.Query.Collection.ContainsKey("path"));
45+
Assert.Equal(QueryString.Empty, context.Query.QueryString);
46+
}
47+
48+
[Fact]
49+
public async Task Set_RouteValuesTakePrecedenceOverQuery()
50+
{
51+
var routeValues = new RouteValueDictionary
52+
{
53+
["value"] = "fromRoute"
54+
};
55+
56+
var context = CreateContext(routeValues, new QueryString("?value=fromQuery"));
57+
var transform = new QueryParameterTemplateTransform(QueryStringTransformMode.Set, "path", "{value}");
58+
59+
await transform.ApplyAsync(context);
60+
61+
Assert.Equal("fromRoute", context.Query.Collection["path"].ToString());
62+
}
63+
64+
[Fact]
65+
public async Task Set_CreatesExpectedPathAndQueryFromTemplate()
66+
{
67+
const string originalPath = "/img/cache/classifieds/photo.jpg";
68+
69+
var routeValues = new RouteValueDictionary
70+
{
71+
["category"] = "cache",
72+
["remainder"] = "classifieds/photo.jpg"
73+
};
74+
75+
var context = CreateContext(routeValues, QueryString.Empty, new PathString(originalPath));
76+
var pathTransform = new PathStringTransform(PathStringTransform.PathTransformMode.Set, new PathString("/v1/weather/render"));
77+
var queryTransform = new QueryParameterTemplateTransform(QueryStringTransformMode.Set, "path", "img/{category}/{remainder}");
78+
79+
await pathTransform.ApplyAsync(context);
80+
await queryTransform.ApplyAsync(context);
81+
82+
Assert.Equal(new PathString("/v1/weather/render"), context.Path);
83+
Assert.Equal("img/cache/classifieds/photo.jpg", context.Query.Collection["path"].ToString());
84+
}
85+
86+
private static RequestTransformContext CreateContext(RouteValueDictionary routeValues, QueryString queryString, PathString? path = null)
87+
{
88+
var httpContext = new DefaultHttpContext();
89+
httpContext.Request.RouteValues = routeValues;
90+
httpContext.Request.QueryString = queryString;
91+
92+
return new RequestTransformContext
93+
{
94+
Path = path ?? httpContext.Request.Path,
95+
Query = new QueryTransformContext(httpContext.Request),
96+
HttpContext = httpContext
97+
};
98+
}
99+
}

0 commit comments

Comments
 (0)