diff --git a/playground/TestShop/TestShop.AppHost/Program.cs b/playground/TestShop/TestShop.AppHost/Program.cs index 9ea61680a14..47337132290 100644 --- a/playground/TestShop/TestShop.AppHost/Program.cs +++ b/playground/TestShop/TestShop.AppHost/Program.cs @@ -66,7 +66,12 @@ .WithExternalHttpEndpoints() .WithReference(basketService) .WithReference(catalogService) - .WithUrls(c => c.Urls.ForEach(u => u.DisplayText = $"Online store ({u.Endpoint?.EndpointName})")); + // Modify the display text of the URLs + .WithUrls(c => c.Urls.ForEach(u => u.DisplayText = $"Online store ({u.Endpoint?.EndpointName})")) + // Don't show the non-HTTPS link on the resources page (details only) + .WithUrlForEndpoint("http", url => url.DisplayLocation = UrlDisplayLocation.DetailsOnly) + // Add health relative URL (show in details only) + .WithUrlForEndpoint("https", ep => new() { Url = "/health", DisplayText = "Health", DisplayLocation = UrlDisplayLocation.DetailsOnly }); var _ = frontend.GetEndpoint("https").Exists ? frontend.WithHttpsHealthCheck("/health") : frontend.WithHttpHealthCheck("/health"); diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor index 1164597b1d8..8fce4c7bfbb 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor @@ -74,17 +74,25 @@ ResizeType="DataGridResizeType.Discrete" Style="width:100%" RowSize="DataGridRowSize.Medium" - GridTemplateColumns="1fr 1.5fr" + GridTemplateColumns="1fr 1fr 0.5fr" ShowHover="true"> - - + - + + + + @vm.Text; } - // If the URL and text are the same, render a link for the URL - else if (string.Equals(vm.Url, vm.Text, StringComparison.Ordinal)) + // Otherwise, render a link for the URL + else { if (highlighting) { @@ -301,6 +309,17 @@ } return @@vm.Url; } + } + + private static RenderFragment RenderTextValue(DisplayedUrl vm, string filter) + { + var highlighting = !string.IsNullOrEmpty(filter) && vm.Text != "-"; + + // If there's no URL, e.g. this is a tcp:// URI, show nothing, or URL is same as Text, then show nothing + if (vm.Url is null || string.Equals(vm.Url, vm.Text, StringComparison.Ordinal)) + { + return @; + } // Otherwise, render a link with the text as the anchor text & title as the URL else { diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs index 47f7cc04403..08a0ecb54d4 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs @@ -511,11 +511,20 @@ public static string LabelUnset { } /// - /// Looks up a localized string similar to Link. + /// Looks up a localized string similar to Address. /// - public static string LinkColumnHeader { + public static string LinkAddressColumnHeader { get { - return ResourceManager.GetString("LinkColumnHeader", resourceCulture); + return ResourceManager.GetString("LinkAddressColumnHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Text. + /// + public static string LinkTextColumnHeader { + get { + return ResourceManager.GetString("LinkTextColumnHeader", resourceCulture); } } diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.resx b/src/Aspire.Dashboard/Resources/ControlsStrings.resx index 311963fc2a2..1afbcc9d5af 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.resx +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.resx @@ -466,8 +466,8 @@ Endpoint name - - Link + + Address No endpoints @@ -484,4 +484,7 @@ Capture paused + + Text + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf index 0b95da285bb..83a6ef0a4ae 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf @@ -252,9 +252,14 @@ (nenastaveno) - - Link - Propojit + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf index aea1a606b02..f1bed3a9671 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf @@ -252,9 +252,14 @@ (Nicht festgelegt) - - Link - Link + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf index 10e457f292d..a0fa13d5ba9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf @@ -252,9 +252,14 @@ (Anular) - - Link - Vínculo + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf index ec4deb3ef9a..8a8468358dd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf @@ -252,9 +252,14 @@ (Annuler) - - Link - Lien + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf index fd912f80756..0e6ff3397ca 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf @@ -252,9 +252,14 @@ (Annulla) - - Link - Collega + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf index 6aa84870349..1faeeefbf76 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf @@ -252,9 +252,14 @@ (設定解除) - - Link - リンク + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf index a78e28c3845..cd88d94c11d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf @@ -252,9 +252,14 @@ (설정 해제) - - Link - 링크 + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf index cc739c721b6..6ecbf3d1752 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf @@ -252,9 +252,14 @@ (Cofnij ustawienie) - - Link - Link + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf index f38b39ff1ef..21a255700e6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf @@ -252,9 +252,14 @@ (Remover definição) - - Link - Link + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf index 9da5397e14d..c06ed3ab5aa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf @@ -252,9 +252,14 @@ (Удалить) - - Link - Ссылка + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf index c4839ba2e71..514a78385ca 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf @@ -252,9 +252,14 @@ (Ayarı Kaldır) - - Link - Bağlantı + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf index 492f00985d8..07fc2177ce6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf @@ -252,9 +252,14 @@ (取消设置) - - Link - 链接 + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf index 20bafd37c2a..d18dce11a09 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf @@ -252,9 +252,14 @@ (取消設定) - - Link - 連結 + + Address + Address + + + + Text + Text diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs index 05a0b042f22..4d0a859fced 100644 --- a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs @@ -172,11 +172,11 @@ public sealed record ResourceStateSnapshot(string Text, string? Style) public sealed record EnvironmentVariableSnapshot(string Name, string? Value, bool IsFromSpec); /// -/// A snapshot of the url. +/// A snapshot of the URL. /// -/// Name of the endpoint associated with the url. -/// The full uri. -/// Determines if this url is internal. +/// Name of the endpoint associated with the URL. +/// The full URL. +/// Determines if this URL is internal. Internal URLs are only shown in the details grid for a resource. [DebuggerDisplay("{Url}", Name = "{Name}")] public sealed record UrlSnapshot(string? Name, string Url, bool IsInternal) { diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceUrlAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceUrlAnnotation.cs index 453fd54b21c..e2b8f01e90c 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceUrlAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceUrlAnnotation.cs @@ -30,4 +30,38 @@ public sealed class ResourceUrlAnnotation : IResourceAnnotation /// The display order the URL. Higher values mean sort higher in the list. /// public int? DisplayOrder; + + /// + /// Locations where this URL should be shown on the dashboard. Defaults to . + /// + public UrlDisplayLocation DisplayLocation { get; set; } = UrlDisplayLocation.SummaryAndDetails; + + internal bool IsInternal => DisplayLocation == UrlDisplayLocation.DetailsOnly; + + internal ResourceUrlAnnotation WithEndpoint(EndpointReference endpoint) + { + return new() + { + Url = Url, + DisplayText = DisplayText, + Endpoint = endpoint, + DisplayOrder = DisplayOrder, + DisplayLocation = DisplayLocation + }; + } +} + +/// +/// Specifies where the URL should be displayed. +/// +public enum UrlDisplayLocation +{ + /// + /// Show the URL in locations where either the resource summary or resource details are being displayed. + /// + SummaryAndDetails, + /// + /// Show the URL in locations where the full details of the resource are being displayed. + /// + DetailsOnly } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceUrlsCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/ResourceUrlsCallbackContext.cs index a3d73c1b8d1..eeb3ccbeae9 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceUrlsCallbackContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceUrlsCallbackContext.cs @@ -20,6 +20,19 @@ public class ResourceUrlsCallbackContext(DistributedApplicationExecutionContext /// public IResource Resource { get; } = resource; + /// + /// Gets an endpoint reference from for the specified endpoint name.
+ /// If does not implement then returns null. + ///
+ /// + /// + public EndpointReference? GetEndpoint(string name) => + Resource switch + { + IResourceWithEndpoints resourceWithEndpoints => resourceWithEndpoints.GetEndpoint(name), + _ => null + }; + /// /// Gets the URLs associated with the callback context. /// diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index 2a59c60a329..4a0a97f0975 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -210,7 +210,7 @@ private ImmutableArray GetUrls(CustomResource resource, string? res var activeEndpoint = _resourceState.EndpointsMap.SingleOrDefault(e => e.Value.Spec.ServiceName == serviceName && e.Value.Metadata.OwnerReferences?.Any(or => or.Kind == resource.Kind && or.Name == name) == true).Value; var isInactive = activeEndpoint is null; - urls.Add(new(Name: endpointUrl.Endpoint!.EndpointName, Url: endpointUrl.Url, IsInternal: false) { IsInactive = isInactive, DisplayProperties = new(endpointUrl.DisplayText ?? "", endpointUrl.DisplayOrder ?? 0) }); + urls.Add(new(Name: endpointUrl.Endpoint!.EndpointName, Url: endpointUrl.Url, IsInternal: endpointUrl.IsInternal) { IsInactive = isInactive, DisplayProperties = new(endpointUrl.DisplayText ?? "", endpointUrl.DisplayOrder ?? 0) }); } } @@ -218,7 +218,7 @@ private ImmutableArray GetUrls(CustomResource resource, string? res var resourceRunning = string.Equals(resourceState, KnownResourceStates.Running, StringComparisons.ResourceState); foreach (var url in nonEndpointUrls) { - urls.Add(new(Name: null, Url: url.Url, IsInternal: false) { IsInactive = !resourceRunning, DisplayProperties = new(url.DisplayText ?? "", url.DisplayOrder ?? 0) }); + urls.Add(new(Name: null, Url: url.Url, IsInternal: url.IsInternal) { IsInactive = !resourceRunning, DisplayProperties = new(url.DisplayText ?? "", url.DisplayOrder ?? 0) }); } } diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index b967a592a3d..b37b512d247 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -200,6 +200,18 @@ private async Task ProcessUrls(IResource resource, CancellationToken cancellatio } } + // Convert relative endpoint URLs to absolute URLs + foreach (var url in urls) + { + if (url.Endpoint is { } endpoint) + { + if (url.Url.StartsWith('/') && endpoint.AllocatedEndpoint is { } allocatedEndpoint) + { + url.Url = allocatedEndpoint.UriString.TrimEnd('/') + url.Url; + } + } + } + // Add URLs foreach (var url in urls) { diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 97725ed11c3..be1e31bc49f 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -859,13 +859,17 @@ public static IResourceBuilder WithUrl(this IResourceBuilder builder, R /// The . /// /// - /// Use this method to customize the URL that is automatically added for an endpoint on the resource. + /// Use this method to customize the URL that is automatically added for an endpoint on the resource.
+ /// To add another URL for an endpoint, use . ///
/// /// The callback will be executed after endpoints have been allocated and the URL has been generated.
/// This allows you to modify the URL or its display text. ///
/// + /// If the URL returned by is relative, it will be combined with the endpoint URL to create an absolute URL. + /// + /// /// If the endpoint with the specified name does not exist, the callback will not be executed and a warning will be logged. /// ///
@@ -876,6 +880,13 @@ public static IResourceBuilder WithUrl(this IResourceBuilder builder, R /// .WithUrlForEndpoint("https", url => url.DisplayText = "Home"); /// /// + /// + /// Customize the URL for the "https" endpoint to deep to the "/home" path: + /// + /// var frontend = builder.AddProject<Projects.Frontend>("frontend") + /// .WithUrlForEndpoint("https", url => url.Url = "/home"); + /// + /// public static IResourceBuilder WithUrlForEndpoint(this IResourceBuilder builder, string endpointName, Action callback) where T : IResource { @@ -895,6 +906,53 @@ public static IResourceBuilder WithUrlForEndpoint(this IResourceBuilder return builder; } + /// + /// Registers a callback to add a URL for the endpoint with the specified name. + /// + /// The resource type. + /// The builder for the resource. + /// The name of the endpoint to add the URL for. + /// The callback that will create the URL. + /// The . + /// + /// + /// Use this method to add another URL for an endpoint on the resource.
+ /// To customize the URL that is automatically added for an endpoint, use . + ///
+ /// + /// The callback will be executed after endpoints have been allocated and the resource is about to start. + /// + /// + /// If the endpoint with the specified name does not exist, the callback will not be executed and a warning will be logged. + /// + ///
+ /// + /// Add a URL for the "https" endpoint that deep-links to an admin page with the text "Admin": + /// + /// var frontend = builder.AddProject<Projects.Frontend>("frontend") + /// .WithUrlForEndpoint("https", ep => new() { Url = "/admin", DisplayText = "Admin" }); + /// + /// + public static IResourceBuilder WithUrlForEndpoint(this IResourceBuilder builder, string endpointName, Func callback) + where T : IResourceWithEndpoints + { + builder.WithUrls(context => + { + var endpoint = builder.GetEndpoint(endpointName); + if (endpoint.Exists) + { + var url = callback(endpoint).WithEndpoint(endpoint); + context.Urls.Add(url); + } + else + { + context.Logger.LogWarning("Could not execute callback to add an endpoint URL as no endpoint with name '{EndpointName}' could be found on resource '{ResourceName}'.", endpointName, builder.Resource.Name); + } + }); + + return builder; + } + /// /// Excludes a resource from being published to the manifest. /// diff --git a/tests/Aspire.Hosting.Tests/WithUrlsTests.cs b/tests/Aspire.Hosting.Tests/WithUrlsTests.cs index b6cb59bcbac..23912a04805 100644 --- a/tests/Aspire.Hosting.Tests/WithUrlsTests.cs +++ b/tests/Aspire.Hosting.Tests/WithUrlsTests.cs @@ -414,7 +414,56 @@ public async Task NonEndpointUrlsAreInactiveUntilResourceRunning() } [Fact] - public async Task WithUrlForEndpointDoesNotThrowOrCallCallbackIfEndpointNotFound() + public async Task UrlsAreMarkedAsInternalDependingOnDisplayLocation() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddProject("servicea") + .WithUrls(c => + { + c.Urls.Add(new() { Url = "http://example.com/", DisplayLocation = UrlDisplayLocation.SummaryAndDetails }); + c.Urls.Add(new() { Url = "http://example.com/internal", DisplayLocation = UrlDisplayLocation.DetailsOnly }); + c.Urls.Add(new() { Url = "http://example.com/out-of-range", DisplayLocation = (UrlDisplayLocation)100 }); + }); + + var app = await builder.BuildAsync(); + + var rns = app.Services.GetRequiredService(); + ImmutableArray urlSnapshot = default; + var cts = new CancellationTokenSource(); + var watchTask = Task.Run(async () => + { + await foreach (var notification in rns.WatchAsync(cts.Token).WithCancellation(cts.Token)) + { + if (string.Equals(notification.Snapshot.State?.Text, KnownResourceStates.Running)) + { + if (notification.Snapshot.Urls.Length > 1 && urlSnapshot == default) + { + urlSnapshot = notification.Snapshot.Urls; + break; + } + } + } + }); + + await app.StartAsync(); + + await rns.WaitForResourceAsync("servicea", KnownResourceStates.Running).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await watchTask.DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + cts.Cancel(); + + await app.StopAsync(); + + Assert.Collection(urlSnapshot, + url => { Assert.Equal("http", url.Name); Assert.False(url.IsInternal); }, + url => { Assert.Equal("http://example.com/", url.Url); Assert.False(url.IsInternal); }, + url => { Assert.Equal("http://example.com/internal", url.Url); Assert.True(url.IsInternal); }, + url => { Assert.Equal("http://example.com/out-of-range", url.Url); Assert.False(url.IsInternal); } + ); + } + + [Fact] + public async Task WithUrlForEndpointUpdateDoesNotThrowOrCallCallbackIfEndpointNotFound() { using var builder = TestDistributedApplicationBuilder.Create(); @@ -442,6 +491,129 @@ public async Task WithUrlForEndpointDoesNotThrowOrCallCallbackIfEndpointNotFound await app.StopAsync(); } + [Fact] + public async Task WithUrlForEndpointAddDoesNotThrowOrCallCallbackIfEndpointNotFound() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var called = false; + var projectA = builder.AddProject("projecta") + .WithHttpEndpoint(name: "test") + .WithUrlForEndpoint("non-existant", ep => + { + called = true; + return new() { Url = "https://example.com" }; + }); + + var tcs = new TaskCompletionSource(); + builder.Eventing.Subscribe(projectA.Resource, (e, ct) => + { + tcs.SetResult(); + return Task.CompletedTask; + }); + + var app = await builder.BuildAsync(); + await app.StartAsync(); + await tcs.Task; + + Assert.False(called); + + await app.StopAsync(); + } + + [Fact] + public async Task WithUrlForEndpointUpdateTurnsRelativeUrlIntoAbsoluteUrl() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta") + .WithHttpEndpoint(name: "test") + .WithUrlForEndpoint("test", url => + { + url.Url = "/sub-path"; + }); + + var tcs = new TaskCompletionSource(); + builder.Eventing.Subscribe(projectA.Resource, (e, ct) => + { + tcs.SetResult(); + return Task.CompletedTask; + }); + + var app = await builder.BuildAsync(); + await app.StartAsync(); + await tcs.Task; + + var endpointUrl = projectA.Resource.Annotations.OfType().FirstOrDefault(u => u.Endpoint?.EndpointName == "test"); + + Assert.NotNull(endpointUrl); + Assert.True(endpointUrl.Url.StartsWith("http://localhost") && endpointUrl.Url.EndsWith("/sub-path")); + + await app.StopAsync(); + } + + [Fact] + public async Task WithUrlForEndpointAddTurnsRelativeUrlIntoAbsoluteUrl() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta") + .WithHttpEndpoint(name: "test") + .WithUrlForEndpoint("test", ep => + { + return new() { Url = "/sub-path" }; + }); + + var tcs = new TaskCompletionSource(); + builder.Eventing.Subscribe(projectA.Resource, (e, ct) => + { + tcs.SetResult(); + return Task.CompletedTask; + }); + + var app = await builder.BuildAsync(); + await app.StartAsync(); + await tcs.Task; + + var endpointUrl = projectA.Resource.Annotations.OfType().FirstOrDefault(u => u.Endpoint?.EndpointName == "test" && u.Url.EndsWith("/sub-path")); + + Assert.NotNull(endpointUrl); + Assert.True(endpointUrl.Url.StartsWith("http://localhost") && endpointUrl.Url.EndsWith("/sub-path")); + + await app.StopAsync(); + } + + [Fact] + public async Task WithUrlsTurnsRelativeEndpointUrlsIntoAbsoluteUrls() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta") + .WithHttpEndpoint(name: "test") + .WithUrls(c => + { + c.Urls.Add(new() { Endpoint = c.GetEndpoint("test"), Url = "/sub-path" }); + }); + + var tcs = new TaskCompletionSource(); + builder.Eventing.Subscribe(projectA.Resource, (e, ct) => + { + tcs.SetResult(); + return Task.CompletedTask; + }); + + var app = await builder.BuildAsync(); + await app.StartAsync(); + await tcs.Task; + + var endpointUrl = projectA.Resource.Annotations.OfType().FirstOrDefault(u => u.Endpoint?.EndpointName == "test" && u.Url.EndsWith("/sub-path")); + + Assert.NotNull(endpointUrl); + Assert.True(endpointUrl.Url.StartsWith("http://localhost") && endpointUrl.Url.EndsWith("/sub-path")); + + await app.StopAsync(); + } + private sealed class ProjectA : IProjectMetadata { public string ProjectPath => "projectA";