Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@
<PackageReference Include="MessagePack" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="CommunityToolkit.Aspire.Hosting.RavenDB.Tests" />
</ItemGroup>

</Project>
4 changes: 4 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.RavenDB/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ var myService = builder.AddProject<Projects.MyService>()
.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

<!-- TODO: Update the link once it is created -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,65 @@ public static IResourceBuilder<RavenDBDatabaseResource> 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<ResourceEndpointsAllocatedEvent>(
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<ResourceUrlAnnotation>()
.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<ResourceNotificationService>();
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) =>
Expand Down Expand Up @@ -286,6 +345,26 @@ public static IResourceBuilder<RavenDBDatabaseResource> 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}";
}

/// <summary>
/// Adds a bind mount for the data folder to a RavenDB container resource.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ public class AppHostTests(AspireIntegrationTestFixture<Projects.CommunityToolkit
[Fact]
public async Task TestAppHost()
{
using var cancellationToken = new CancellationTokenSource();
cancellationToken.CancelAfter(TimeSpan.FromMinutes(5));
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMinutes(5));

var connectionName = "ravendb";
var databaseName = "ravenDatabase";

await fixture.ResourceNotificationService.WaitForResourceAsync(connectionName, KnownResourceStates.Running, cancellationToken.Token).WaitAsync(TimeSpan.FromMinutes(5), cancellationToken.Token);
await fixture.ResourceNotificationService.WaitForResourceAsync(connectionName, KnownResourceStates.Running, cts.Token).WaitAsync(TimeSpan.FromMinutes(5), cts.Token);

var endpoint = fixture.GetEndpoint(connectionName, "http");
Assert.NotNull(endpoint);
Expand All @@ -29,12 +29,12 @@ public async Task TestAppHost()
var serverResource = Assert.Single(appModel.Resources.OfType<RavenDBServerResource>());
var dbResource = Assert.Single(appModel.Resources.OfType<RavenDBDatabaseResource>());

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);
Expand All @@ -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<dynamic>("Test/1", cancellationToken.Token);
var doc = await session.LoadAsync<dynamic>("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<DistributedApplicationModel>();
var dbResource = Assert.Single(appModel.Resources.OfType<RavenDBDatabaseResource>());

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<ResourceUrlAnnotation>(),
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);
}
}