Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/designs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions src/ReverseProxy/ConfigurationSchema.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@
"QueryRouteParameter": { "not": {} },
"PathPattern": { "not": {} },
"QueryValueParameter": { "not": {} },
"QueryParameter": { "not": {} },
"QueryRemoveParameter": { "not": {} },
"HttpMethodChange": { "not": {} },
"RequestHeaderRouteValue": { "not": {} },
Expand Down Expand Up @@ -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.",
Expand Down
147 changes: 147 additions & 0 deletions src/ReverseProxy/Transforms/QueryParameterTemplateTransform.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Creates a query parameter value by substituting template tokens from route or query values.
/// </summary>
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; }

/// <inheritdoc/>
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<TemplateSegment>();
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;
}
}
}
23 changes: 23 additions & 0 deletions src/ReverseProxy/Transforms/QueryTransformExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ public static TransformBuilderContext AddQueryRouteValue(this TransformBuilderCo
return context;
}

/// <summary>
/// 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.
/// </summary>
public static RouteConfig WithTransformQueryParameter(this RouteConfig route, string queryKey, string valueTemplate)
{
return route.WithTransform(transform =>
{
transform[QueryTransformFactory.QueryParameterKey] = queryKey;
transform[QueryTransformFactory.SetKey] = valueTemplate;
});
}

/// <summary>
/// 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.
/// </summary>
public static TransformBuilderContext AddQueryParameter(this TransformBuilderContext context, string queryKey, string valueTemplate)
{
context.RequestTransforms.Add(new QueryParameterTemplateTransform(QueryStringTransformMode.Set, queryKey, valueTemplate));
return context;
}

/// <summary>
/// Clones the route and adds the transform that will remove the given query key.
/// </summary>
Expand Down
21 changes: 21 additions & 0 deletions src/ReverseProxy/Transforms/QueryTransformFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -79,6 +88,18 @@ public bool Build(TransformBuilderContext context, IReadOnlyDictionary<string, s
throw new NotSupportedException(string.Join(";", transformValues.Keys));
}
}
else if (transformValues.TryGetValue(QueryParameterKey, out var queryParameter))
{
TransformHelpers.CheckTooManyParameters(transformValues, expected: 2);
if (transformValues.TryGetValue(SetKey, out var setValue) && !transformValues.TryGetValue(AppendKey, out var _))
{
context.AddQueryParameter(queryParameter, setValue);
}
else
{
throw new NotSupportedException(string.Join(";", transformValues.Keys));
}
}
else if (transformValues.TryGetValue(QueryRemoveParameterKey, out var removeQueryParameter))
{
TransformHelpers.CheckTooManyParameters(transformValues, expected: 1);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Xunit;

namespace Yarp.ReverseProxy.Transforms.Tests;

public class QueryParameterTemplateTransformTests
{
[Fact]
public async Task Set_UsesRouteAndQueryValues()
{
var routeValues = new RouteValueDictionary
{
["remainder"] = "7/8",
["plugin"] = "dark"
};

var context = CreateContext(routeValues, new QueryString("?size=small"));
var transform = new QueryParameterTemplateTransform(QueryStringTransformMode.Set, "path", "img/{plugin}/{**remainder}/{size}");

await transform.ApplyAsync(context);

Assert.Equal("img/dark/7/8/small", context.Query.Collection["path"].ToString());
Assert.Equal("small", context.Query.Collection["size"].ToString());
}

[Fact]
public async Task Set_SkipsWhenTokenMissing()
{
var routeValues = new RouteValueDictionary
{
["remainder"] = "7/8"
};

var context = CreateContext(routeValues, QueryString.Empty);
var transform = new QueryParameterTemplateTransform(QueryStringTransformMode.Set, "path", "img/{missing}");

await transform.ApplyAsync(context);

Assert.False(context.Query.Collection.ContainsKey("path"));
Assert.Equal(QueryString.Empty, context.Query.QueryString);
}

[Fact]
public async Task Set_RouteValuesTakePrecedenceOverQuery()
{
var routeValues = new RouteValueDictionary
{
["value"] = "fromRoute"
};

var context = CreateContext(routeValues, new QueryString("?value=fromQuery"));
var transform = new QueryParameterTemplateTransform(QueryStringTransformMode.Set, "path", "{value}");

await transform.ApplyAsync(context);

Assert.Equal("fromRoute", context.Query.Collection["path"].ToString());
}

[Fact]
public async Task Set_CreatesExpectedPathAndQueryFromTemplate()
{
const string originalPath = "/img/cache/classifieds/photo.jpg";

var routeValues = new RouteValueDictionary
{
["category"] = "cache",
["remainder"] = "classifieds/photo.jpg"
};

var context = CreateContext(routeValues, QueryString.Empty, new PathString(originalPath));
var pathTransform = new PathStringTransform(PathStringTransform.PathTransformMode.Set, new PathString("/v1/weather/render"));
var queryTransform = new QueryParameterTemplateTransform(QueryStringTransformMode.Set, "path", "img/{category}/{remainder}");

await pathTransform.ApplyAsync(context);
await queryTransform.ApplyAsync(context);

Assert.Equal(new PathString("/v1/weather/render"), context.Path);
Assert.Equal("img/cache/classifieds/photo.jpg", context.Query.Collection["path"].ToString());
}

private static RequestTransformContext CreateContext(RouteValueDictionary routeValues, QueryString queryString, PathString? path = null)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues = routeValues;
httpContext.Request.QueryString = queryString;

return new RequestTransformContext
{
Path = path ?? httpContext.Request.Path,
Query = new QueryTransformContext(httpContext.Request),
HttpContext = httpContext
};
}
}
Loading