File: ResourceMapping\ResourceTypeMapper.cs
Web Access
Project: src\src\Aspire.Hosting.Radius\Aspire.Hosting.Radius.csproj (Aspire.Hosting.Radius)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Radius.ResourceMapping;
 
/// <summary>
/// Maps Aspire resource types to Radius user-defined resource type (UDT) strings.
/// </summary>
/// <remarks>
/// <para>
/// Radius is migrating from built-in portable resource types (<c>Applications.*</c>) to
/// user-defined resource types (UDTs) under the <c>Radius.*</c> namespace. During the transition,
/// types that have a <see cref="RadiusTypeMapping.LegacyFallbackType"/> are emitted using the
/// legacy <c>Applications.*</c> type and API version. Once a Radius release promotes the UDT
/// to stable, the legacy fallback should be removed from the mapping entry.
/// </para>
/// </remarks>
internal sealed class ResourceTypeMapper
{
    private readonly ILogger _logger;
 
    /// <summary>
    /// Mapping entry containing the new Radius.* type, optional legacy fallback, and API version.
    /// </summary>
    internal readonly record struct RadiusTypeMapping(
        string RadiusType,
        string ApiVersion,
        string? LegacyFallbackType = null,
        string? LegacyApiVersion = null);
 
    /// <summary>
    /// Maps Aspire resource CLR type information keyed by the resource's <em>simple</em> type
    /// name (e.g. <c>"RedisResource"</c>), not its fully-qualified name. This is an intentional
    /// compatibility tradeoff: the mapped resource types live in optional hosting packages that
    /// this project deliberately does not reference, and keying by simple name tolerates a type
    /// moving assemblies or namespaces across Aspire versions. Fully-qualified names would also
    /// work as plain string keys, but would break silently on such a move.
    /// </summary>
    /// <remarks>
    /// Caveat: because lookup (see <see cref="GetMappingKey"/>) walks the inheritance chain
    /// comparing <c>Type.Name</c>, a third-party resource type that happens to share
    /// a simple name with an entry here (e.g. an unrelated <c>RedisResource</c> in another
    /// namespace/assembly) would be mapped to the corresponding Radius type. This is an accepted
    /// low-risk tradeoff for the no-assembly-reference design.
    /// </remarks>
    private static readonly Dictionary<string, RadiusTypeMapping> s_typeMappings = new(StringComparer.Ordinal)
    {
        // Resource types from optional hosting packages - referenced by string name
        // since those packages are not referenced by this project.
        ["RedisResource"] = new(
            RadiusResourceTypes.RedisCaches,
            RadiusResourceTypes.RadiusApiVersion,
            RadiusResourceTypes.LegacyRedisCaches,
            RadiusResourceTypes.LegacyApiVersion),
 
        ["SqlServerServerResource"] = new(
            RadiusResourceTypes.SqlDatabases,
            RadiusResourceTypes.RadiusApiVersion),
 
        ["PostgresServerResource"] = new(
            RadiusResourceTypes.PostgreSqlDatabases,
            RadiusResourceTypes.RadiusApiVersion),
 
        ["MongoDBServerResource"] = new(
            RadiusResourceTypes.MongoDatabases,
            RadiusResourceTypes.RadiusApiVersion,
            RadiusResourceTypes.LegacyMongoDatabases,
            RadiusResourceTypes.LegacyApiVersion),
 
        ["RabbitMQServerResource"] = new(
            RadiusResourceTypes.RabbitMQQueues,
            RadiusResourceTypes.RadiusApiVersion,
            RadiusResourceTypes.LegacyRabbitMQQueues,
            RadiusResourceTypes.LegacyApiVersion),
 
        // Core hosting types - these are in the Aspire.Hosting package
        ["ContainerResource"] = new(
            RadiusResourceTypes.Containers,
            RadiusResourceTypes.RadiusApiVersion),
 
        ["ProjectResource"] = new(
            RadiusResourceTypes.Containers,
            RadiusResourceTypes.RadiusApiVersion),
 
        // Dapr types
        ["DaprStateStoreResource"] = new(
            RadiusResourceTypes.DaprStateStores,
            RadiusResourceTypes.RadiusApiVersion,
            RadiusResourceTypes.LegacyDaprStateStores,
            RadiusResourceTypes.LegacyApiVersion),
 
        ["DaprPubSubResource"] = new(
            RadiusResourceTypes.DaprPubSubBrokers,
            RadiusResourceTypes.RadiusApiVersion,
            RadiusResourceTypes.LegacyDaprPubSubBrokers,
            RadiusResourceTypes.LegacyApiVersion),
    };
 
    private static readonly RadiusTypeMapping s_containerFallback = new(
        RadiusResourceTypes.Containers,
        RadiusResourceTypes.RadiusApiVersion);
 
    public ResourceTypeMapper(ILogger<ResourceTypeMapper> logger)
        : this((ILogger)logger)
    {
    }
 
    // Overload for callers that already have a non-generic ILogger (e.g., a pipeline-step
    // logger or a test FakeLogger). Allows mapper diagnostics to flow into the same logger
    // the caller uses, instead of forcing each call site to allocate a LoggerFactory.
    internal ResourceTypeMapper(ILogger logger)
    {
        _logger = logger;
    }
 
    /// <summary>
    /// Maps an Aspire resource to its Radius resource type string and API version.
    /// </summary>
    /// <remarks>
    /// Uses the new <c>Radius.*</c> namespace when available, falling back to legacy
    /// <c>Applications.*</c> types during the UDT migration. Unmapped resource types
    /// fall back to <c>Radius.Compute/containers</c>.
    /// </remarks>
    /// <param name="resource">The Aspire resource to map.</param>
    /// <returns>A tuple of (resourceType, apiVersion) for the Radius resource.</returns>
    public (string ResourceType, string ApiVersion) MapResource(IResource resource)
    {
        var resourceTypeName = GetMappingKey(resource);
 
        if (s_typeMappings.TryGetValue(resourceTypeName, out var mapping))
        {
            // If a legacy fallback exists, it means the Radius.* type is not yet fully migrated.
            // Use the legacy type. This is the prototype's expected v1 mapping for several types
            // (e.g. Redis, MongoDB, RabbitMQ, Dapr), so we log at Information rather than Warning
            // to avoid flooding the dashboard / CI logs with per-resource yellow noise. The README
            // documents which Aspire resources are mapped to legacy vs UDT.
            if (mapping.LegacyFallbackType is not null)
            {
                _logger.LogInformation(
                    "Resource '{ResourceName}' mapped to legacy type '{LegacyType}' (API {LegacyApiVersion}). " +
                    "New type '{RadiusType}' is pending migration.",
                    resource.Name,
                    mapping.LegacyFallbackType,
                    mapping.LegacyApiVersion,
                    mapping.RadiusType);
 
                return (mapping.LegacyFallbackType, mapping.LegacyApiVersion!);
            }
 
            return (mapping.RadiusType, mapping.ApiVersion);
        }
 
        // Unmapped type — fallback to Radius.Compute/containers
        _logger.LogWarning(
            "Resource '{ResourceName}' of type '{ResourceType}' has no Radius mapping; " +
            "falling back to '{FallbackType}'.",
            resource.Name,
            resource.GetType().Name,
            s_containerFallback.RadiusType);
 
        return (s_containerFallback.RadiusType, s_containerFallback.ApiVersion);
    }
 
    /// <summary>
    /// Returns <see langword="true"/> when <paramref name="resource"/> maps to a known
    /// non-compute Radius backing resource type (e.g. a cache, database, or queue).
    /// Returns <see langword="false"/> for compute workloads (project/container) and for
    /// resources with no Radius mapping (which would otherwise fall back to
    /// <see cref="RadiusResourceTypes.Containers"/>).
    /// </summary>
    /// <remarks>
    /// This is a side-effect-free probe; unlike <see cref="MapResource"/> it never logs, so
    /// callers do not emit the per-resource mapping diagnostics that belong to the publish path.
    /// </remarks>
    internal static bool IsBackingResource(IResource resource)
    {
        var key = GetMappingKey(resource);
        return s_typeMappings.TryGetValue(key, out var mapping)
            && !string.Equals(mapping.RadiusType, RadiusResourceTypes.Containers, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Gets the key used to look up the type mapping. Walks the type hierarchy to find the
    /// most specific match (e.g., <c>RedisResource</c> inherits from <c>ContainerResource</c>
    /// but should match as Redis).
    /// </summary>
    private static string GetMappingKey(IResource resource)
    {
        var type = resource.GetType();
 
        // Walk the inheritance chain to find the most specific mapped type
        while (type is not null)
        {
            if (s_typeMappings.ContainsKey(type.Name))
            {
                return type.Name;
            }
 
            type = type.BaseType;
        }
 
        return resource.GetType().Name;
    }
}