diff --git a/Aspire.sln b/Aspire.sln index 8478c71c52..f92f463b3c 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -423,12 +423,24 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Azure.Sql", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Azure.Storage", "src\Aspire.Hosting.Azure.Storage\Aspire.Hosting.Azure.Storage.csproj", "{89E9F2B8-662C-4FFA-8F69-360680362653}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Qdrant", "src\Aspire.Hosting.Qdrant\Aspire.Hosting.Qdrant.csproj", "{D3CDBA75-7707-4884-908D-1BA22B8DF8E7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "qdrant", "qdrant", "{A4800EE3-F902-4B7B-AF53-01A85514E6B9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Qdrant.AppHost", "playground\Qdrant\Qdrant.AppHost\Qdrant.AppHost.csproj", "{F43586B8-FE36-490D-9EFA-82CFFB83A304}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Qdrant.ApiService", "playground\Qdrant\Qdrant.ApiService\Qdrant.ApiService.csproj", "{6B6D3953-E961-4720-B27E-9466A69BED1A}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Azure.EventHubs", "src\Aspire.Hosting.Azure.EventHubs\Aspire.Hosting.Azure.EventHubs.csproj", "{2580B014-E7FE-48D9-BE40-E90604365F0E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Tests.SharedShim", "tests\Aspire.Hosting.Tests.SharedShim\Aspire.Hosting.Tests.SharedShim.csproj", "{74644A4D-8F61-4314-B6E8-5CE3802CD6C2}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Azure.KeyVault", "src\Aspire.Hosting.Azure.KeyVault\Aspire.Hosting.Azure.KeyVault.csproj", "{427F4D7C-8969-4015-AD1A-5EFFE921A184}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Qdrant.Client", "src\Components\Aspire.Qdrant.Client\Aspire.Qdrant.Client.csproj", "{E0E1B557-D3CF-4446-B993-E5CE719234FB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Qdrant.Client.Tests", "tests\Aspire.Qdrant.Client.Tests\Aspire.Qdrant.Client.Tests.csproj", "{A9CFA376-0C90-4231-9152-FBF14065195A}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Messaging.EventHubs.Tests", "tests\Aspire.Azure.Messaging.EventHubs.Tests\Aspire.Azure.Messaging.EventHubs.Tests.csproj", "{8191109E-130C-47F3-B84E-82070A6CD269}" EndProject Global @@ -437,6 +449,10 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A9CFA376-0C90-4231-9152-FBF14065195A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9CFA376-0C90-4231-9152-FBF14065195A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9CFA376-0C90-4231-9152-FBF14065195A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9CFA376-0C90-4231-9152-FBF14065195A}.Release|Any CPU.Build.0 = Release|Any CPU {B52DCF1A-465D-4E92-A68A-0EE1A9ED49DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B52DCF1A-465D-4E92-A68A-0EE1A9ED49DF}.Debug|Any CPU.Build.0 = Debug|Any CPU {B52DCF1A-465D-4E92-A68A-0EE1A9ED49DF}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -1113,6 +1129,18 @@ Global {89E9F2B8-662C-4FFA-8F69-360680362653}.Debug|Any CPU.Build.0 = Debug|Any CPU {89E9F2B8-662C-4FFA-8F69-360680362653}.Release|Any CPU.ActiveCfg = Release|Any CPU {89E9F2B8-662C-4FFA-8F69-360680362653}.Release|Any CPU.Build.0 = Release|Any CPU + {D3CDBA75-7707-4884-908D-1BA22B8DF8E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3CDBA75-7707-4884-908D-1BA22B8DF8E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3CDBA75-7707-4884-908D-1BA22B8DF8E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3CDBA75-7707-4884-908D-1BA22B8DF8E7}.Release|Any CPU.Build.0 = Release|Any CPU + {F43586B8-FE36-490D-9EFA-82CFFB83A304}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F43586B8-FE36-490D-9EFA-82CFFB83A304}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F43586B8-FE36-490D-9EFA-82CFFB83A304}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F43586B8-FE36-490D-9EFA-82CFFB83A304}.Release|Any CPU.Build.0 = Release|Any CPU + {6B6D3953-E961-4720-B27E-9466A69BED1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B6D3953-E961-4720-B27E-9466A69BED1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B6D3953-E961-4720-B27E-9466A69BED1A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B6D3953-E961-4720-B27E-9466A69BED1A}.Release|Any CPU.Build.0 = Release|Any CPU {2580B014-E7FE-48D9-BE40-E90604365F0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2580B014-E7FE-48D9-BE40-E90604365F0E}.Debug|Any CPU.Build.0 = Debug|Any CPU {2580B014-E7FE-48D9-BE40-E90604365F0E}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -1125,6 +1153,10 @@ Global {427F4D7C-8969-4015-AD1A-5EFFE921A184}.Debug|Any CPU.Build.0 = Debug|Any CPU {427F4D7C-8969-4015-AD1A-5EFFE921A184}.Release|Any CPU.ActiveCfg = Release|Any CPU {427F4D7C-8969-4015-AD1A-5EFFE921A184}.Release|Any CPU.Build.0 = Release|Any CPU + {E0E1B557-D3CF-4446-B993-E5CE719234FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0E1B557-D3CF-4446-B993-E5CE719234FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0E1B557-D3CF-4446-B993-E5CE719234FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0E1B557-D3CF-4446-B993-E5CE719234FB}.Release|Any CPU.Build.0 = Release|Any CPU {8191109E-130C-47F3-B84E-82070A6CD269}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8191109E-130C-47F3-B84E-82070A6CD269}.Debug|Any CPU.Build.0 = Debug|Any CPU {8191109E-130C-47F3-B84E-82070A6CD269}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -1134,6 +1166,7 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {A9CFA376-0C90-4231-9152-FBF14065195A} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {B52DCF1A-465D-4E92-A68A-0EE1A9ED49DF} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47} {E958BE04-81C2-434C-9E6C-CA145A2B8218} = {A68BA1A5-1604-433D-9778-DC0199831C2A} {C1D595AD-FFFD-4E52-AAF6-8DD8C4BD67F1} = {A68BA1A5-1604-433D-9778-DC0199831C2A} @@ -1330,9 +1363,14 @@ Global {CB7CAE39-F041-4B20-A0C4-D6F44920A647} = {77CFE74A-32EE-400C-8930-5025E8555256} {9FF853BD-FA56-4DA5-A50A-9867F2FAB1F0} = {77CFE74A-32EE-400C-8930-5025E8555256} {89E9F2B8-662C-4FFA-8F69-360680362653} = {77CFE74A-32EE-400C-8930-5025E8555256} + {D3CDBA75-7707-4884-908D-1BA22B8DF8E7} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47} + {A4800EE3-F902-4B7B-AF53-01A85514E6B9} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} + {F43586B8-FE36-490D-9EFA-82CFFB83A304} = {A4800EE3-F902-4B7B-AF53-01A85514E6B9} + {6B6D3953-E961-4720-B27E-9466A69BED1A} = {A4800EE3-F902-4B7B-AF53-01A85514E6B9} {2580B014-E7FE-48D9-BE40-E90604365F0E} = {77CFE74A-32EE-400C-8930-5025E8555256} {74644A4D-8F61-4314-B6E8-5CE3802CD6C2} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {427F4D7C-8969-4015-AD1A-5EFFE921A184} = {77CFE74A-32EE-400C-8930-5025E8555256} + {E0E1B557-D3CF-4446-B993-E5CE719234FB} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {8191109E-130C-47F3-B84E-82070A6CD269} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/Directory.Packages.props b/Directory.Packages.props index 27660f7e6b..383c504e5e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -97,6 +97,7 @@ + diff --git a/playground/Qdrant/Qdrant.ApiService/Program.cs b/playground/Qdrant/Qdrant.ApiService/Program.cs new file mode 100644 index 0000000000..589448e7a4 --- /dev/null +++ b/playground/Qdrant/Qdrant.ApiService/Program.cs @@ -0,0 +1,93 @@ +using Qdrant.Client; +using Qdrant.Client.Grpc; + +var builder = WebApplication.CreateBuilder(args); + +// Add service defaults & Aspire components. +builder.AddServiceDefaults(); + +// Add services to the container. +builder.Services.AddProblemDetails(); + +builder.AddQdrantClient("qdrant"); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +app.UseExceptionHandler(); + +app.MapGet("/create", async (QdrantClient client, ILogger logger) => +{ + var collections = await client.ListCollectionsAsync(); + if (collections.Any(x => x.Contains("movie_collection"))) + { + await client.DeleteCollectionAsync("movie_collection"); + } + + await client.CreateCollectionAsync("movie_collection", new VectorParams { Size = 2, Distance = Distance.Cosine }); + var collectionInfo = await client.GetCollectionInfoAsync("movie_collection"); + logger.LogInformation(collectionInfo.ToString()); + + // generate some vectors + var data = new[] + { + new PointStruct + { + Id = 1, + Vectors = new [] {0.10022575f, -0.23998135f}, + Payload = + { + ["title"] = "The Lion King" + } + }, + new PointStruct + { + Id = 2, + Vectors = new [] {0.10327095f, 0.2563685f}, + Payload = + { + ["title"] = "Inception" + } + }, + new PointStruct + { + Id = 3, + Vectors = new [] {0.095857024f, -0.201278f}, + Payload = + { + ["title"] = "Toy Story" + } + }, + new PointStruct + { + Id = 4, + Vectors = new [] {0.106827796f, 0.21676421f}, + Payload = + { + ["title"] = "Pulp Function" + } + }, + new PointStruct + { + Id = 5, + Vectors = new [] {0.09568083f, -0.21177962f}, + Payload = + { + ["title"] = "Shrek" + } + }, + }; + var updateResult = await client.UpsertAsync("movie_collection", data); + + return updateResult.Status; +}); + +app.MapGet("/search", async (QdrantClient client) => +{ + var results = await client.SearchAsync("movie_collection", new[] { 0.12217915f, -0.034832448f }, limit: 3); + return results.Select(titles => titles.Payload["title"].StringValue); +}); + +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/playground/Qdrant/Qdrant.ApiService/Properties/launchSettings.json b/playground/Qdrant/Qdrant.ApiService/Properties/launchSettings.json new file mode 100644 index 0000000000..a728d35665 --- /dev/null +++ b/playground/Qdrant/Qdrant.ApiService/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "https://localhost:5450;http://localhost:5451", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "http://localhost:5451", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/Qdrant/Qdrant.ApiService/Qdrant.ApiService.csproj b/playground/Qdrant/Qdrant.ApiService/Qdrant.ApiService.csproj new file mode 100644 index 0000000000..ffffb8fc49 --- /dev/null +++ b/playground/Qdrant/Qdrant.ApiService/Qdrant.ApiService.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + 1e49caab-af46-4c24-8011-953ec12b4069 + + + + + + + + diff --git a/playground/Qdrant/Qdrant.ApiService/appsettings.Development.json b/playground/Qdrant/Qdrant.ApiService/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/playground/Qdrant/Qdrant.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/Qdrant/Qdrant.ApiService/appsettings.json b/playground/Qdrant/Qdrant.ApiService/appsettings.json new file mode 100644 index 0000000000..1e74af8c8a --- /dev/null +++ b/playground/Qdrant/Qdrant.ApiService/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Qdrant.Client": "Debug" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/Qdrant/Qdrant.AppHost/Directory.Build.props b/playground/Qdrant/Qdrant.AppHost/Directory.Build.props new file mode 100644 index 0000000000..d9b2c324ac --- /dev/null +++ b/playground/Qdrant/Qdrant.AppHost/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/playground/Qdrant/Qdrant.AppHost/Directory.Build.targets b/playground/Qdrant/Qdrant.AppHost/Directory.Build.targets new file mode 100644 index 0000000000..29db89f209 --- /dev/null +++ b/playground/Qdrant/Qdrant.AppHost/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/playground/Qdrant/Qdrant.AppHost/Program.cs b/playground/Qdrant/Qdrant.AppHost/Program.cs new file mode 100644 index 0000000000..d7390fb4e2 --- /dev/null +++ b/playground/Qdrant/Qdrant.AppHost/Program.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +var qdrant = builder.AddQdrant("qdrant") + .WithDataVolume("qdrant_data"); + +builder.AddProject("apiservice") + .WithReference(qdrant); + +builder.Build().Run(); diff --git a/playground/Qdrant/Qdrant.AppHost/Properties/launchSettings.json b/playground/Qdrant/Qdrant.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..e75f3d87e9 --- /dev/null +++ b/playground/Qdrant/Qdrant.AppHost/Properties/launchSettings.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:15206;http://localhost:15207", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16022", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17038", + "DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15207", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16022", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17039", + "DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} diff --git a/playground/Qdrant/Qdrant.AppHost/Qdrant.AppHost.csproj b/playground/Qdrant/Qdrant.AppHost/Qdrant.AppHost.csproj new file mode 100644 index 0000000000..bf89e16e61 --- /dev/null +++ b/playground/Qdrant/Qdrant.AppHost/Qdrant.AppHost.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0 + enable + enable + true + 10c36641-05e0-4bfb-ad9d-a588431430f0 + + + + + + + + + + + + + + diff --git a/playground/Qdrant/Qdrant.AppHost/appsettings.Development.json b/playground/Qdrant/Qdrant.AppHost/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/playground/Qdrant/Qdrant.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/Qdrant/Qdrant.AppHost/appsettings.json b/playground/Qdrant/Qdrant.AppHost/appsettings.json new file mode 100644 index 0000000000..31c092aa45 --- /dev/null +++ b/playground/Qdrant/Qdrant.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/spelling.dic b/spelling.dic index fa2eff6f45..af6b38c5a5 100644 --- a/spelling.dic +++ b/spelling.dic @@ -45,6 +45,7 @@ pgadmin postgre postgres protoc +qdrant rabbitmq redis rediscommander diff --git a/src/Aspire.Hosting.Qdrant/Aspire.Hosting.Qdrant.csproj b/src/Aspire.Hosting.Qdrant/Aspire.Hosting.Qdrant.csproj new file mode 100644 index 0000000000..4b29d0c93d --- /dev/null +++ b/src/Aspire.Hosting.Qdrant/Aspire.Hosting.Qdrant.csproj @@ -0,0 +1,24 @@ + + + + $(NetCurrent) + true + aspire hosting qdrant + Qdrant vector database support for .NET Aspire. + $(SharedDir)QdrantLogo_256x.png + + + + + + + + + + + + + + + + diff --git a/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs b/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs new file mode 100644 index 0000000000..f2ca4ad989 --- /dev/null +++ b/src/Aspire.Hosting.Qdrant/QdrantBuilderExtensions.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Qdrant; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Qdrant resources to the application model. +/// +public static class QdrantBuilderExtensions +{ + private const int QdrantPortGrpc = 6334; + private const int QdrantPortHttp = 6333; + private const string ApiKeyEnvVarName = "QDRANT__SERVICE__API_KEY"; + private const string EnableStaticContentEnvVarName = "QDRANT__SERVICE__ENABLE_STATIC_CONTENT"; + + /// + /// Adds a Qdrant resource to the application. A container is used for local development. + /// + /// + /// This version the package defaults to the v1.8.3 tag of the qdrant/qdrant container image. + /// The .NET client library uses the gRPC port by default to communicate and this resource exposes that endpoint. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency + /// The parameter used to provide the API Key for the Qdrant resource. If a random key will be generated as {name}-Key. + /// The host port of Qdrant database. + /// A reference to the . + public static IResourceBuilder AddQdrant(this IDistributedApplicationBuilder builder, + string name, + IResourceBuilder? apiKey = null, + int? port = null) + { + var apiKeyParameter = apiKey?.Resource ?? + ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-Key", special: false); + var qdrant = new QdrantServerResource(name, apiKeyParameter); + return builder.AddResource(qdrant) + .WithImage(QdrantContainerImageTags.Image, QdrantContainerImageTags.Tag) + .WithHttpEndpoint(port: port, targetPort: QdrantPortGrpc, name: QdrantServerResource.PrimaryEndpointName) + .WithHttpEndpoint(port: port, targetPort: QdrantPortHttp, name: QdrantServerResource.RestEndpointName) + .WithEnvironment(context => + { + context.EnvironmentVariables[ApiKeyEnvVarName] = qdrant.ApiKeyParameter; + + // If in Publish mode, disable static content, which disables the Dashboard Web UI + // https://github.com/qdrant/qdrant/blob/acb04d5f0d22b46a756b31c0fc507336a0451c15/src/settings.rs#L36-L40 + if (builder.ExecutionContext.IsPublishMode) + { + context.EnvironmentVariables[EnableStaticContentEnvVarName] = "0"; + } + }); + } + + /// + /// Adds a named volume for the data folder to a Qdrant container resource. + /// + /// The resource builder. + /// The name of the volume. Defaults to an auto-generated name based on the resource name. + /// A flag that indicates if this is a read-only volume. + /// The . + public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null, bool isReadOnly = false) + => builder.WithVolume(name ?? VolumeNameGenerator.CreateVolumeName(builder, "data"), "/qdrant/storage", isReadOnly); + + /// + /// Adds a bind mount for the data folder to a Qdrant container resource. + /// + /// The resource builder. + /// The source directory on the host to mount into the container. + /// A flag that indicates if this is a read-only mount. + /// The . + public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source, bool isReadOnly = false) + => builder.WithBindMount(source, "/qdrant/storage", isReadOnly); + + /// + /// Add a reference to a Qdrant settings for a project. + /// + /// An for + /// The Qdrant server resource + /// + public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder qdrantResource) + { + builder.WithEnvironment(context => + { + // primary endpoint (gRPC) + context.EnvironmentVariables[$"ConnectionStrings__{qdrantResource.Resource.Name}"] = qdrantResource.Resource.ConnectionStringExpression; + + // REST endpoint + context.EnvironmentVariables[$"ConnectionStrings__{qdrantResource.Resource.Name}_{QdrantServerResource.RestEndpointName}"] = qdrantResource.Resource.RestConnectionStringExpression; + }); + + return builder; + } +} diff --git a/src/Aspire.Hosting.Qdrant/QdrantContainerImageTags.cs b/src/Aspire.Hosting.Qdrant/QdrantContainerImageTags.cs new file mode 100644 index 0000000000..9c598c5b7f --- /dev/null +++ b/src/Aspire.Hosting.Qdrant/QdrantContainerImageTags.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Qdrant; + +internal static class QdrantContainerImageTags +{ + public const string Image = "qdrant/qdrant"; + public const string Tag = "v1.8.3"; +} diff --git a/src/Aspire.Hosting.Qdrant/QdrantServerResource.cs b/src/Aspire.Hosting.Qdrant/QdrantServerResource.cs new file mode 100644 index 0000000000..fda54134a0 --- /dev/null +++ b/src/Aspire.Hosting.Qdrant/QdrantServerResource.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Qdrant database. +/// +public class QdrantServerResource : ContainerResource, IResourceWithConnectionString +{ + internal const string PrimaryEndpointName = "http"; + internal const string RestEndpointName = "rest"; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// A that contains the API Key + public QdrantServerResource(string name, ParameterResource apiKey) : base(name) + { + ArgumentNullException.ThrowIfNull(apiKey); + ApiKeyParameter = apiKey; + } + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the parameter that contains the Qdrant API key. + /// + public ParameterResource ApiKeyParameter { get; } + + /// + /// Gets the primary endpoint for the Qdrant database. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); + + /// + /// Gets the connection string expression for the Qdrant gRPC endpoint. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"Endpoint={PrimaryEndpoint.Property(EndpointProperty.Scheme)}://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)};Key={ApiKeyParameter}"); + + /// + /// Gets the connection string expression for the Qdrant REST endpoint. + /// + public ReferenceExpression RestConnectionStringExpression => + ReferenceExpression.Create( + $"Endpoint={PrimaryEndpoint.Property(EndpointProperty.Scheme)}://{PrimaryEndpoint.Property(EndpointProperty.Host)}:6333;Key={ApiKeyParameter}"); +} diff --git a/src/Aspire.Hosting.Qdrant/README.md b/src/Aspire.Hosting.Qdrant/README.md new file mode 100644 index 0000000000..a25ff6dc2b --- /dev/null +++ b/src/Aspire.Hosting.Qdrant/README.md @@ -0,0 +1,34 @@ +# Aspire.Hosting.Qdrant library + +Provides extension methods and resource definitions for a .NET Aspire AppHost to configure a Qdrant vector database resource. + +## Getting started + +### Install the package + +In your AppHost project, install the .NET Aspire Qdrant Hosting library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.Qdrant +``` + +## Usage example + +Then, in the _Program.cs_ file of `AppHost`, add a Qdrant resource and consume the connection using the following methods: + +```csharp +var qdrant = builder.AddQdrant("qdrant"); + +var myService = builder.AddProject() + .WithReference(qdrant); +``` + +## Additional documentation +* https://qdrant.tech/documentation +* https://learn.microsoft.com/azure/cosmos-db/nosql/sdk-dotnet-v3 + +## Feedback & contributing + +https://github.com/dotnet/aspire + +_Qdrant, and the Qdrant logo are trademarks or registered trademarks of Qdrant Solutions GmbH of Germany, and used with their permission._ diff --git a/src/Components/Aspire.Qdrant.Client/Aspire.Qdrant.Client.csproj b/src/Components/Aspire.Qdrant.Client/Aspire.Qdrant.Client.csproj new file mode 100644 index 0000000000..9edc30e8d3 --- /dev/null +++ b/src/Components/Aspire.Qdrant.Client/Aspire.Qdrant.Client.csproj @@ -0,0 +1,21 @@ + + + + $(NetCurrent) + true + $(ComponentCommonPackageTags) qdrant + A Qdrant client that integrates with Aspire, including logging. + $(SharedDir)QdrantLogo_256x.png + + + + + + + + + + + + + diff --git a/src/Components/Aspire.Qdrant.Client/AspireQdrantExtensions.cs b/src/Components/Aspire.Qdrant.Client/AspireQdrantExtensions.cs new file mode 100644 index 0000000000..e22929edaa --- /dev/null +++ b/src/Components/Aspire.Qdrant.Client/AspireQdrantExtensions.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Qdrant.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Qdrant.Client; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for registering Qdrant-related services in an . +/// +public static class AspireQdrantExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Qdrant:Client"; + + /// + /// Registers as a singleton in the services provided by the . + /// Configures logging for the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// Reads the configuration from "Aspire:Qdrant:Client" section. + /// If required ConnectionString is not provided in configuration section + public static void AddQdrantClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null) + { + AddQdrant(builder, DefaultConfigSectionName, configureSettings, connectionName, serviceKey: null); + } + + /// + /// Registers as a keyed singleton for the given in the services provided by the . + /// Configures logging for the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// Reads the configuration from "Aspire:Qdrant:Client" section. + /// If required ConnectionString is not provided in configuration section + public static void AddKeyedQdrantClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null) + { + AddQdrant(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, connectionName: name, serviceKey: name); + } + + private static void AddQdrant( + this IHostApplicationBuilder builder, + string configurationSectionName, + Action? configureSettings, + string connectionName, + string? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + + var settings = new QdrantClientSettings(); + builder.Configuration.GetSection(configurationSectionName).Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ParseConnectionString(connectionString); + } + + configureSettings?.Invoke(settings); + + if (serviceKey is null) + { + builder.Services.AddSingleton(ConfigureQdrant); + } + else + { + builder.Services.AddKeyedSingleton(serviceKey, (sp, key) => ConfigureQdrant(sp)); + } + + QdrantClient ConfigureQdrant(IServiceProvider serviceProvider) + { + if (settings.Endpoint is not null) + { + return new QdrantClient(settings.Endpoint, apiKey: settings.Key, loggerFactory: serviceProvider.GetRequiredService()); + } + else + { + throw new InvalidOperationException( + $"A QdrantClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or either " + + $"{nameof(settings.Endpoint)} must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } + } + } +} diff --git a/src/Components/Aspire.Qdrant.Client/AssemblyInfo.cs b/src/Components/Aspire.Qdrant.Client/AssemblyInfo.cs new file mode 100644 index 0000000000..914347a4b9 --- /dev/null +++ b/src/Components/Aspire.Qdrant.Client/AssemblyInfo.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.Qdrant.Client; + +[assembly: ConfigurationSchema("Aspire:Qdrant:Client", typeof(QdrantClientSettings))] + +[assembly: LoggingCategories("Qdrant.Client")] diff --git a/src/Components/Aspire.Qdrant.Client/ConfigurationSchema.json b/src/Components/Aspire.Qdrant.Client/ConfigurationSchema.json new file mode 100644 index 0000000000..a0b7d5db3d --- /dev/null +++ b/src/Components/Aspire.Qdrant.Client/ConfigurationSchema.json @@ -0,0 +1,39 @@ +{ + "definitions": { + "logLevel": { + "properties": { + "Qdrant.Client": { + "$ref": "#/definitions/logLevelThreshold" + } + } + } + }, + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Qdrant": { + "type": "object", + "properties": { + "Client": { + "type": "object", + "properties": { + "Endpoint": { + "type": "string", + "format": "uri", + "description": "The endpoint URI string of the Qdrant server to connect to." + }, + "Key": { + "type": "string", + "description": "The API Key of the Qdrant server to connect to." + } + }, + "description": "Provides the client configuration settings for connecting to a Qdrant server using QdrantClient." + } + } + } + } + } + }, + "type": "object" +} diff --git a/src/Components/Aspire.Qdrant.Client/QdrantClientSettings.cs b/src/Components/Aspire.Qdrant.Client/QdrantClientSettings.cs new file mode 100644 index 0000000000..5db43f6549 --- /dev/null +++ b/src/Components/Aspire.Qdrant.Client/QdrantClientSettings.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data.Common; + +namespace Aspire.Qdrant.Client; + +/// +/// Provides the client configuration settings for connecting to a Qdrant server using QdrantClient. +/// +public sealed class QdrantClientSettings +{ + private const string ConnectionStringEndpoint = "Endpoint"; + private const string ConnectionStringKey = "Key"; + + /// + /// The endpoint URI string of the Qdrant server to connect to. + /// + public Uri? Endpoint { get; set; } + + /// + /// The API Key of the Qdrant server to connect to. + /// + public string? Key { get; set; } + + internal void ParseConnectionString(string? connectionString) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + Endpoint = uri; + } + else + { + var connectionBuilder = new DbConnectionStringBuilder + { + ConnectionString = connectionString + }; + + if (connectionBuilder.ContainsKey(ConnectionStringEndpoint) && Uri.TryCreate(connectionBuilder[ConnectionStringEndpoint].ToString(), UriKind.Absolute, out var serviceUri)) + { + Endpoint = serviceUri; + } + + if (connectionBuilder.ContainsKey(ConnectionStringKey)) + { + Key = connectionBuilder[ConnectionStringKey].ToString(); + } + } + } +} diff --git a/src/Components/Aspire.Qdrant.Client/README.md b/src/Components/Aspire.Qdrant.Client/README.md new file mode 100644 index 0000000000..c956e2ec4b --- /dev/null +++ b/src/Components/Aspire.Qdrant.Client/README.md @@ -0,0 +1,107 @@ +# Aspire.Qdrant.Client library + +Registers a [QdrantClient](https://github.com/qdrant/qdrant-dotnet) in the DI container for connecting to a Qdrant server. + +## Getting started + +### Prerequisites + +- Qdrant server and connection string for accessing the server API endpoint. + +### Install the package + +Install the .NET Aspire Qdrant Client library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Qdrant.Client +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddQdrantClient` extension method to register a `QdrantClient` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddQdrantClient("qdrant"); +``` + +## Configuration + +The .NET Aspire Qdrant Client component provides multiple options to configure the server connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddQdrantClient()`: + +```csharp +builder.AddQdrantClient("qdrant"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "qdrant": "Endpoint=http://localhost:6334;Key=123456!@#$%" + } +} +``` + +By default the `QdrantClient` uses the gRPC API endpoint. + +### Use configuration providers + +The .NET Aspire Qdrant Client component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `QdrantSettings` from configuration by using the `Aspire:Qdrant:Client` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Qdrant": { + "Client": { + "Key": "123456!@#$%" + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to set the API key from code: + +```csharp +builder.AddQdrantClient("qdrant", settings => settings.ApiKey = "12345!@#$%"); +``` + +## AppHost extensions + +In your AppHost project, install the `Aspire.Hosting.Qdrant` library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.Qdrant +``` + +Then, in the _Program.cs_ file of `AppHost`, register a Qdrant server and consume the connection using the following methods: + +```csharp +var qdrant = builder.AddQdrant("qdrant"); + +var myService = builder.AddProject() + .WithReference(qdrant); +``` + +The `WithReference` method configures a connection in the `MyService` project named `qdrant`. In the _Program.cs_ file of `MyService`, the Qdrant connection can be consumed using: + +```csharp +builder.AddQdrantClient("qdrant"); +``` + +## Additional documentation + +* https://github.com/qdrant/qdrant-dotnet +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire + +_Qdrant, and the Qdrant logo are trademarks or registered trademarks of Qdrant Solutions GmbH of Germany, and used with their permission._ diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 69ea259cef..0fe3966191 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -29,6 +29,7 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As | Pomelo.EntityFrameworkCore.MySql | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | NATS.Net | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | Seq | ✅ | ✅ | ✅ | ✅ | ✅ | N/A | N/A | ✅ | +| Qdrant.Client | ✅ | ✅ | ✅ | ✅ | ✅ | | | | Nomenclature used in the table above: diff --git a/src/Components/Telemetry.md b/src/Components/Telemetry.md index 3fea50c74f..ea137b68d6 100644 --- a/src/Components/Telemetry.md +++ b/src/Components/Telemetry.md @@ -274,6 +274,14 @@ Aspire.Pomelo.EntityFrameworkCore.MySql: - "db.client.connections.timeouts" - "db.client.connections.usage" +Aspire.Qdrant.Client: +- Log categories: + "Qdrant.Client" +- Activity source names: + - none (not currently supported by Qdrant.Client library) +- Metric names: + - none (currently not supported by Qdrant.Client library) + Aspire.RabbitMQ.Client: - Log categories: - "RabbitMQ.Client" diff --git a/src/Shared/QdrantLogo_256x.png b/src/Shared/QdrantLogo_256x.png new file mode 100644 index 0000000000..d49afd6f3b Binary files /dev/null and b/src/Shared/QdrantLogo_256x.png differ diff --git a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj index ef1d696493..69256ba50d 100644 --- a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj +++ b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj @@ -32,6 +32,7 @@ + diff --git a/tests/Aspire.Hosting.Tests/Qdrant/AddQdrantTests.cs b/tests/Aspire.Hosting.Tests/Qdrant/AddQdrantTests.cs new file mode 100644 index 0000000000..0e6cc2109a --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Qdrant/AddQdrantTests.cs @@ -0,0 +1,255 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Hosting.Qdrant; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Hosting.Tests.Qdrant; + +public class AddQdrantTests +{ + private const int QdrantPortHttp = 6334; + private const int QdrantPortDashboard = 6333; + + [Fact] + public async Task AddQdrantWithDefaultsAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddQdrant("my-qdrant"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.GetContainerResources()); + Assert.Equal("my-qdrant", containerResource.Name); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(QdrantContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(QdrantContainerImageTags.Image, containerAnnotation.Image); + Assert.Null(containerAnnotation.Registry); + + var endpoint = containerResource.Annotations.OfType() + .FirstOrDefault(e => e.Name == "http"); + Assert.NotNull(endpoint); + Assert.Equal(QdrantPortHttp, endpoint.TargetPort); + Assert.False(endpoint.IsExternal); + Assert.Equal("http", endpoint.Name); + Assert.Null(endpoint.Port); + Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); + Assert.Equal("http", endpoint.Transport); + Assert.Equal("http", endpoint.UriScheme); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(containerResource); + + Assert.Collection(config, + env => + { + Assert.Equal("QDRANT__SERVICE__API_KEY", env.Key); + Assert.False(string.IsNullOrEmpty(env.Value)); + }); + } + + [Fact] + public void AddQdrantWithDefaultsAndDashboardAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddQdrant("my-qdrant"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.GetContainerResources()); + Assert.Equal("my-qdrant", containerResource.Name); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(QdrantContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(QdrantContainerImageTags.Image, containerAnnotation.Image); + Assert.Null(containerAnnotation.Registry); + + var endpoint = containerResource.Annotations.OfType() + .FirstOrDefault(e => e.Name == "rest"); + + Assert.NotNull(endpoint); + Assert.Equal(QdrantPortDashboard, endpoint.TargetPort); + Assert.False(endpoint.IsExternal); + Assert.Equal("rest", endpoint.Name); + Assert.Null(endpoint.Port); + Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); + Assert.Equal("http", endpoint.Transport); + Assert.Equal("http", endpoint.UriScheme); + } + + [Fact] + public async Task AddQdrantAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.Configuration["Parameters:pass"] = "pass"; + + var pass = appBuilder.AddParameter("pass"); + appBuilder.AddQdrant("my-qdrant", apiKey: pass); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.GetContainerResources()); + Assert.Equal("my-qdrant", containerResource.Name); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(QdrantContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(QdrantContainerImageTags.Image, containerAnnotation.Image); + Assert.Null(containerAnnotation.Registry); + + var endpoint = containerResource.Annotations.OfType() + .FirstOrDefault(e => e.Name == "http"); + Assert.NotNull(endpoint); + Assert.Equal(QdrantPortHttp, endpoint.TargetPort); + Assert.False(endpoint.IsExternal); + Assert.Equal("http", endpoint.Name); + Assert.Null(endpoint.Port); + Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); + Assert.Equal("http", endpoint.Transport); + Assert.Equal("http", endpoint.UriScheme); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(containerResource); + + Assert.Collection(config, + env => + { + Assert.Equal("QDRANT__SERVICE__API_KEY", env.Key); + Assert.Equal("pass", env.Value); + }); + } + + [Fact] + public async Task QdrantCreatesConnectionString() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.Configuration["Parameters:pass"] = "pass"; + var pass = appBuilder.AddParameter("pass"); + + var qdrant = appBuilder.AddQdrant("my-qdrant", pass) + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6334)); + + var connectionStringResource = qdrant.Resource as IResourceWithConnectionString; + + var connectionString = await connectionStringResource.GetConnectionStringAsync(); + Assert.Equal($"Endpoint=http://localhost:6334;Key=pass", connectionString); + } + + [Fact] + public async Task QdrantClientAppWithReferenceContainsConnectionStrings() + { + using var testProgram = CreateTestProgram(); + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.Configuration["Parameters:pass"] = "pass"; + var pass = appBuilder.AddParameter("pass"); + + var qdrant = appBuilder.AddQdrant("my-qdrant", pass) + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6334)) + .WithEndpoint("rest", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6333)); + + var projectA = appBuilder.AddProject("projecta") + .WithReference(qdrant); + + // Call environment variable callbacks. + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectA.Resource); + + var servicesKeysCount = config.Keys.Count(k => k.StartsWith("ConnectionStrings__")); + Assert.Equal(2, servicesKeysCount); + + Assert.Contains(config, kvp => kvp.Key == "ConnectionStrings__my-qdrant" && kvp.Value == "Endpoint=http://localhost:6334;Key=pass"); + Assert.Contains(config, kvp => kvp.Key == "ConnectionStrings__my-qdrant_rest" && kvp.Value == "Endpoint=http://localhost:6333;Key=pass"); + } + + [Fact] + public async Task VerifyManifest() + { + var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions() { Args = new string[] { "--publisher", "manifest" } } ); + var qdrant = appBuilder.AddQdrant("qdrant"); + + var serverManifest = await ManifestUtils.GetManifest(qdrant.Resource); // using this method does not get any ExecutionContext.IsPublishMode changes + + var expectedManifest = $$""" + { + "type": "container.v0", + "connectionString": "Endpoint={qdrant.bindings.http.scheme}://{qdrant.bindings.http.host}:{qdrant.bindings.http.port};Key={qdrant-Key.value}", + "image": "{{QdrantContainerImageTags.Image}}:{{QdrantContainerImageTags.Tag}}", + "env": { + "QDRANT__SERVICE__API_KEY": "{qdrant-Key.value}", + "QDRANT__SERVICE__ENABLE_STATIC_CONTENT": "0" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 6334 + }, + "rest": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 6333 + } + } + } + """; + Assert.Equal(expectedManifest, serverManifest.ToString()); + } + + [Fact] + public async Task VerifyManifestWithParameters() + { + var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions() { Args = new string[] { "--publisher", "manifest" } }); + + var apiKeyParameter = appBuilder.AddParameter("QdrantApiKey"); + var qdrant = appBuilder.AddQdrant("qdrant", apiKeyParameter); + + var serverManifest = await ManifestUtils.GetManifest(qdrant.Resource); // using this method does not get any ExecutionContext.IsPublishMode changes + + var expectedManifest = $$""" + { + "type": "container.v0", + "connectionString": "Endpoint={qdrant.bindings.http.scheme}://{qdrant.bindings.http.host}:{qdrant.bindings.http.port};Key={QdrantApiKey.value}", + "image": "{{QdrantContainerImageTags.Image}}:{{QdrantContainerImageTags.Tag}}", + "env": { + "QDRANT__SERVICE__API_KEY": "{QdrantApiKey.value}", + "QDRANT__SERVICE__ENABLE_STATIC_CONTENT": "0" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 6334 + }, + "rest": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 6333 + } + } + } + """; + Assert.Equal(expectedManifest, serverManifest.ToString()); + } + + private static TestProgram CreateTestProgram(string[]? args = null) => TestProgram.Create(args); + + private sealed class ProjectA : IProjectMetadata + { + public string ProjectPath => "projectA"; + + public LaunchSettings LaunchSettings { get; } = new(); + } +} diff --git a/tests/Aspire.Qdrant.Client.Tests/Aspire.Qdrant.Client.Tests.csproj b/tests/Aspire.Qdrant.Client.Tests/Aspire.Qdrant.Client.Tests.csproj new file mode 100644 index 0000000000..43aeb02ea8 --- /dev/null +++ b/tests/Aspire.Qdrant.Client.Tests/Aspire.Qdrant.Client.Tests.csproj @@ -0,0 +1,15 @@ + + + + $(NetCurrent) + true + + + + + + + + + + diff --git a/tests/Aspire.Qdrant.Client.Tests/AspireQdrantHelpers.cs b/tests/Aspire.Qdrant.Client.Tests/AspireQdrantHelpers.cs new file mode 100644 index 0000000000..40c3e18400 --- /dev/null +++ b/tests/Aspire.Qdrant.Client.Tests/AspireQdrantHelpers.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.XUnitExtensions; +using Qdrant.Client; + +namespace Aspire.Qdrant.Client.Tests; +public static class AspireQdrantHelpers +{ + public const string TestingEndpoint = "http://localhost:6334"; + + private static readonly Lazy s_canConnectToServer = new(GetCanConnect); + public static bool CanConnectToServer => s_canConnectToServer.Value; + + public static void SkipIfCanNotConnectToServer() + { + if (!CanConnectToServer) + { + throw new SkipTestException("Unable to connect to the server."); + } + } + + private static bool GetCanConnect() + { + try + { + var client = new QdrantClient(new Uri(TestingEndpoint)); + client.ListCollectionsAsync().Wait(); + return true; + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + return false; + } + } +} diff --git a/tests/Aspire.Qdrant.Client.Tests/ConfigurationTests.cs b/tests/Aspire.Qdrant.Client.Tests/ConfigurationTests.cs new file mode 100644 index 0000000000..5761b2f0fb --- /dev/null +++ b/tests/Aspire.Qdrant.Client.Tests/ConfigurationTests.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Qdrant.Client.Tests; +public class ConfigurationTests +{ + [Fact] + public void EndpointIsNullByDefault() + => Assert.Null(new QdrantClientSettings().Endpoint); +} diff --git a/tests/Aspire.Qdrant.Client.Tests/ConformanceTests.cs b/tests/Aspire.Qdrant.Client.Tests/ConformanceTests.cs new file mode 100644 index 0000000000..d75c8366c1 --- /dev/null +++ b/tests/Aspire.Qdrant.Client.Tests/ConformanceTests.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.ConformanceTests; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Qdrant.Client; + +namespace Aspire.Qdrant.Client.Tests; +public class ConformanceTests : ConformanceTests +{ + protected override bool SupportsKeyedRegistrations => true; + + protected override bool CanCreateClientWithoutConnectingToServer => false; + + protected override bool CanConnectToServer => AspireQdrantHelpers.CanConnectToServer; + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string[] RequiredLogCategories => Array.Empty(); + + protected override string ActivitySourceName => ""; + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddQdrantClient("qdrant", configure); + } + else + { + builder.AddKeyedQdrantClient(key, configure); + } + } + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[2] + { + new KeyValuePair(CreateConfigKey("Aspire:Qdrant:Client", key, "Endpoint"), "http://localhost:6334"), + new KeyValuePair($"ConnectionStrings:{key}","Endpoint=http://localhost:6334;Key=pass") + }); + + protected override void TriggerActivity(QdrantClient service) + { + } + + protected override void SetHealthCheck(QdrantClientSettings options, bool enabled) => throw new NotImplementedException(); + + protected override void SetTracing(QdrantClientSettings options, bool enabled) => throw new NotImplementedException(); + + protected override void SetMetrics(QdrantClientSettings options, bool enabled) => throw new NotImplementedException(); + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Qdrant": { + "Client": { + "Endpoint": "http://localhost:6334" + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Qdrant":{ "Client": { "Endpoint": 3 }}}}""", "Value is \"integer\" but should be \"string\""), + ("""{"Aspire": { "Qdrant":{ "Client": { "Endpoint": "hello" }}}}""", "Value does not match format \"uri\"") + }; +} diff --git a/tests/Aspire.Qdrant.Client.Tests/README.md b/tests/Aspire.Qdrant.Client.Tests/README.md new file mode 100644 index 0000000000..af9e6386a6 --- /dev/null +++ b/tests/Aspire.Qdrant.Client.Tests/README.md @@ -0,0 +1,13 @@ +# Aspire.Qdrant.Client.Tests + +This project contains tests for the Aspire.Qdrant.Client project. + +When running tests locally until TestContainers support is enabled, you will need to have a Qdrant instance running locally. + +Run: + +```bash +docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant:v1.8.0 +``` + +Then run the tests to enable the connected tests.