diff --git a/AzureSearchEmulator.Aspire.DemoAppHost/AppHost.cs b/AzureSearchEmulator.Aspire.DemoAppHost/AppHost.cs new file mode 100644 index 0000000..6616435 --- /dev/null +++ b/AzureSearchEmulator.Aspire.DemoAppHost/AppHost.cs @@ -0,0 +1,11 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// For local development and testing, we add an Azure Search Emulator instance based on the project directly +builder.AddProject("emulator-project") + .WithExternalHttpEndpoints(); + +// Example container usage via F23.Aspire.Hosting.AzureSearchEmulator +builder.AddAzureSearchEmulator("emulator-container") + .WithIndexesVolume(); + +builder.Build().Run(); diff --git a/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj b/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj new file mode 100644 index 0000000..68e7b6d --- /dev/null +++ b/AzureSearchEmulator.Aspire.DemoAppHost/AzureSearchEmulator.Aspire.DemoAppHost.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + e92d4b6e-a95b-491f-9fe2-8c8ecb4be28f + + + + + + + + diff --git a/AzureSearchEmulator.Aspire.DemoAppHost/Properties/launchSettings.json b/AzureSearchEmulator.Aspire.DemoAppHost/Properties/launchSettings.json new file mode 100644 index 0000000..cb92b02 --- /dev/null +++ b/AzureSearchEmulator.Aspire.DemoAppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17078;http://localhost:15216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21046", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23199", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22069" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19291", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18104", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20178" + } + } + } +} diff --git a/AzureSearchEmulator.Aspire.DemoAppHost/appsettings.Development.json b/AzureSearchEmulator.Aspire.DemoAppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/AzureSearchEmulator.Aspire.DemoAppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/AzureSearchEmulator.Aspire.DemoAppHost/appsettings.json b/AzureSearchEmulator.Aspire.DemoAppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/AzureSearchEmulator.Aspire.DemoAppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/AzureSearchEmulator.Aspire.Tests/AzureSearchEmulator.Aspire.Tests.csproj b/AzureSearchEmulator.Aspire.Tests/AzureSearchEmulator.Aspire.Tests.csproj new file mode 100644 index 0000000..4376526 --- /dev/null +++ b/AzureSearchEmulator.Aspire.Tests/AzureSearchEmulator.Aspire.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AzureSearchEmulator.Aspire.Tests/AzureSearchEmulatorResourceExtensionsTests.cs b/AzureSearchEmulator.Aspire.Tests/AzureSearchEmulatorResourceExtensionsTests.cs new file mode 100644 index 0000000..d96317d --- /dev/null +++ b/AzureSearchEmulator.Aspire.Tests/AzureSearchEmulatorResourceExtensionsTests.cs @@ -0,0 +1,62 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using F23.Aspire.Hosting.AzureSearchEmulator; + +namespace AzureSearchEmulator.Aspire.Tests; + +public class AzureSearchEmulatorResourceExtensionsTests +{ + [Fact] + public async Task AddAzureSearchEmulator_ShouldAddResourceToBuilder() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + const string resourceName = "my-emulator"; + + // Act + var resource = builder.AddAzureSearchEmulator(resourceName); + + // Assert + Assert.NotNull(resource); + Assert.Equal(resourceName, resource.Resource.Name); + Assert.Contains(resource.Resource, builder.Resources.ToList()); + + var http = resource.Resource.GetEndpoint("http"); + Assert.NotNull(http); + Assert.Equal(AzureSearchEmulatorResource.DefaultHttpPort, http.TargetPort); + Assert.Equal("http", http.Scheme); + + var https = resource.Resource.GetEndpoint("https"); + Assert.NotNull(https); + Assert.Equal(AzureSearchEmulatorResource.DefaultHttpsPort, https.TargetPort); + Assert.Equal("https", https.Scheme); + + var envVars = await resource.Resource.GetEnvironmentVariableValuesAsync(); + Assert.True(envVars.ContainsKey("ASPNETCORE_URLS")); + } + + [InlineData(false)] + [InlineData(true)] + [Theory] + public void WithIndexesVolume_ShouldAddVolumeToResource(bool isReadOnly) + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var resourceBuilder = builder.AddAzureSearchEmulator("my-emulator"); + + // Act + var updatedBuilder = resourceBuilder.WithIndexesVolume(isReadOnly: isReadOnly); + + // Assert + Assert.NotNull(updatedBuilder); + + if (!updatedBuilder.Resource.TryGetAnnotationsOfType(out var mountAnnotations)) + { + Assert.Fail("No mount annotations found on the resource."); + } + + var mount = mountAnnotations.FirstOrDefault(ma => ma.Target == "/app/indexes"); + Assert.NotNull(mount); + Assert.Equal(isReadOnly, mount.IsReadOnly); + } +} diff --git a/AzureSearchEmulator.Aspire.Tests/AzureSearchEmulatorResourceTests.cs b/AzureSearchEmulator.Aspire.Tests/AzureSearchEmulatorResourceTests.cs new file mode 100644 index 0000000..d9bf234 --- /dev/null +++ b/AzureSearchEmulator.Aspire.Tests/AzureSearchEmulatorResourceTests.cs @@ -0,0 +1,19 @@ +using F23.Aspire.Hosting.AzureSearchEmulator; + +namespace AzureSearchEmulator.Aspire.Tests; + +public class AzureSearchEmulatorResourceTests +{ + [Fact] + public void Constructor_InitializesResourceProperly() + { + // Arrange + const string name = "my-emulator"; + + // Act + var resource = new AzureSearchEmulatorResource(name); + + // Assert + Assert.Equal(name, resource.Name); + } +} diff --git a/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj b/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj new file mode 100644 index 0000000..91ff030 --- /dev/null +++ b/AzureSearchEmulator.Aspire/AzureSearchEmulator.Aspire.csproj @@ -0,0 +1,39 @@ + + + + net10.0 + enable + enable + F23.Aspire.Hosting.AzureSearchEmulator + true + 14 + F23.Aspire.Hosting.AzureSearchEmulator + AGPL-3.0-or-later + https://github.com/feature23/azuresearchemulator + azure search emulator ai hosting docker + Azure Search Emulator hosting support for Aspire. + true + 1.0.0-beta + feature[23] + Copyright (c) feature[23] 2025 + Paul Irwin + logo.png + README.md + true + snupkg + + + + + + + + + + + + + + + + diff --git a/AzureSearchEmulator.Aspire/AzureSearchEmulatorResource.cs b/AzureSearchEmulator.Aspire/AzureSearchEmulatorResource.cs new file mode 100644 index 0000000..438fe7c --- /dev/null +++ b/AzureSearchEmulator.Aspire/AzureSearchEmulatorResource.cs @@ -0,0 +1,9 @@ +using Aspire.Hosting.ApplicationModel; + +namespace F23.Aspire.Hosting.AzureSearchEmulator; + +public class AzureSearchEmulatorResource(string name) : ContainerResource(name) +{ + public const int DefaultHttpPort = 5100; + public const int DefaultHttpsPort = 5143; +} diff --git a/AzureSearchEmulator.Aspire/AzureSearchEmulatorResourceExtensions.cs b/AzureSearchEmulator.Aspire/AzureSearchEmulatorResourceExtensions.cs new file mode 100644 index 0000000..e3ba8c8 --- /dev/null +++ b/AzureSearchEmulator.Aspire/AzureSearchEmulatorResourceExtensions.cs @@ -0,0 +1,63 @@ +using Aspire.Hosting.ApplicationModel; +using F23.Aspire.Hosting.AzureSearchEmulator; + +// ReSharper disable once CheckNamespace +namespace Aspire.Hosting; + +/// +/// Extension methods for adding and configuring Azure Search Emulator resources in Aspire. +/// +public static class AzureSearchEmulatorResourceExtensions +{ + extension(IDistributedApplicationBuilder builder) + { + /// + /// Adds an Azure Search Emulator container resource to the distributed application. + /// + /// The name of the resource. + /// An optional HTTP port. If null, will use a generated port number. + /// An optional HTTPS port. If null, will use a generated port number. + /// A resource builder for further configuration. + /// + /// It is recommended to configure a volume for persisting index data using + /// . + /// You can also override the default image tag ("latest") by using the returned resource builder's + /// method. + /// + public IResourceBuilder AddAzureSearchEmulator(string name, + int? httpPort = null, + int? httpsPort = null) + { + var resource = new AzureSearchEmulatorResource(name); + + var resourceBuilder = builder.AddResource(resource) + .WithImage("feature23/azuresearchemulator") + .WithImageTag("latest") + .WithImageRegistry("ghcr.io") + .WithHttpEndpoint(port: httpPort, targetPort: AzureSearchEmulatorResource.DefaultHttpPort, env: "HTTP_PORTS") + .WithHttpsEndpoint(port: httpsPort, targetPort: AzureSearchEmulatorResource.DefaultHttpsPort, env: "HTTPS_PORTS") + .WithEnvironment("ASPNETCORE_URLS", $"https://+:{resource.GetEndpoint("https").Property(EndpointProperty.Port)};http://+:{resource.GetEndpoint("http").Property(EndpointProperty.Port)}") + .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Password", "password") + .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Path", "/app/aspnetapp.pfx"); + + return resourceBuilder; + } + } + + extension(IResourceBuilder builder) + { + /// + /// Configures a volume for persisting Azure Search index data. + /// + /// Optional name for the volume. If null, a name will be generated. + /// Indicates whether the volume should be mounted as read-only. + /// The resource builder for further configuration. + public IResourceBuilder WithIndexesVolume(string? volumeName = null, bool isReadOnly = false) + { + return builder.WithVolume( + name: volumeName ?? VolumeNameGenerator.Generate(builder, "indexes"), + target: "/app/indexes", + isReadOnly: isReadOnly); + } + } +} diff --git a/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj b/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj index bd3d87d..a8715b6 100644 --- a/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj +++ b/AzureSearchEmulator.IntegrationTests/AzureSearchEmulator.IntegrationTests.csproj @@ -6,6 +6,7 @@ enable true nullable + false diff --git a/AzureSearchEmulator.sln b/AzureSearchEmulator.sln index 6bde183..6a384ee 100644 --- a/AzureSearchEmulator.sln +++ b/AzureSearchEmulator.sln @@ -9,6 +9,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureSearchEmulator.Integra EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DebugClient", "DebugClient\DebugClient.csproj", "{787C4AEB-D46D-472F-9BC5-10857FEF2A05}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureSearchEmulator.Aspire", "AzureSearchEmulator.Aspire\AzureSearchEmulator.Aspire.csproj", "{D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureSearchEmulator.Aspire.DemoAppHost", "AzureSearchEmulator.Aspire.DemoAppHost\AzureSearchEmulator.Aspire.DemoAppHost.csproj", "{D771EC01-A785-4B7F-AC50-A86D2E281210}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureSearchEmulator.Aspire.Tests", "AzureSearchEmulator.Aspire.Tests\AzureSearchEmulator.Aspire.Tests.csproj", "{EDF10963-768B-426B-B4E2-F2C1DBC2C47C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +33,18 @@ Global {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Debug|Any CPU.Build.0 = Debug|Any CPU {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Release|Any CPU.ActiveCfg = Release|Any CPU {787C4AEB-D46D-472F-9BC5-10857FEF2A05}.Release|Any CPU.Build.0 = Release|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0B85C5F-2B49-4D93-BCDA-7D33E6CC1E05}.Release|Any CPU.Build.0 = Release|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D771EC01-A785-4B7F-AC50-A86D2E281210}.Release|Any CPU.Build.0 = Release|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EDF10963-768B-426B-B4E2-F2C1DBC2C47C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/AzureSearchEmulator/AzureSearchEmulator.csproj b/AzureSearchEmulator/AzureSearchEmulator.csproj index 4adbb36..e57a39c 100644 --- a/AzureSearchEmulator/AzureSearchEmulator.csproj +++ b/AzureSearchEmulator/AzureSearchEmulator.csproj @@ -8,6 +8,7 @@ 1.0.0-beta Nullable 14 + false diff --git a/AzureSearchEmulator/Properties/launchSettings.json b/AzureSearchEmulator/Properties/launchSettings.json index 53ceed9..33168b4 100644 --- a/AzureSearchEmulator/Properties/launchSettings.json +++ b/AzureSearchEmulator/Properties/launchSettings.json @@ -1,35 +1,12 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:54359", - "sslPort": 44381 - } - }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "AzureSearchEmulator": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "dotnetRunMessages": "true", "applicationUrl": "https://localhost:5123" - }, - "Docker": { - "commandName": "Docker", - "launchBrowser": true, - "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", - "publishAllPorts": true, - "useSSL": true } } -} \ No newline at end of file +} diff --git a/DebugClient/DebugClient.csproj b/DebugClient/DebugClient.csproj index ccd72fe..a05e2f1 100644 --- a/DebugClient/DebugClient.csproj +++ b/DebugClient/DebugClient.csproj @@ -5,6 +5,7 @@ net10.0 enable enable + false diff --git a/DebugClient/Program.cs b/DebugClient/Program.cs index 9276658..6c6aa6b 100644 --- a/DebugClient/Program.cs +++ b/DebugClient/Program.cs @@ -1,11 +1,29 @@ -using Azure; +using System.Globalization; +using Azure; using Azure.Core.Pipeline; using Azure.Search.Documents; using Azure.Search.Documents.Indexes; using Azure.Search.Documents.Indexes.Models; using Azure.Search.Documents.Models; -const string endpoint = "https://localhost:5123"; +int port = 5123; + +if (args.Length > 0 && int.TryParse(args[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var argPort)) +{ + port = argPort; +} +else +{ + Console.WriteLine("Enter HTTPS port number for Azure Search Emulator (default 5123): "); + var portInput = Console.ReadLine(); + if (!string.IsNullOrWhiteSpace(portInput) && int.TryParse(portInput, NumberStyles.Integer, + CultureInfo.InvariantCulture, out var parsedPort)) + { + port = parsedPort; + } +} + +string endpoint = $"https://localhost:{port}"; const string indexName = "test-index"; var handler = new HttpClientHandler(); diff --git a/Dockerfile b/Dockerfile index c35197d..11d60a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,10 @@ COPY ["AzureSearchEmulator/AzureSearchEmulator.csproj", "AzureSearchEmulator/"] RUN dotnet restore "AzureSearchEmulator/AzureSearchEmulator.csproj" COPY . . WORKDIR "/src/AzureSearchEmulator" -RUN dotnet build "AzureSearchEmulator.csproj" -c Release -o /app/build +RUN dotnet build --no-restore "AzureSearchEmulator.csproj" -c Release -o /app/build FROM build AS publish -RUN dotnet publish "AzureSearchEmulator.csproj" -c Release -o /app/publish +RUN dotnet publish --no-restore "AzureSearchEmulator.csproj" -c Release -o /app/publish FROM base AS final WORKDIR /app diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..5ac3f0d Binary files /dev/null and b/logo.png differ