File: YarpConfigurationBuilder.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 System.Security.Authentication;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Yarp.ReverseProxy.Configuration;
 
namespace Aspire.Hosting.Yarp;
 
internal sealed class YarpConfigurationBuilder : IYarpConfigurationBuilder
{
    private string? _configFilePath;
    private readonly List<ClusterConfig> _clusterConfigs = new List<ClusterConfig>();
    private readonly List<RouteConfig> _routeConfigs = new List<RouteConfig>();
    private readonly JsonSerializerOptions _serializerOptions;
 
    public YarpConfigurationBuilder()
    {
        _serializerOptions = new JsonSerializerOptions()
        {
            WriteIndented = true,
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        };
        _serializerOptions.Converters.Add(new SslProtocolsConverter());
        _serializerOptions.Converters.Add(new JsonStringEnumConverter(new PascalCaseJsonNamingPolicy()));
    }
 
    public IYarpConfigurationBuilder AddCluster(ClusterConfig cluster)
    {
        if (_configFilePath != null)
        {
            throw new ArgumentException("Configuring programmatically clusters while providing a configuration file isn't supported");
        }
        _clusterConfigs.Add(cluster);
        return this;
    }
 
    public IYarpConfigurationBuilder AddRoute(RouteConfig route)
    {
        if (_configFilePath != null)
        {
            throw new ArgumentException("Configuring programmatically routes while providing a configuration file isn't supported");
        }
        _routeConfigs.Add(route);
        return this;
    }
 
    public IYarpConfigurationBuilder WithConfigFile(string configFilePath)
    {
        if (_clusterConfigs.Count > 0 || _routeConfigs.Count > 0)
        {
            throw new ArgumentException("Providing a configuration file isn't supported when configuring routes and clusters programmatically");
        }
        _configFilePath = configFilePath;
        return this;
    }
 
    public async ValueTask<string> Build(CancellationToken ct)
    {
        if (_configFilePath != null)
        {
            try
            {
                return await File.ReadAllTextAsync(_configFilePath, ct).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                throw new DistributedApplicationException($"Error when reading the YARP config file '{_configFilePath}'", ex);
            }
        }
        else
        {
            if (_clusterConfigs.Count == 0 || _routeConfigs.Count == 0)
            {
                // TODO: build dynamically the config file if none provided.
                throw new DistributedApplicationException($"No configuration provided for YARP instance");
            }
 
            // Ideally the json generation should be done in YARP directly,
            // keep it in Aspire for now
            var jsonObject = new JsonObject();
            var jsonProxyConfig = jsonObject["ReverseProxy"] = new JsonObject();
            jsonProxyConfig["Clusters"] = AddClusters();
            jsonProxyConfig["Routes"] = AddRoutes();
            // TODO Validate the configuration
            var content = JsonSerializer.Serialize(jsonObject, _serializerOptions);
 
            return content;
        }
    }
 
    private JsonObject AddRoutes()
    {
        var routesNode = new JsonObject();
 
        foreach (var route in _routeConfigs)
        {
            var node = JsonSerializer.SerializeToNode(route, _serializerOptions);
            node?.AsObject().Remove(nameof(RouteConfig.RouteId));
            routesNode[route.RouteId] = node;
        }
 
        return routesNode;
    }
 
    private JsonObject AddClusters()
    {
        var routesNode = new JsonObject();
 
        foreach (var cluster in _clusterConfigs)
        {
            var node = JsonSerializer.SerializeToNode(cluster, _serializerOptions);
            node?.AsObject().Remove(nameof(ClusterConfig.ClusterId));
            routesNode[cluster.ClusterId] = node;
        }
 
        return routesNode;
    }
 
    /// <summary>
    /// Convert SslProtocols to an array of strings
    /// </summary>
    public sealed class SslProtocolsConverter : JsonConverter<SslProtocols>
    {
        public override SslProtocols Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            // We don't need to deserialize
            throw new NotImplementedException();
        }
 
        public override void Write(Utf8JsonWriter writer, SslProtocols value, JsonSerializerOptions options)
        {
            writer.WriteStartArray();
            foreach (var protocol in Enum.GetValues<SslProtocols>())
            {
                if (protocol != SslProtocols.None)
                {
                    if ((value & protocol) == protocol)
                    {
                        writer.WriteStringValue(protocol.ToString());
                    }
                }
            }
            writer.WriteEndArray();
        }
    }
 
    public sealed class PascalCaseJsonNamingPolicy : JsonNamingPolicy
    {
        public override string ConvertName(string name)
        {
            if (string.IsNullOrEmpty(name) || !char.IsAsciiLetterUpper(name[0]))
            {
                throw new ArgumentException("Invalid parameter", nameof(name));
            }
 
            return name;
        }
    }
}