// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Aspire.Hosting.Testing;
/// <summary>
/// Extensions for working with <see cref="DistributedApplication"/> in test code.
/// </summary>
public static class DistributedApplicationHostingTestingExtensions
{
private const string DashboardResourceName = "aspire-dashboard";
/// <summary>
/// Gets the URL for the running Aspire dashboard.
/// </summary>
/// <param name="app">The distributed application.</param>
/// <param name="cancellationToken">A token used to cancel the operation.</param>
/// <returns>
/// An absolute <see cref="Uri"/> that can be opened in a browser. The URL authenticates the browser when
/// authentication is enabled and otherwise points directly to the dashboard.
/// </returns>
/// <remarks>
/// <para>
/// Enable dashboard support with <see cref="DistributedApplicationTestingBuilderOptions.EnableDashboard"/> when creating
/// the testing builder.
/// </para>
/// <para>
/// This method does not start the distributed application. Call <see cref="DistributedApplication.StartAsync(CancellationToken)"/>
/// before requesting the dashboard URL.
/// </para>
/// <para>
/// This method waits for the dashboard resource to become healthy. Pass a cancellation token when the wait must
/// be bounded. When authentication is enabled, the returned URI contains an authentication credential and should
/// be treated as sensitive.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="app"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the application is in publish mode, the dashboard is disabled, the application has not started,
/// or a dashboard URL is unavailable.
/// </exception>
/// <exception cref="DistributedApplicationException">Thrown when the dashboard reaches a terminal failure state.</exception>
/// <exception cref="OperationCanceledException">Thrown when <paramref name="cancellationToken"/> is canceled.</exception>
/// <exception cref="ObjectDisposedException">Thrown when the distributed application has been disposed.</exception>
/// <example>
/// <code lang="csharp">
/// var options = new DistributedApplicationTestingBuilderOptions
/// {
/// EnableDashboard = true
/// };
///
/// var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.MyAppHost_AppHost>(options, []);
/// await using var app = await builder.BuildAsync();
/// await app.StartAsync();
///
/// var dashboardUrl = await app.GetDashboardUrlAsync();
/// </code>
/// </example>
[AspireExportIgnore(Reason = "Use the exported getDashboardUrl overload without a cancellation token.")]
public static async Task<Uri> GetDashboardUrlAsync(
this DistributedApplication app,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(app);
var executionContext = app.Services.GetRequiredService<DistributedApplicationExecutionContext>();
if (executionContext.IsPublishMode)
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlPublishModeExceptionMessage);
}
var applicationOptions = app.Services.GetRequiredService<DistributedApplicationOptions>();
if (applicationOptions.DisableDashboard)
{
throw new InvalidOperationException(Properties.Resources.DashboardDisabledExceptionMessage);
}
ThrowIfNotStarted(app, Properties.Resources.DashboardUrlApplicationNotStartedExceptionMessage);
cancellationToken.ThrowIfCancellationRequested();
await app.ResourceNotifications.WaitForResourceHealthyAsync(
DashboardResourceName,
WaitBehavior.StopOnResourceUnavailable,
cancellationToken).ConfigureAwait(false);
var applicationModel = app.Services.GetRequiredService<DistributedApplicationModel>();
if (!applicationModel.Resources.TryGetByName(DashboardResourceName, out var resource) ||
resource is not IResourceWithEndpoints dashboardResource)
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage);
}
var httpsEndpoint = dashboardResource.GetEndpoint("https");
var httpEndpoint = dashboardResource.GetEndpoint("http");
var dashboardEndpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint;
if (!dashboardEndpoint.Exists)
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage);
}
var dashboardUrl = await EndpointHostHelpers.GetUrlWithTargetHostAsync(dashboardEndpoint, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(dashboardUrl))
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage);
}
var browserToken = app.Services.GetRequiredService<IConfiguration>()["AppHost:BrowserToken"];
if (!string.IsNullOrEmpty(browserToken))
{
dashboardUrl = $"{dashboardUrl.TrimEnd('/')}/login?t={Uri.EscapeDataString(browserToken)}";
}
if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri))
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage);
}
return dashboardUri;
}
/// <summary>
/// Gets the URL for the running Aspire dashboard.
/// </summary>
/// <returns>
/// An absolute <see cref="Uri"/> that can be opened in a browser. When authentication is enabled, the URI
/// contains an authentication credential and should be treated as sensitive.
/// </returns>
[AspireExport("getDashboardUrl")]
internal static Task<Uri> GetDashboardUrlAsyncExport(this DistributedApplication app)
{
return app.GetDashboardUrlAsync(default);
}
/// <summary>
/// Creates an <see cref="HttpClient"/> configured to communicate with the specified resource.
/// </summary>
/// <param name="app">The application.</param>
/// <param name="resourceName">The resourceName of the resource.</param>
/// <param name="endpointName">The optional endpoint name. If none is specified, the "https" endpoint is preferred when available, falling back to "http".</param>
/// <remarks>This method is not available in polyglot app hosts.</remarks>
/// <returns>The <see cref="HttpClient"/>.</returns>
[AspireExportIgnore(Reason = "HttpClient is not ATS-compatible.")]
public static HttpClient CreateHttpClient(this DistributedApplication app, string resourceName, string? endpointName = default)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
var baseUri = GetEndpointUriStringCore(app, resourceName, endpointName);
var clientFactory = app.Services.GetRequiredService<IHttpClientFactory>();
var client = clientFactory.CreateClient();
client.BaseAddress = new(baseUri);
return client;
}
/// <summary>
/// Gets the connection string for the specified resource.
/// </summary>
/// <param name="app">The application.</param>
/// <param name="resourceName">The resource name.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/>.</param>
/// <remarks>This overload is not available in polyglot app hosts. Use the exported overload without a cancellation token instead.</remarks>
/// <returns>The connection string for the specified resource.</returns>
/// <exception cref="ArgumentException">The resource was not found or does not expose a connection string.</exception>
[AspireExportIgnore(Reason = "Use the exported getConnectionString overload without a cancellation token.")]
public static ValueTask<string?> GetConnectionStringAsync(this DistributedApplication app, string resourceName, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
var resource = GetResource(app, resourceName);
if (resource is not IResourceWithConnectionString resourceWithConnectionString)
{
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.ResourceDoesNotExposeConnectionStringExceptionMessage, resourceName), nameof(resourceName));
}
return resourceWithConnectionString.GetConnectionStringAsync(cancellationToken);
}
/// <summary>
/// Gets the connection string for the specified resource.
/// </summary>
/// <param name="app">The application.</param>
/// <param name="resourceName">The resource name.</param>
/// <returns>The connection string for the specified resource.</returns>
/// <exception cref="ArgumentException">The resource was not found or does not expose a connection string.</exception>
[AspireExport("getConnectionString")]
internal static Task<string?> GetConnectionStringAsyncExport(this DistributedApplication app, string resourceName)
{
return app.GetConnectionStringAsync(resourceName, default).AsTask();
}
/// <summary>
/// Gets the endpoint for the specified resource.
/// </summary>
/// <param name="app">The application.</param>
/// <param name="resourceName">The resource name.</param>
/// <param name="endpointName">The optional endpoint name. If none is specified, the "https" endpoint is preferred when available, falling back to "http".</param>
/// <returns>A URI representation of the endpoint.</returns>
/// <exception cref="ArgumentException">The resource was not found, no matching endpoint was found, or multiple endpoints were found.</exception>
/// <exception cref="InvalidOperationException">The resource has no endpoints.</exception>
[AspireExport]
public static Uri GetEndpoint(this DistributedApplication app, string resourceName, string? endpointName = default)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
return GetEndpointForNetwork(app, resourceName, null, endpointName);
}
/// <summary>
/// Gets the endpoint for the specified resource.
/// </summary>
/// <param name="app">The application.</param>
/// <param name="resourceName">The resource name.</param>
/// <param name="networkIdentifier">The optional network identifier. If none is specified, the default network is used.</param>
/// <param name="endpointName">The optional endpoint name. If none is specified, the "https" endpoint is preferred when available, falling back to "http".</param>
/// <remarks>This overload is not available in polyglot app hosts. Use the exported overload that accepts a network identifier string instead.</remarks>
/// <returns>A URI representation of the endpoint.</returns>
/// <exception cref="ArgumentException">The resource was not found, no matching endpoint was found, or multiple endpoints were found.</exception>
/// <exception cref="InvalidOperationException">The resource has no endpoints.</exception>
[AspireExportIgnore(Reason = "Use the ATS-friendly overload that accepts a network identifier string.")]
public static Uri GetEndpointForNetwork(this DistributedApplication app, string resourceName, NetworkIdentifier? networkIdentifier, string? endpointName = default)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentException.ThrowIfNullOrEmpty(resourceName);
return new(GetEndpointUriStringCore(app, resourceName, endpointName, networkIdentifier));
}
/// <summary>
/// Gets the endpoint for the specified resource in the specified network context.
/// </summary>
/// <param name="app">The application.</param>
/// <param name="resourceName">The resource name.</param>
/// <param name="networkIdentifier">The optional network identifier string. If none is specified, the default network is used.</param>
/// <param name="endpointName">The optional endpoint name. If none is specified, the "https" endpoint is preferred when available, falling back to "http".</param>
/// <returns>A URI representation of the endpoint.</returns>
/// <exception cref="ArgumentException">The resource was not found, no matching endpoint was found, or multiple endpoints were found.</exception>
/// <exception cref="InvalidOperationException">The resource has no endpoints.</exception>
[AspireExport]
internal static Uri GetEndpointForNetworkExport(this DistributedApplication app, string resourceName, string? networkIdentifier = default, string? endpointName = default)
{
return app.GetEndpointForNetwork(resourceName, networkIdentifier is null ? null : new NetworkIdentifier(networkIdentifier), endpointName);
}
static IResource GetResource(DistributedApplication app, string resourceName)
{
ThrowIfNotStarted(app, Properties.Resources.ApplicationNotStartedExceptionMessage);
var applicationModel = app.Services.GetRequiredService<DistributedApplicationModel>();
if (!applicationModel.Resources.TryGetByName(resourceName, out var resource))
{
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.ResourceNotFoundExceptionMessage, resourceName), nameof(resourceName));
}
return resource;
}
static string GetEndpointUriStringCore(DistributedApplication app, string resourceName, string? endpointName = default, NetworkIdentifier? networkIdentifier = default)
{
var resource = GetResource(app, resourceName);
if (resource is not IResourceWithEndpoints resourceWithEndpoints)
{
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.ResourceHasNoAllocatedEndpointsExceptionMessage, resourceName), nameof(resourceName));
}
EndpointReference? endpoint;
if (!string.IsNullOrEmpty(endpointName))
{
endpoint = GetEndpointOrDefault(resourceWithEndpoints, endpointName, networkIdentifier);
}
else
{
// Prefer https over http to match the default service discovery behavior (https+http://),
// where https is tried first.
endpoint = GetEndpointOrDefault(resourceWithEndpoints, "https", networkIdentifier) ?? GetEndpointOrDefault(resourceWithEndpoints, "http", networkIdentifier);
}
if (endpoint is null)
{
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.EndpointForResourceNotFoundExceptionMessage, endpointName, resourceName), nameof(endpointName));
}
return endpoint.Url;
}
static void ThrowIfNotStarted(DistributedApplication app, string exceptionMessage)
{
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
if (!lifetime.ApplicationStarted.IsCancellationRequested)
{
throw new InvalidOperationException(exceptionMessage);
}
}
static EndpointReference? GetEndpointOrDefault(IResourceWithEndpoints resourceWithEndpoints, string endpointName, NetworkIdentifier? networkIdentifier = default)
{
var reference = resourceWithEndpoints.GetEndpoint(endpointName, networkIdentifier ?? KnownNetworkIdentifiers.LocalhostNetwork);
return reference.IsAllocated ? reference : null;
}
}