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)]