diff --git a/docs/designs/config.md b/docs/designs/config.md
index 28a7c515f..7c3fe1569 100644
--- a/docs/designs/config.md
+++ b/docs/designs/config.md
@@ -58,6 +58,31 @@ Update: The custom rule system was modified by [#24](https://github.com/dotnet/y
]
```
+## Transforms
+
+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.
+
+Example:
+```json
+ "Routes": {
+ "DarkWeatherImagesRoute": {
+ "ClusterId": "SeabreezeApi",
+ "Match": {
+ "Path": "/img/cache/{category}/{**remainder}"
+ },
+ "Transforms": [
+ {
+ "PathSet": "/v1/weather/render"
+ },
+ {
+ "QueryParameter": "path",
+ "Set": "img/{category}/{remainder}"
+ }
+ ]
+ }
+ }
+```
+
## Backend configuration
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.
diff --git a/src/ReverseProxy/ConfigurationSchema.json b/src/ReverseProxy/ConfigurationSchema.json
index 1d8aaf741..cee5b083b 100644
--- a/src/ReverseProxy/ConfigurationSchema.json
+++ b/src/ReverseProxy/ConfigurationSchema.json
@@ -230,6 +230,7 @@
"QueryRouteParameter": { "not": {} },
"PathPattern": { "not": {} },
"QueryValueParameter": { "not": {} },
+ "QueryParameter": { "not": {} },
"QueryRemoveParameter": { "not": {} },
"HttpMethodChange": { "not": {} },
"RequestHeaderRouteValue": { "not": {} },
@@ -480,6 +481,25 @@
}
]
},
+ {
+ "type": "object",
+ "description": "Adds or replaces a query string parameter using template substitutions from route and query values.",
+ "properties": {
+ "QueryParameter": {
+ "type": "string",
+ "description": "Name of a query string parameter."
+ },
+ "Set": {
+ "type": "string",
+ "description": "Template used to set the given query parameter. Supports {token} and {**token} substitutions."
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "QueryParameter",
+ "Set"
+ ]
+ },
{
"type": "object",
"description": "Removes the specified parameter from the request query string.",
diff --git a/src/ReverseProxy/Transforms/QueryParameterTemplateTransform.cs b/src/ReverseProxy/Transforms/QueryParameterTemplateTransform.cs
new file mode 100644
index 000000000..afd6a601a
--- /dev/null
+++ b/src/ReverseProxy/Transforms/QueryParameterTemplateTransform.cs
@@ -0,0 +1,147 @@
+// 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.Text;
+
+namespace Yarp.ReverseProxy.Transforms;
+
+///
+/// Creates a query parameter value by substituting template tokens from route or query values.
+///
+public sealed class QueryParameterTemplateTransform : QueryParameterTransform
+{
+ private readonly TemplateSegment[] _segments;
+
+ public QueryParameterTemplateTransform(QueryStringTransformMode mode, string key, string template)
+ : base(mode, key)
+ {
+ if (string.IsNullOrEmpty(key))
+ {
+ throw new ArgumentException($"'{nameof(key)}' cannot be null or empty.", nameof(key));
+ }
+
+ ArgumentNullException.ThrowIfNull(template);
+
+ Template = template;
+ _segments = TemplateParser.Parse(template);
+ }
+
+ internal string Template { get; }
+
+ ///
+ protected override string? GetValue(RequestTransformContext context)
+ {
+ var builder = new StringBuilder();
+ foreach (var segment in _segments)
+ {
+ if (!segment.IsToken)
+ {
+ builder.Append(segment.Value);
+ continue;
+ }
+
+ if (!TryResolveToken(context, segment.Value, out var tokenValue))
+ {
+ return null;
+ }
+
+ builder.Append(tokenValue);
+ }
+
+ return builder.ToString();
+ }
+
+ private static bool TryResolveToken(RequestTransformContext context, string tokenName, out string? value)
+ {
+ var routeValues = context.HttpContext.Request.RouteValues;
+ if (routeValues.TryGetValue(tokenName, out var routeValue) && routeValue is not null)
+ {
+ value = routeValue.ToString();
+ return true;
+ }
+
+ if (context.Query.Collection.TryGetValue(tokenName, out var queryValue))
+ {
+ value = queryValue.ToString();
+ return true;
+ }
+
+ value = null;
+ return false;
+ }
+
+ private readonly record struct TemplateSegment(bool IsToken, string Value);
+
+ private static class TemplateParser
+ {
+ public static TemplateSegment[] Parse(string template)
+ {
+ var segments = new List();
+ var index = 0;
+ while (index < template.Length)
+ {
+ var openIndex = template.IndexOf('{', index);
+ if (openIndex < 0)
+ {
+ if (index < template.Length)
+ {
+ segments.Add(new TemplateSegment(false, template.Substring(index)));
+ }
+ break;
+ }
+
+ if (openIndex > index)
+ {
+ segments.Add(new TemplateSegment(false, template.Substring(index, openIndex - index)));
+ }
+
+ var closeIndex = template.IndexOf('}', openIndex + 1);
+ if (closeIndex < 0)
+ {
+ segments.Add(new TemplateSegment(false, template.Substring(openIndex)));
+ break;
+ }
+
+ var rawToken = template.Substring(openIndex + 1, closeIndex - openIndex - 1);
+ var tokenName = NormalizeTokenName(rawToken);
+ if (string.IsNullOrEmpty(tokenName))
+ {
+ segments.Add(new TemplateSegment(false, template.Substring(openIndex, closeIndex - openIndex + 1)));
+ }
+ else
+ {
+ segments.Add(new TemplateSegment(true, tokenName));
+ }
+
+ index = closeIndex + 1;
+ }
+
+ return segments.ToArray();
+ }
+
+ private static string? NormalizeTokenName(string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ return null;
+ }
+
+ token = token.Trim();
+
+ while (token.Length > 0 && token[0] == '*')
+ {
+ token = token.Substring(1);
+ }
+
+ var constraintIndex = token.IndexOf(':', StringComparison.Ordinal);
+ if (constraintIndex >= 0)
+ {
+ token = token.Substring(0, constraintIndex);
+ }
+
+ return string.IsNullOrWhiteSpace(token) ? null : token;
+ }
+ }
+}
diff --git a/src/ReverseProxy/Transforms/QueryTransformExtensions.cs b/src/ReverseProxy/Transforms/QueryTransformExtensions.cs
index f6be87a86..0fd97eebd 100644
--- a/src/ReverseProxy/Transforms/QueryTransformExtensions.cs
+++ b/src/ReverseProxy/Transforms/QueryTransformExtensions.cs
@@ -59,6 +59,29 @@ public static TransformBuilderContext AddQueryRouteValue(this TransformBuilderCo
return context;
}
+ ///
+ /// Clones the route and adds the transform that will set the query parameter using template substitutions.
+ /// Tokens like {name} and {**name} are resolved from route values first, then query values.
+ ///
+ public static RouteConfig WithTransformQueryParameter(this RouteConfig route, string queryKey, string valueTemplate)
+ {
+ return route.WithTransform(transform =>
+ {
+ transform[QueryTransformFactory.QueryParameterKey] = queryKey;
+ transform[QueryTransformFactory.SetKey] = valueTemplate;
+ });
+ }
+
+ ///
+ /// Adds the transform that will set the query parameter using template substitutions.
+ /// Tokens like {name} and {**name} are resolved from route values first, then query values.
+ ///
+ public static TransformBuilderContext AddQueryParameter(this TransformBuilderContext context, string queryKey, string valueTemplate)
+ {
+ context.RequestTransforms.Add(new QueryParameterTemplateTransform(QueryStringTransformMode.Set, queryKey, valueTemplate));
+ return context;
+ }
+
///
/// Clones the route and adds the transform that will remove the given query key.
///
diff --git a/src/ReverseProxy/Transforms/QueryTransformFactory.cs b/src/ReverseProxy/Transforms/QueryTransformFactory.cs
index 401eb0d2b..ec0abda5a 100644
--- a/src/ReverseProxy/Transforms/QueryTransformFactory.cs
+++ b/src/ReverseProxy/Transforms/QueryTransformFactory.cs
@@ -11,6 +11,7 @@ internal sealed class QueryTransformFactory : ITransformFactory
{
internal const string QueryValueParameterKey = "QueryValueParameter";
internal const string QueryRouteParameterKey = "QueryRouteParameter";
+ internal const string QueryParameterKey = "QueryParameter";
internal const string QueryRemoveParameterKey = "QueryRemoveParameter";
internal const string AppendKey = "Append";
internal const string SetKey = "Set";
@@ -33,6 +34,14 @@ public bool Validate(TransformRouteValidationContext context, IReadOnlyDictionar
context.Errors.Add(new ArgumentException($"Unexpected parameters for QueryRouteParameter: {string.Join(';', transformValues.Keys)}. Expected 'Append' or 'Set'."));
}
}
+ else if (transformValues.TryGetValue(QueryParameterKey, out var queryParameter))
+ {
+ TransformHelpers.TryCheckTooManyParameters(context, transformValues, expected: 2);
+ if (!transformValues.TryGetValue(SetKey, out var _) || transformValues.TryGetValue(AppendKey, out var _))
+ {
+ context.Errors.Add(new ArgumentException($"Unexpected parameters for QueryParameter: {string.Join(';', transformValues.Keys)}. Expected 'Set'."));
+ }
+ }
else if (transformValues.TryGetValue(QueryRemoveParameterKey, out var removeQueryParameter))
{
TransformHelpers.TryCheckTooManyParameters(context, transformValues, expected: 1);
@@ -79,6 +88,18 @@ public bool Build(TransformBuilderContext context, IReadOnlyDictionary(requestTransform);
+ Assert.Equal(key, queryParameterTemplateTransform.Key);
+ Assert.Equal(template, queryParameterTemplateTransform.Template);
+ Assert.Equal(QueryStringTransformMode.Set, queryParameterTemplateTransform.Mode);
+ }
+
[Theory]
[InlineData(false)]
[InlineData(true)]