File: ApplicationModel\ParameterResource.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.ExceptionServices;
using Aspire.Dashboard.Model;
using Aspire.Hosting.Resources;
using static Aspire.Hosting.Resources.MessageStrings;
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// Represents a parameter resource.
/// </summary>
[AspireExport]
public class ParameterResource : Resource, IExpressionValue
{
    private Lazy<string?> _lazyValue;
    private readonly Func<ParameterDefault?, string> _valueGetter;
    private readonly object _valueTaskLock = new();
    private string? _configurationKey;
    private bool _lazyValueWasMissing;
    private bool _required = true;
    private long _valueChangeVersion;
 
    /// <summary>
    /// Initializes a new instance of <see cref="ParameterResource"/>.
    /// </summary>
    /// <param name="name">The name of the parameter resource.</param>
    /// <param name="callback">The callback function to retrieve the value of the parameter.</param>
    /// <param name="secret">A flag indicating whether the parameter is secret.</param>
    public ParameterResource(string name, Func<ParameterDefault?, string> callback, bool secret = false) : base(name)
    {
        ArgumentNullException.ThrowIfNull(name);
        ArgumentNullException.ThrowIfNull(callback);
 
        _valueGetter = callback;
        _lazyValue = new Lazy<string?>(GetValue);
        Secret = secret;
    }
 
    private string? GetValue()
    {
        try
        {
            return _valueGetter(Default);
        }
        catch (MissingParameterValueException)
        {
            _lazyValueWasMissing = true;
            if (!Required)
            {
                return null;
            }
 
            throw;
        }
    }
 
    /// <summary>
    /// Gets the value of the parameter.
    /// </summary>
    /// <remarks>
    /// This property is obsolete. Use <see cref="GetValueAsync(CancellationToken)"/> for async access or pass the <see cref="ParameterResource"/> directly to methods that accept it (e.g., environment variables).
    /// </remarks>
    [Obsolete("Use GetValueAsync for async access or pass the ParameterResource directly to methods that accept it (e.g., environment variables).")]
    public string Value => GetValueAsync(default).AsTask().GetAwaiter().GetResult() ?? string.Empty;
 
    internal string? ValueInternal
    {
        get
        {
            // If the WaitForValueTcs has a set value then prefer it.
            var waitForValueTcs = WaitForValueTcs;
            if (waitForValueTcs?.Task is { IsCompleted: true } valueTask)
            {
                return valueTask.GetAwaiter().GetResult();
            }
 
            return _lazyValue.Value;
        }
    }
 
    /// <summary>
    /// Represents how the default value of the parameter should be retrieved.
    /// </summary>
    public ParameterDefault? Default { get; set; }
 
    /// <summary>
    /// Gets a value indicating whether the parameter is secret.
    /// </summary>
    public bool Secret { get; }
 
    /// <summary>
    /// Gets or sets a value indicating whether this parameter must have a value before it can be resolved.
    /// </summary>
    /// <remarks>
    /// Optional parameters that do not have values resolve to <see langword="null"/> from <see cref="GetValueAsync(CancellationToken)"/>.
    /// The obsolete <see cref="Value"/> property returns an empty string for unset optional parameters because its signature is non-nullable.
    /// </remarks>
    public bool Required
    {
        get => _required;
        set
        {
            ParameterResourceValueChangedEventArgs? eventArgs = null;
 
            lock (_valueTaskLock)
            {
                if (_required == value)
                {
                    return;
                }
 
                _required = value;
                if (_lazyValueWasMissing)
                {
                    _lazyValueWasMissing = false;
                    _lazyValue = new Lazy<string?>(GetValue);
                }
 
                // Once created, WaitForValueTcs is the authoritative value source. Reconcile missing
                // state here so requiredness changes also update existing waiters, prompts, and dashboard state.
                if (WaitForValueTcs is { } waitForValueTcs)
                {
                    if (!value && !waitForValueTcs.Task.IsCompleted)
                    {
                        // Complete the existing source rather than replacing it so current waiters are released.
                        waitForValueTcs.SetResult(null);
                        eventArgs = CreateValueChangedEventArgs(value: null);
                    }
                    else if (!value && IsMissingValueTask(waitForValueTcs.Task))
                    {
                        WaitForValueTcs = waitForValueTcs = CreateWaitForValueTcs();
                        waitForValueTcs.SetResult(null);
                        eventArgs = CreateValueChangedEventArgs(value: null);
                    }
                    else if (value && waitForValueTcs.Task is { IsCompletedSuccessfully: true, Result: null or "" })
                    {
                        WaitForValueTcs = waitForValueTcs = CreateWaitForValueTcs();
                        var missingValueException = CreateMissingValueException(value: null)!;
                        waitForValueTcs.SetException(missingValueException);
                        eventArgs = CreateValueChangedEventArgs(missingValueException);
                    }
                }
            }
 
            if (eventArgs is not null)
            {
                NotifyValueChangedInBackground(eventArgs);
            }
        }
    }
 
    /// <summary>
    /// Gets or sets a value indicating whether the parameter is a connection string.
    /// </summary>
    public bool IsConnectionString { get; set; }
 
    /// <summary>
    /// Gets the expression used in the manifest to reference the value of the parameter.
    /// </summary>
    public string ValueExpression => $"{{{Name}.value}}";
 
    /// <summary>
    /// The configuration key for this parameter. The default format is "ConnectionStrings:{Name}" if the parameter is a connection string,
    /// otherwise it is "Parameters:{Name}".
    /// </summary>
    internal string ConfigurationKey
    {
        get => _configurationKey ?? (IsConnectionString ? $"ConnectionStrings:{Name}" : $"Parameters:{Name}");
        set => _configurationKey = value;
    }
 
    internal TaskCompletionSource<string?>? WaitForValueTcs { get; set; }
 
    internal long ValueChangeVersion
    {
        get
        {
            lock (_valueTaskLock)
            {
                return _valueChangeVersion;
            }
        }
    }
 
    internal event Func<ParameterResource, ParameterResourceValueChangedEventArgs, CancellationToken, Task>? ValueChanged;
 
    // Record secrets synchronously before releasing value waiters. Asynchronous notifications can be
    // canceled or superseded, but every assigned value must remain redactable in lagging snapshots.
    internal event Action<ParameterResource, string?>? ValueChanging;
 
    /// <summary>
    /// Attempts to get the current value for this parameter without waiting for unresolved input.
    /// </summary>
    /// <param name="value">When this method returns <see langword="true"/>, contains the current parameter value.</param>
    /// <returns><see langword="true"/> if the parameter currently has a non-empty value; otherwise, <see langword="false"/>.</returns>
    /// <remarks>
    /// This method returns <see langword="false"/> for unresolved parameters, parameters with initialization errors, and optional
    /// parameters that resolved without a value.
    /// </remarks>
    [AspireExportIgnore(Reason = "Uses out parameter which is not ATS-compatible.")]
    public bool TryGetCurrentValue([NotNullWhen(true)] out string? value)
    {
        var waitForValueTcs = WaitForValueTcs;
        if (waitForValueTcs is not null)
        {
            var valueTask = waitForValueTcs.Task;
            if (!valueTask.IsCompletedSuccessfully)
            {
                value = null;
                return false;
            }
 
            value = valueTask.Result;
            return !string.IsNullOrEmpty(value);
        }
 
        try
        {
            value = _lazyValue.Value;
            return !string.IsNullOrEmpty(value);
        }
        catch (Exception)
        {
            // Try-pattern callers use this for command state and input defaults; value resolution
            // failures mean there is no usable current value, not that the command should fail.
            value = null;
            return false;
        }
    }
 
    /// <summary>
    /// Gets the current value for this parameter without waiting for unresolved input.
    /// </summary>
    /// <returns>The current parameter value, or <see langword="null"/> if the parameter does not currently have a non-empty value.</returns>
    /// <remarks>
    /// This method returns <see langword="null"/> for unresolved parameters, parameters with initialization errors, and optional
    /// parameters that resolved without a value.
    /// </remarks>
    [AspireExport("ParameterResource.tryGetCurrentValue", MethodName = "tryGetCurrentValue")]
    internal string? TryGetCurrentValue()
    {
        return TryGetCurrentValue(out var value) ? value : null;
    }
 
    /// <summary>
    /// Attempts to complete this parameter with the specified value.
    /// </summary>
    /// <param name="value">The value to use for this parameter.</param>
    /// <returns><see langword="true"/> if the parameter was completed with <paramref name="value"/> or a missing-value exception; otherwise, <see langword="false"/>.</returns>
    /// <remarks>
    /// This method follows <see cref="TaskCompletionSource{TResult}.TrySetResult(TResult)"/> semantics and returns
    /// <see langword="false"/> when the parameter has already completed. Required parameters completed with <see langword="null"/>
    /// or an empty string are faulted with <see cref="MissingParameterValueException"/>. Use <see cref="SetValueAsync(string?, CancellationToken)"/>
    /// when the value should replace an existing value and update dashboard state. To also save the value to user secrets
    /// in run mode, use <c>ParameterProcessor.SetValueAsync</c> with <c>saveToUserSecrets: true</c>.
    /// In publish mode, values are saved to deployment state during parameter initialization;
    /// setting a value does not itself persist it.
    /// </remarks>
    public bool TrySetValue(string? value)
    {
        ParameterResourceValueChangedEventArgs? eventArgs;
 
        lock (_valueTaskLock)
        {
            var waitForValueTcs = GetOrCreateWaitForValueTcs();
            if (waitForValueTcs.Task.IsCompleted)
            {
                return false;
            }
 
            var missingValueException = CreateMissingValueException(value);
            if (missingValueException is null)
            {
                ValueChanging?.Invoke(this, value);
            }
 
            var valueWasSet = missingValueException is null
                ? waitForValueTcs.TrySetResult(value)
                : waitForValueTcs.TrySetException(missingValueException);
 
            if (!valueWasSet)
            {
                return false;
            }
 
            eventArgs = missingValueException is null
                ? CreateValueChangedEventArgs(value)
                : CreateValueChangedEventArgs(missingValueException);
        }
 
        NotifyValueChangedInBackground(eventArgs);
        return true;
    }
 
    /// <summary>
    /// Attempts to complete this parameter with the specified exception.
    /// </summary>
    /// <param name="exception">The exception used to fault this parameter.</param>
    /// <returns><see langword="true"/> if the parameter was completed with <paramref name="exception"/>; otherwise, <see langword="false"/>.</returns>
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="exception"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// This method follows <see cref="TaskCompletionSource{TResult}.TrySetException(Exception)"/> semantics and returns
    /// <see langword="false"/> when the parameter has already completed.
    /// </remarks>
    public bool TrySetException(Exception exception)
    {
        ArgumentNullException.ThrowIfNull(exception);
 
        ParameterResourceValueChangedEventArgs? eventArgs;
 
        lock (_valueTaskLock)
        {
            var waitForValueTcs = GetOrCreateWaitForValueTcs();
            if (!waitForValueTcs.TrySetException(exception))
            {
                return false;
            }
 
            eventArgs = CreateValueChangedEventArgs(exception);
        }
 
        NotifyValueChangedInBackground(eventArgs);
        return true;
    }
 
    internal void EnsureValueTask()
    {
        lock (_valueTaskLock)
        {
            _ = GetOrCreateWaitForValueTcs();
        }
    }
 
    /// <summary>
    /// Sets or replaces the value for this parameter.
    /// </summary>
    /// <param name="value">The value to use for this parameter.</param>
    /// <param name="cancellationToken">The cancellation token to observe while applying the value.</param>
    /// <returns>A task that completes when the value has been applied and observers have processed the change.</returns>
    /// <remarks>
    /// Setting a value directly on the resource updates dashboard state after the resource has been observed by the host.
    /// It does not save the value to deployment state. Required parameters set to <see langword="null"/> or an empty string
    /// are faulted with <see cref="MissingParameterValueException"/>, while optional parameters can be cleared to <see langword="null"/>.
    /// Cancellation is checked before assignment. After assignment, cancellation does not roll back the value or
    /// prevent the host from synchronizing dashboard and parameter-resolution state, even if another observer reports cancellation.
    /// </remarks>
    [AspireExport("ParameterResource.setValueAsync", MethodName = "setValueAsync")]
    public Task SetValueAsync(string? value = null, CancellationToken cancellationToken = default)
    {
        return SetValueAsyncCore(value, expectedVersion: null, cancellationToken);
    }
 
    internal Task<bool> SetValueIfVersionAsync(string? value, long expectedVersion, CancellationToken cancellationToken)
    {
        return SetValueAsyncCore(value, expectedVersion, cancellationToken);
    }
 
    private async Task<bool> SetValueAsyncCore(string? value, long? expectedVersion, CancellationToken cancellationToken)
    {
        ParameterResourceValueChangedEventArgs eventArgs;
 
        lock (_valueTaskLock)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (expectedVersion is not null && expectedVersion != _valueChangeVersion)
            {
                return false;
            }
 
            var waitForValueTcs = GetOrCreateResettableWaitForValueTcs();
            var missingValueException = CreateMissingValueException(value);
            if (missingValueException is null)
            {
                ValueChanging?.Invoke(this, value);
                waitForValueTcs.SetResult(value);
                eventArgs = CreateValueChangedEventArgs(value);
            }
            else
            {
                waitForValueTcs.SetException(missingValueException);
                eventArgs = CreateValueChangedEventArgs(missingValueException);
            }
        }
 
        await NotifyValueChangedAsync(eventArgs, cancellationToken).ConfigureAwait(false);
        return true;
    }
 
    /// <summary>
    /// Sets or replaces the exception for this parameter.
    /// </summary>
    /// <param name="exception">The exception used to fault this parameter.</param>
    /// <param name="cancellationToken">The cancellation token to observe while applying the exception.</param>
    /// <returns>A task that completes when the exception has been applied and observers have processed the change.</returns>
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="exception"/> is <see langword="null"/>.</exception>
    /// <remarks>
    /// Cancellation is checked before assignment. After assignment, cancellation does not roll back the exception or
    /// prevent the host from synchronizing dashboard and parameter-resolution state, even if another observer reports cancellation.
    /// </remarks>
    public Task SetExceptionAsync(Exception exception, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(exception);
 
        ParameterResourceValueChangedEventArgs eventArgs;
 
        lock (_valueTaskLock)
        {
            cancellationToken.ThrowIfCancellationRequested();
            GetOrCreateResettableWaitForValueTcs().SetException(exception);
            eventArgs = CreateValueChangedEventArgs(exception);
        }
 
        return NotifyValueChangedAsync(eventArgs, cancellationToken);
    }
 
    private ParameterResourceValueChangedEventArgs CreateValueChangedEventArgs(string? value) =>
        new(++_valueChangeVersion, value, exception: null);
 
    private ParameterResourceValueChangedEventArgs CreateValueChangedEventArgs(Exception exception) =>
        new(++_valueChangeVersion, value: null, exception);
 
    private void NotifyValueChangedInBackground(ParameterResourceValueChangedEventArgs eventArgs)
    {
        if (ValueChanged is null)
        {
            return;
        }
 
        _ = ObserveValueChangedAsync(NotifyValueChangedAsync(eventArgs, CancellationToken.None));
    }
 
    private static async Task ObserveValueChangedAsync(Task notificationTask)
    {
        try
        {
            await notificationTask.ConfigureAwait(false);
        }
        catch
        {
            // TrySet* is synchronous and cannot surface asynchronous observer failures.
            // Await SetValueAsync or SetExceptionAsync when callers need those failures.
        }
    }
 
    private async Task NotifyValueChangedAsync(ParameterResourceValueChangedEventArgs eventArgs, CancellationToken cancellationToken)
    {
        var valueChanged = ValueChanged;
        if (valueChanged is null)
        {
            return;
        }
 
        var handlers = valueChanged.GetInvocationList();
        List<Exception>? exceptions = null;
        for (var i = 0; i < handlers.Length; i++)
        {
            var handler = (Func<ParameterResource, ParameterResourceValueChangedEventArgs, CancellationToken, Task>)handlers[i];
            try
            {
                await handler(this, eventArgs, cancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                // Notify every observer even if one throws so a single faulty subscriber cannot stop the
                // others (for example the dashboard updater) from observing the change. Failures are collected
                // and surfaced after all observers have run: awaited SetValueAsync/SetExceptionAsync callers
                // see them, while TrySet* swallows them via ObserveValueChangedAsync.
                (exceptions ??= []).Add(ex);
            }
        }
 
        if (exceptions is { Count: 1 })
        {
            // Preserve the original exception type and stack for the common single-observer case.
            ExceptionDispatchInfo.Capture(exceptions[0]).Throw();
        }
        else if (exceptions is { Count: > 1 })
        {
            throw new AggregateException(exceptions);
        }
    }
 
    private TaskCompletionSource<string?> GetOrCreateWaitForValueTcs()
    {
        return WaitForValueTcs ??= CreateWaitForValueTcs();
    }
 
    private TaskCompletionSource<string?> GetOrCreateResettableWaitForValueTcs()
    {
        var waitForValueTcs = GetOrCreateWaitForValueTcs();
        if (waitForValueTcs.Task.IsCompleted)
        {
            WaitForValueTcs = waitForValueTcs = CreateWaitForValueTcs();
        }
 
        return waitForValueTcs;
    }
 
    private static TaskCompletionSource<string?> CreateWaitForValueTcs() => new(TaskCreationOptions.RunContinuationsAsynchronously);
 
    private static bool IsMissingValueTask(Task<string?> task)
    {
        return task.Exception is { InnerExceptions: [MissingParameterValueException] };
    }
 
    private MissingParameterValueException? CreateMissingValueException(string? value)
    {
        if (!Required || !string.IsNullOrEmpty(value))
        {
            return null;
        }
 
        return new MissingParameterValueException($"Parameter resource '{Name}' requires a value.");
    }
 
    internal ResourcePropertySnapshot CreateValueSnapshotProperty(string value)
    {
        return new(KnownProperties.Parameter.Value, value)
        {
            IsSensitive = Secret,
            DisplayName = ResourcePropertyParameterValueDisplayName,
            IsHighlighted = true,
            SortOrder = 0
        };
    }
 
    /// <summary>
    /// Gets the value of the parameter asynchronously, waiting if necessary for the value to be set.
    /// </summary>
    /// <param name="cancellationToken">The cancellation token to observe while waiting for the value.</param>
    /// <returns>A task that represents the asynchronous operation, containing the value of the parameter.</returns>
    public async ValueTask<string?> GetValueAsync(CancellationToken cancellationToken)
    {
        if (WaitForValueTcs is not null)
        {
            // Wait for the value to be set if the task completion source is available.
            return await WaitForValueTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
        }
 
        // In publish mode, there's no WaitForValueTcs set.
        return ValueInternal;
    }
 
    /// <summary>
    /// Gets the value of the parameter asynchronously, waiting if necessary for the value to be set.
    /// </summary>
    public ValueTask<string?> GetValueAsync(ValueProviderContext _, CancellationToken cancellationToken)
    {
        // It might look like this does not provide any additional functionality over GetValueAsync,
        // but we need to ensure that types derived from ParameterResource implement IValueProvider.GetValueAsync(ValueProviderContext, CancellationToken)
        // using WaitForValueTcs, and not via an interface default implementation.
        return GetValueAsync(cancellationToken);
    }
 
    /// <summary>
    /// Gets a description of the parameter resource.
    /// </summary>
    public string? Description { get; set; }
 
    /// <summary>
    /// Gets or sets a value indicating whether the description should be rendered as Markdown.
    /// </summary>
    public bool EnableDescriptionMarkdown { get; set; }
 
    internal InteractionInput CreateInput(string? name = null, bool? required = null, InputLoadOptions? dynamicLoading = null)
    {
        if (this.TryGetLastAnnotation<InputGeneratorAnnotation>(out var annotation))
        {
            var generatedInput = annotation.InputGenerator(this);
            if (name is not null)
            {
                generatedInput.SetName(name);
            }
 
            // An explicit caller-provided value always wins. Otherwise only force the generated input to be
            // optional when the parameter itself is optional, so optional parameters are never presented as
            // required. For required parameters we preserve whatever the custom input generator chose (which
            // may be false, e.g. when the generator supplies a default value), so WithCustomInput behavior is
            // not silently changed.
            if (required is not null)
            {
                generatedInput.SetRequired(required.Value);
            }
            else if (!Required)
            {
                generatedInput.SetRequired(false);
            }
 
            if (dynamicLoading is not null)
            {
                generatedInput.SetDynamicLoading(dynamicLoading);
            }
 
            return generatedInput;
        }
 
        var input = new InteractionInput
        {
            Name = name ?? Name,
            InputType = Secret ? InputType.SecretText : InputType.Text,
            Label = Name,
            Description = Description,
            EnableDescriptionMarkdown = EnableDescriptionMarkdown,
            Required = required ?? Required,
            DynamicLoading = dynamicLoading,
            Placeholder = string.Format(CultureInfo.CurrentCulture, InteractionStrings.ParametersInputsParameterPlaceholder, Name)
        };
        return input;
    }
}
 
internal sealed class ParameterResourceValueChangedEventArgs(long version, string? value, Exception? exception)
{
    public long Version { get; } = version;
 
    public string? Value { get; } = value;
 
    public Exception? Exception { get; } = exception;
}