File: ConfigurationBuilder\YarpRoute.cs
Web Access
Project: src\src\Aspire.Hosting.Yarp\Aspire.Hosting.Yarp.csproj (Aspire.Hosting.Yarp)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Yarp.ReverseProxy.Configuration;
 
namespace Aspire.Hosting.Yarp;
 
/// <summary>
/// Represents a route for YARP
/// </summary>
[AspireExport]
public class YarpRoute
{
    // Testing only
    internal YarpRoute(RouteConfig routeConfig)
    {
        RouteConfig = routeConfig;
    }
 
    internal YarpRoute(YarpCluster cluster, string routeId)
    {
        RouteConfig = new RouteConfig
        {
            RouteId = routeId,
            ClusterId = cluster.ClusterConfig.ClusterId,
            Match = new RouteMatch(),
        };
    }
 
    internal RouteConfig RouteConfig { get; private set; }
 
    internal void Configure(Func<RouteConfig, RouteConfig> configure)
    {
        RouteConfig = configure(RouteConfig);
    }
}
 
/// <summary>
/// Provides extension methods for configuring a YARP destination
/// </summary>
public static class YarpRouteExtensions
{
    /// <summary>
    /// Set the parameters used to match requests.
    /// </summary>
    /// <remarks>This overload is not available in polyglot app hosts. Use the DTO-based overload or the specific match helper methods instead.</remarks>
    [AspireExportIgnore(Reason = "RouteMatch is not ATS-compatible. Use the DTO-based overload or the specific match helper methods instead.")]
    public static YarpRoute WithMatch(this YarpRoute route, RouteMatch match)
    {
        route.Configure(r => r with { Match = match });
        return route;
    }
 
    /// <summary>
    /// Set the parameters used to match requests.
    /// </summary>
    [AspireExport("withMatch", Description = "Sets the route match criteria.")]
    internal static YarpRoute WithMatch(this YarpRoute route, YarpRouteMatch match)
    {
        ArgumentNullException.ThrowIfNull(match);
 
        route.Configure(r => r with { Match = ToRouteMatch(match) });
        return route;
    }
 
    #region RouteMatch helpers
 
    private static YarpRoute ConfigureMatch(this YarpRoute route, Func<RouteMatch, RouteMatch> match)
    {
        route.Configure(r => r with { Match = match(r.Match) });
        return route;
    }
 
    /// <summary>
    /// Only match requests with the given Path pattern.
    /// </summary>
    [AspireExport("withMatchPath", Description = "Matches requests with the specified path pattern.")]
    public static YarpRoute WithMatchPath(this YarpRoute route, string path)
    {
        route.ConfigureMatch(match => match with { Path = path });
        return route;
    }
 
    /// <summary>
    /// Only match requests that use these optional HTTP methods. E.g. GET, POST.
    /// </summary>
    [AspireExport("withMatchMethods", Description = "Matches requests that use the specified HTTP methods.")]
    public static YarpRoute WithMatchMethods(this YarpRoute route, params string[] methods)
    {
        route.ConfigureMatch(match => match with { Methods = methods });
        return route;
    }
 
    /// <summary>
    /// Only match requests that contain all of these headers.
    /// </summary>
    /// <remarks>This overload is not available in polyglot app hosts. Use the DTO-based overload instead.</remarks>
    [AspireExportIgnore(Reason = "RouteHeader is not ATS-compatible. Use the DTO-based overload instead.")]
    public static YarpRoute WithMatchHeaders(this YarpRoute route, params RouteHeader[] headers)
    {
        route.ConfigureMatch(match => match with { Headers = headers.ToList() });
        return route;
    }
 
    /// <summary>
    /// Only match requests that contain all of these headers.
    /// </summary>
    [AspireExport("withMatchHeaders", Description = "Matches requests that contain the specified headers.")]
    internal static YarpRoute WithMatchHeaders(this YarpRoute route, params YarpRouteHeaderMatch[] headers)
    {
        ArgumentNullException.ThrowIfNull(headers);
 
        route.ConfigureMatch(match => match with { Headers = headers.Select(ToRouteHeader).ToList() });
        return route;
    }
 
    /// <summary>
    ///  Only match requests with the given Host header. Supports wildcards and ports.
    ///  For unicode host names, do not use punycode.
    /// </summary>
    [AspireExport("withMatchHosts", Description = "Matches requests that contain the specified host headers.")]
    public static YarpRoute WithMatchHosts(this YarpRoute route, params string[] hosts)
    {
        route.ConfigureMatch(match => match with { Hosts = hosts.ToList() });
        return route;
    }
 
    /// <summary>
    ///  Only match requests that contain all of these query parameters.
    /// </summary>
    /// <remarks>This overload is not available in polyglot app hosts. Use the DTO-based overload instead.</remarks>
    [AspireExportIgnore(Reason = "RouteQueryParameter is not ATS-compatible. Use the DTO-based overload instead.")]
    public static YarpRoute WithMatchRouteQueryParameter(this YarpRoute route, params RouteQueryParameter[] queryParameters)
    {
        route.ConfigureMatch(match => match with { QueryParameters = queryParameters.ToList() });
        return route;
    }
 
    /// <summary>
    ///  Only match requests that contain all of these query parameters.
    /// </summary>
    [AspireExport("withMatchRouteQueryParameter", Description = "Matches requests that contain the specified query parameters.")]
    internal static YarpRoute WithMatchRouteQueryParameter(this YarpRoute route, params YarpRouteQueryParameterMatch[] queryParameters)
    {
        ArgumentNullException.ThrowIfNull(queryParameters);
 
        route.ConfigureMatch(match => match with { QueryParameters = queryParameters.Select(ToRouteQueryParameter).ToList() });
        return route;
    }
 
    #endregion
 
    /// <summary>
    /// Set the order for the destination
    /// </summary>
    [AspireExport("withOrder", Description = "Sets the route order.")]
    public static YarpRoute WithOrder(this YarpRoute route, int? order)
    {
        route.Configure(r => r with { Order = order });
        return route;
    }
 
    /// <summary>
    /// Set the MaxRequestBodySize for the destination
    /// </summary>
    [AspireExport("withMaxRequestBodySize", Description = "Sets the maximum request body size for the route.")]
    public static YarpRoute WithMaxRequestBodySize(this YarpRoute route, long maxRequestBodySize)
    {
        route.Configure(r => r with { MaxRequestBodySize = maxRequestBodySize });
        return route;
    }
 
    /// <summary>
    /// Set the Metadata of the destination
    /// </summary>
    [AspireExport("withRouteMetadata", MethodName = "withMetadata", Description = "Sets metadata for the route.")]
    public static YarpRoute WithMetadata(this YarpRoute route, IReadOnlyDictionary<string, string>? metadata)
    {
        route.Configure(r => r with { Metadata = metadata });
        return route;
    }
 
    /// <summary>
    /// Set the Transforms of the destination
    /// </summary>
    [AspireExport("withTransforms", Description = "Sets the transforms for the route.")]
    public static YarpRoute WithTransforms(this YarpRoute route, IReadOnlyList<IReadOnlyDictionary<string, string>>? transforms)
    {
        route.Configure(r => r with { Transforms = transforms });
        return route;
    }
 
    /// <summary>
    /// Add a new transform to the destination
    /// </summary>
    /// <remarks>This method is not available in polyglot app hosts. Use <see cref="WithTransforms"/> or the transform-specific helpers instead.</remarks>
    [AspireExportIgnore(Reason = "Action<IDictionary<string, string>> callbacks are not ATS-compatible.")]
    public static YarpRoute WithTransform(this YarpRoute route, Action<IDictionary<string, string>> createTransform)
    {
        ArgumentNullException.ThrowIfNull(createTransform);
 
        route.Configure(r =>
        {
            var transform = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
            createTransform(transform);
            return AppendTransform(r, transform);
        });
 
        return route;
    }
 
    /// <summary>
    /// Add a new transform to the destination.
    /// </summary>
    [AspireExport("withTransform", Description = "Adds a transform to the route.")]
    internal static YarpRoute WithTransform(this YarpRoute route, IReadOnlyDictionary<string, string> transform)
    {
        ArgumentNullException.ThrowIfNull(transform);
 
        route.Configure(r =>
        {
            // Polyglot callbacks receive serialized values rather than a live mutable dictionary,
            // so ATS uses this value-based overload instead of the callback-based .NET API.
            var copiedTransform = new Dictionary<string, string>(transform, StringComparer.OrdinalIgnoreCase);
            return AppendTransform(r, copiedTransform);
        });
 
        return route;
    }
 
    private static RouteHeader ToRouteHeader(YarpRouteHeaderMatch header)
    {
        ArgumentNullException.ThrowIfNull(header);
 
        return new RouteHeader
        {
            Name = header.Name,
            Values = header.Values,
            IsCaseSensitive = header.IsCaseSensitive,
            Mode = header.Mode,
        };
    }
 
    private static RouteMatch ToRouteMatch(YarpRouteMatch match)
    {
        ArgumentNullException.ThrowIfNull(match);
 
        return new RouteMatch
        {
            Path = match.Path,
            Methods = match.Methods,
            Hosts = match.Hosts,
            Headers = match.Headers?.Select(ToRouteHeader).ToList(),
            QueryParameters = match.QueryParameters?.Select(ToRouteQueryParameter).ToList(),
        };
    }
 
    private static RouteQueryParameter ToRouteQueryParameter(YarpRouteQueryParameterMatch queryParameter)
    {
        ArgumentNullException.ThrowIfNull(queryParameter);
 
        return new RouteQueryParameter
        {
            Name = queryParameter.Name,
            Values = queryParameter.Values,
            IsCaseSensitive = queryParameter.IsCaseSensitive,
            Mode = queryParameter.Mode,
        };
    }
 
    private static RouteConfig AppendTransform(RouteConfig routeConfig, IReadOnlyDictionary<string, string> transform)
    {
        List<IReadOnlyDictionary<string, string>> transforms;
        if (routeConfig.Transforms is null)
        {
            transforms = new List<IReadOnlyDictionary<string, string>>();
        }
        else
        {
            transforms = new List<IReadOnlyDictionary<string, string>>(routeConfig.Transforms.Count + 1);
            transforms.AddRange(routeConfig.Transforms);
        }
 
        transforms.Add(transform);
 
        return routeConfig with { Transforms = transforms };
    }
}