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); + } }