File: Annotations\RadiusRecipeParametersAnnotation.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;
 
namespace Aspire.Hosting.Radius.Annotations;
 
/// <summary>
/// Per-environment annotation that carries the recipe parameters declared via
/// <c>WithRecipeParameters</c>. Holds an environment-wide parameter set applied to
/// every recipe entry, plus parameter sets scoped to individual Radius resource
/// types. The annotation is per-resource and is never shared between environments
/// (FR-010); repeated declarations for the same scope merge with last-write-wins
/// per key (FR-016).
/// </summary>
internal sealed class RadiusRecipeParametersAnnotation : IResourceAnnotation
{
    /// <summary>
    /// Parameters applied to every recipe entry in the environment's recipe pack.
    /// Ordinal-keyed.
    /// </summary>
    public Dictionary<string, object> EnvironmentWide { get; } = new(StringComparer.Ordinal);
 
    /// <summary>
    /// Parameters scoped to a specific Radius resource type (e.g.
    /// <c>Radius.Data/redisCaches</c>). Outer key is the resource type string,
    /// forwarded verbatim.
    /// </summary>
    public Dictionary<string, Dictionary<string, object>> ByResourceType { get; } = new(StringComparer.Ordinal);
 
    /// <summary>
    /// Returns the singleton <see cref="RadiusRecipeParametersAnnotation"/> on
    /// <paramref name="resource"/>, creating and attaching one if absent.
    /// </summary>
    internal static RadiusRecipeParametersAnnotation GetOrAdd(IResource resource)
    {
        ArgumentNullException.ThrowIfNull(resource);
 
        var existing = resource.Annotations.OfType<RadiusRecipeParametersAnnotation>().FirstOrDefault();
        if (existing is not null)
        {
            return existing;
        }
 
        var created = new RadiusRecipeParametersAnnotation();
        resource.Annotations.Add(created);
        return created;
    }
 
    /// <summary>
    /// Merges <paramref name="source"/> into <paramref name="target"/> with
    /// last-write-wins per key (FR-016). Empty/whitespace keys are rejected (FR-009).
    /// </summary>
    /// <param name="target">The destination parameter set for the relevant scope.</param>
    /// <param name="source">The newly supplied parameters from a configure callback.</param>
    /// <param name="parameterName">Argument name used for thrown exceptions.</param>
    internal static void Merge(
        Dictionary<string, object> target,
        IDictionary<string, object> source,
        string parameterName)
    {
        // Validate every key first, then apply. Mutating target as we validate would leave earlier
        // keys applied when a later key is blank; a caller that catches the exception and retries
        // would then publish parameters from a failed call. Validate-then-apply keeps the merge
        // transactional (all-or-nothing).
        foreach (var key in source.Keys)
        {
            if (string.IsNullOrWhiteSpace(key))
            {
                throw new ArgumentException(
                    "Recipe parameter keys must be non-empty and non-whitespace.",
                    parameterName);
            }
        }
 
        foreach (var (key, value) in source)
        {
            target[key] = value;
        }
    }
}