diff --git a/src/CommunityToolkit.Aspire.Hosting.RavenDB/CommunityToolkit.Aspire.Hosting.RavenDB.csproj b/src/CommunityToolkit.Aspire.Hosting.RavenDB/CommunityToolkit.Aspire.Hosting.RavenDB.csproj
index b8322ff13..3387212d1 100644
--- a/src/CommunityToolkit.Aspire.Hosting.RavenDB/CommunityToolkit.Aspire.Hosting.RavenDB.csproj
+++ b/src/CommunityToolkit.Aspire.Hosting.RavenDB/CommunityToolkit.Aspire.Hosting.RavenDB.csproj
@@ -11,4 +11,8 @@
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.RavenDB/README.md b/src/CommunityToolkit.Aspire.Hosting.RavenDB/README.md
index 42038d7c4..f17c98e7f 100644
--- a/src/CommunityToolkit.Aspire.Hosting.RavenDB/README.md
+++ b/src/CommunityToolkit.Aspire.Hosting.RavenDB/README.md
@@ -23,6 +23,10 @@ var myService = builder.AddProject()
.WithReference(db);
```
+### Open databases in RavenDB Studio
+
+Every database added with `AddDatabase(...)` exposes a clickable **RavenDB Studio** link in the Aspire dashboard that opens that database's Documents view directly (for example `http://localhost:9534/studio/index.html#databases/documents?&database=mydb`). The link is added automatically — no extra configuration is required. For secured servers it uses the configured public server URL.
+
## Additional documentation
diff --git a/src/CommunityToolkit.Aspire.Hosting.RavenDB/RavenDBBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.RavenDB/RavenDBBuilderExtensions.cs
index 8817ae0c8..a54349d78 100644
--- a/src/CommunityToolkit.Aspire.Hosting.RavenDB/RavenDBBuilderExtensions.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.RavenDB/RavenDBBuilderExtensions.cs
@@ -255,6 +255,65 @@ public static IResourceBuilder AddDatabase(this IResour
var dbBuilder = builder.ApplicationBuilder.AddResource(databaseResource);
+ // Wire an "RavenDB Studio" deep-link onto the database child resource.
+ // The database has no endpoints of its own, so its own URL pipeline never runs; build the link
+ // from the parent server's primary endpoint (or its public URL when secured), which is only
+ // known once the server's endpoints are allocated. The parent server is a container, so its
+ // ResourceEndpointsAllocatedEvent fires (the child's would not).
+ builder.ApplicationBuilder.Eventing.Subscribe(
+ builder.Resource,
+ async (@event, ct) =>
+ {
+ var server = databaseResource.Parent;
+
+ string? baseUrl;
+ if (server.IsSecured && !string.IsNullOrEmpty(server.PublicServerUrl))
+ baseUrl = server.PublicServerUrl;
+ else if (server.PrimaryEndpoint.IsAllocated)
+ // The primary endpoint's scheme may be forced to "tcp" (see ForceTcpScheme), which is not a
+ // browser-navigable link. Normalize it to http/https (based on IsSecured) while keeping the
+ // allocated host and port, e.g. http://localhost:9534.
+ baseUrl = NormalizeToHttpBaseUrl(server.PrimaryEndpoint.Url, server.IsSecured);
+ else
+ return; // nothing to build a link from yet — no-op
+
+ var studioUrl = BuildStudioUrl(baseUrl, databaseResource.DatabaseName);
+
+ // Endpoints can be (re)allocated more than once (e.g. on a server restart, possibly with a
+ // different port), so this handler must be idempotent: drop any previous "RavenDB Studio"
+ // link before re-adding, in both the annotations and the snapshot, instead of accumulating
+ // duplicates and leaving stale URLs behind.
+
+ // (1) Annotation — discoverable via TryGetUrls and assertable in tests.
+ foreach (var stale in databaseResource.Annotations
+ .OfType()
+ .Where(u => u.DisplayText == StudioDisplayText)
+ .ToArray())
+ {
+ databaseResource.Annotations.Remove(stale);
+ }
+
+ databaseResource.Annotations.Add(new ResourceUrlAnnotation
+ {
+ Url = studioUrl,
+ DisplayText = StudioDisplayText
+ });
+
+ // (2) Snapshot update — the dashboard renders from the snapshot, and the child's initial
+ // snapshot was published before the server endpoints were allocated.
+ var notifications = @event.Services.GetRequiredService();
+ await notifications.PublishUpdateAsync(databaseResource, snapshot =>
+ {
+ var urls = snapshot.Urls
+ .RemoveAll(u => u.DisplayProperties.DisplayName == StudioDisplayText)
+ .Add(new UrlSnapshot(Name: null, Url: studioUrl, IsInternal: false)
+ {
+ DisplayProperties = new UrlDisplayPropertiesSnapshot(StudioDisplayText, 0)
+ });
+ return snapshot with { Urls = urls };
+ }).ConfigureAwait(false);
+ });
+
if (ensureCreated)
{
dbBuilder.OnResourceReady(async (resource, _, ct) =>
@@ -286,6 +345,26 @@ public static IResourceBuilder AddDatabase(this IResour
return dbBuilder;
}
+ // Display text shared by the "RavenDB Studio" URL annotation and its snapshot entry; also used as the
+ // key to de-duplicate them when endpoints are re-allocated.
+ private const string StudioDisplayText = "RavenDB Studio";
+
+ internal static string BuildStudioUrl(string baseUrl, string databaseName)
+ {
+ var root = baseUrl.TrimEnd('/');
+ return $"{root}/studio/index.html#databases/documents?&database={Uri.EscapeDataString(databaseName)}";
+ }
+
+ // Rebuilds an allocated endpoint URL with an http/https scheme, preserving host and port. The primary
+ // endpoint may use a non-HTTP scheme (e.g. "tcp" when ForceTcpScheme is set), which is not a valid
+ // browser link for the Studio.
+ internal static string NormalizeToHttpBaseUrl(string endpointUrl, bool isSecured)
+ {
+ var uri = new Uri(endpointUrl);
+ var scheme = isSecured ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
+ return $"{scheme}://{uri.Authority}";
+ }
+
///
/// Adds a bind mount for the data folder to a RavenDB container resource.
///
diff --git a/tests/CommunityToolkit.Aspire.Hosting.RavenDB.Tests/AddRavenDBTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RavenDB.Tests/AddRavenDBTests.cs
index 4cb612cf2..e9575bb01 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.RavenDB.Tests/AddRavenDBTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.RavenDB.Tests/AddRavenDBTests.cs
@@ -216,4 +216,22 @@ public void CanAddRavenServerWithLogBindMount()
Assert.Equal(hostLogPath, logMount.Source);
Assert.Equal("/var/log/ravendb/logs", logMount.Target);
}
+
+ [Theory]
+ [InlineData("http://localhost:9534", "ravenDatabase",
+ "http://localhost:9534/studio/index.html#databases/documents?&database=ravenDatabase")]
+ [InlineData("http://localhost:9534/", "ravenDatabase", // trailing slash trimmed
+ "http://localhost:9534/studio/index.html#databases/documents?&database=ravenDatabase")]
+ [InlineData("https://my.domain", "My DB", // secured base + name needing escaping
+ "https://my.domain/studio/index.html#databases/documents?&database=My%20DB")]
+ public void BuildStudioUrl_ComposesExpectedLink(string baseUrl, string databaseName, string expected)
+ => Assert.Equal(expected, global::Aspire.Hosting.RavenDBBuilderExtensions.BuildStudioUrl(baseUrl, databaseName));
+
+ [Theory]
+ [InlineData("tcp://localhost:9534", false, "http://localhost:9534")] // ForceTcpScheme -> normalized to http
+ [InlineData("tcp://localhost:9534", true, "https://localhost:9534")] // ForceTcpScheme + secured -> https
+ [InlineData("http://localhost:9534", false, "http://localhost:9534")] // already http -> unchanged
+ [InlineData("https://localhost:9534", true, "https://localhost:9534")] // already https -> unchanged
+ public void NormalizeToHttpBaseUrl_ForcesBrowserNavigableScheme(string endpointUrl, bool isSecured, string expected)
+ => Assert.Equal(expected, global::Aspire.Hosting.RavenDBBuilderExtensions.NormalizeToHttpBaseUrl(endpointUrl, isSecured));
}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.RavenDB.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RavenDB.Tests/AppHostTests.cs
index 7279cac94..b967dff31 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.RavenDB.Tests/AppHostTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.RavenDB.Tests/AppHostTests.cs
@@ -12,13 +12,13 @@ public class AppHostTests(AspireIntegrationTestFixture());
var dbResource = Assert.Single(appModel.Resources.OfType());
- var serverConnectionString = await serverResource.ConnectionStringExpression.GetValueAsync(cancellationToken.Token);
+ var serverConnectionString = await serverResource.ConnectionStringExpression.GetValueAsync(cts.Token);
Assert.False(string.IsNullOrWhiteSpace(serverConnectionString));
Assert.Contains(endpoint.OriginalString, serverConnectionString);
Assert.Equal(databaseName, dbResource.DatabaseName);
- var databaseConnectionString = await dbResource.ConnectionStringExpression.GetValueAsync(cancellationToken.Token);
+ var databaseConnectionString = await dbResource.ConnectionStringExpression.GetValueAsync(cts.Token);
Assert.False(string.IsNullOrWhiteSpace(databaseConnectionString));
Assert.Equal($"URL={endpoint.OriginalString};Database={databaseName}", databaseConnectionString);
Assert.Equal(databaseName, dbResource.DatabaseName);
@@ -56,15 +56,54 @@ public async Task TestAppHost()
using (var session = documentStore.OpenAsyncSession())
{
- await session.StoreAsync(new { Id = "Test/1", Name = "Test Document" }, cancellationToken.Token);
- await session.SaveChangesAsync(cancellationToken.Token);
+ await session.StoreAsync(new { Id = "Test/1", Name = "Test Document" }, cts.Token);
+ await session.SaveChangesAsync(cts.Token);
}
using (var session = documentStore.OpenAsyncSession())
{
- var doc = await session.LoadAsync("Test/1", cancellationToken.Token);
+ var doc = await session.LoadAsync("Test/1", cts.Token);
Assert.NotNull(doc);
Assert.Equal("Test Document", doc.Name.ToString());
}
}
+
+ [Fact]
+ public async Task DatabaseResourceHasStudioUrl()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.CancelAfter(TimeSpan.FromMinutes(5));
+
+ var serverName = "ravendb";
+ var databaseResourceName = "ravenDatabase";
+
+ // The database becomes healthy only after the server's endpoints are allocated, which is when
+ // the "RavenDB Studio" URL annotation is added to the database resource.
+ await fixture.ResourceNotificationService
+ .WaitForResourceHealthyAsync(databaseResourceName)
+ .WaitAsync(TimeSpan.FromMinutes(5), cts.Token);
+
+ var appModel = fixture.App.Services.GetRequiredService();
+ var dbResource = Assert.Single(appModel.Resources.OfType());
+
+ var serverEndpoint = fixture.GetEndpoint(serverName, "http"); // e.g. http://localhost:9534
+ Assert.NotNull(serverEndpoint);
+
+ // Deep-link uses the physical database name (URL-escaped), not the resource name, so assert against
+ // the actual DatabaseName to stay correct even when AddDatabase(name, databaseName: ...) differ.
+ var expectedUrl =
+ $"{serverEndpoint.OriginalString.TrimEnd('/')}/studio/index.html#databases/documents?&database={Uri.EscapeDataString(dbResource.DatabaseName)}";
+
+ var studioUrl = Assert.Single(dbResource.Annotations.OfType(),
+ u => u.DisplayText == "RavenDB Studio");
+ Assert.Equal(expectedUrl, studioUrl.Url);
+
+ // The dashboard renders links from the resource snapshot (published via PublishUpdateAsync), not the
+ // annotation, so assert the URL actually reached the snapshot — otherwise the link could be absent
+ // from the dashboard even though the annotation is present.
+ Assert.True(fixture.ResourceNotificationService.TryGetCurrentState(databaseResourceName, out var resourceEvent));
+ var snapshotUrl = Assert.Single(resourceEvent!.Snapshot.Urls,
+ u => u.DisplayProperties.DisplayName == "RavenDB Studio");
+ Assert.Equal(expectedUrl, snapshotUrl.Url);
+ }
}