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";