diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index 212970a8..854f76bf 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -72,3 +72,5 @@ description: Initial standalone release notes for the CrestApps.Core repository. - distinguishes uploaded vision images from searchable documents in the shared document-availability prompt so multimodal chat sessions analyze supported attached images directly instead of defaulting to document-tool or metadata-only responses - caps the total uploaded vision-image bytes loaded into a single multimodal request through `ChatDocumentsOptions.MaxVisionInputBytesPerRequest`, removes the extra `MemoryStream` copy when attaching those images, and documents how to resolve a vision-capable chat client for direct image-description requests - adds `CrestApps.Core.PostgreSQL` and `CrestApps.Core.AI.PostgreSQL` packages providing a lightweight PostgreSQL + pgvector vector search backend as an alternative to Elasticsearch and Azure AI Search, registers the same keyed services (`ISearchIndexManager`, `ISearchDocumentManager`, `IDataSourceContentManager`, `IDataSourceDocumentReader`, `IODataFilterTranslator`) under the `"PostgreSQL"` provider name, supports `AddAIDocuments()`, `AddAIDataSources()`, and `AddAIMemory()` builder extensions, and integrates into both MVC and Blazor sample hosts +- fixes hosted document and data-source indexing flows so background workers create a scoped service provider before resolving scoped indexing services, preventing upload-triggered failures and similar nightly alignment lifetime issues +- standardizes Azure AI Search configuration on top-level `AuthenticationType`, `ApiKey`, `IdentityClientId`, and `IndexPrefix` settings under `CrestApps:AzureAISearch`, and refreshes the sample host / docs examples to list the full supported option set in one place diff --git a/src/CrestApps.Core.Docs/docs/data-sources/azure-ai.md b/src/CrestApps.Core.Docs/docs/data-sources/azure-ai.md index ca9b2dab..147d24d2 100644 --- a/src/CrestApps.Core.Docs/docs/data-sources/azure-ai.md +++ b/src/CrestApps.Core.Docs/docs/data-sources/azure-ai.md @@ -29,11 +29,12 @@ builder.Services.AddCoreAzureAISearchServices(); ```json { "CrestApps": { - "Search": { - "AzureAISearch": { - "Endpoint": "https://my-search.search.windows.net", - "ApiKey": "your-admin-api-key" - } + "AzureAISearch": { + "Endpoint": "https://my-search.search.windows.net", + "AuthenticationType": "Default", + "ApiKey": "", + "IdentityClientId": "", + "IndexPrefix": "" } } } @@ -44,7 +45,10 @@ builder.Services.AddCoreAzureAISearchServices(); | Property | Type | Description | |----------|------|-------------| | `Endpoint` | `string` | Azure AI Search endpoint URL | -| `ApiKey` | `string` | Admin API key. If empty, uses `DefaultAzureCredential` | +| `AuthenticationType` | `string` | Authentication mode. Supported values: `Default`, `ApiKey`, `ManagedIdentity` | +| `ApiKey` | `string` | Admin API key. Required when `AuthenticationType` is `ApiKey` | +| `IdentityClientId` | `string` | Optional managed identity client ID used by `DefaultAzureCredential` or `ManagedIdentityCredential` | +| `IndexPrefix` | `string` | Optional prefix applied to framework-managed Azure AI Search index names | ## Services Registered (Keyed by `"AzureAISearch"`) @@ -74,8 +78,11 @@ Override `IAIDataSourceIndexingQueue` when you need a durable or distributed que ## Authentication -- **API Key** — Provide the `ApiKey` property -- **Azure AD** — Leave `ApiKey` empty and the service uses `DefaultAzureCredential` (Managed Identity, VS credentials, etc.) +Set `AuthenticationType` to one of these values: + +- **`Default`** — Uses `DefaultAzureCredential`. This is the default when `AuthenticationType` is omitted or invalid. +- **`ApiKey`** — Uses the admin API key from `ApiKey`. +- **`ManagedIdentity`** — Uses `ManagedIdentityCredential`. Set `IdentityClientId` when you need a user-assigned managed identity. ## Azure Setup @@ -104,11 +111,12 @@ The simplest approach — provide the admin API key directly: ```json title="appsettings.json" { "CrestApps": { - "Search": { - "AzureAISearch": { - "Endpoint": "https://myapp-search.search.windows.net", - "ApiKey": "your-admin-api-key" - } + "AzureAISearch": { + "Endpoint": "https://myapp-search.search.windows.net", + "AuthenticationType": "ApiKey", + "ApiKey": "your-admin-api-key", + "IdentityClientId": "", + "IndexPrefix": "" } } } @@ -125,10 +133,12 @@ Leave `ApiKey` empty and the service uses `DefaultAzureCredential`, which automa ```json title="appsettings.json" { "CrestApps": { - "Search": { - "AzureAISearch": { - "Endpoint": "https://myapp-search.search.windows.net" - } + "AzureAISearch": { + "Endpoint": "https://myapp-search.search.windows.net", + "AuthenticationType": "Default", + "ApiKey": "", + "IdentityClientId": "", + "IndexPrefix": "" } } } @@ -141,6 +151,28 @@ Leave `ApiKey` empty and the service uses `DefaultAzureCredential`, which automa 3. **Visual Studio / VS Code credentials** (for local development) 4. **Azure CLI** (`az login`) +If you want to prefer a specific user-assigned managed identity while still using `DefaultAzureCredential`, set `IdentityClientId`. + +### Option 3: Managed Identity Only + +Set `AuthenticationType` to `ManagedIdentity` when you want Azure AI Search to authenticate only with managed identity credentials: + +```json title="appsettings.json" +{ + "CrestApps": { + "AzureAISearch": { + "Endpoint": "https://myapp-search.search.windows.net", + "AuthenticationType": "ManagedIdentity", + "ApiKey": "", + "IdentityClientId": "", + "IndexPrefix": "" + } + } +} +``` + +Leave `IdentityClientId` empty for a system-assigned managed identity, or set it to the client ID of a user-assigned managed identity. + To use Managed Identity: 1. Enable system-assigned managed identity on your Azure App Service. 2. In the Azure AI Search resource, go to **Access Control (IAM)** → **Add role assignment**. @@ -153,11 +185,12 @@ To use Managed Identity: ```json { "CrestApps": { - "Search": { - "AzureAISearch": { - "Endpoint": "https://myapp-search.search.windows.net", - "ApiKey": "your-admin-api-key" - } + "AzureAISearch": { + "Endpoint": "https://myapp-search.search.windows.net", + "AuthenticationType": "Default", + "ApiKey": "", + "IdentityClientId": "", + "IndexPrefix": "" } } } @@ -168,7 +201,10 @@ To use Managed Identity: | Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| | `Endpoint` | `string` | Yes | — | Azure AI Search endpoint URL. Format: `https://{service-name}.search.windows.net` | -| `ApiKey` | `string` | No | — | Admin API key. When empty, `DefaultAzureCredential` is used for authentication. | +| `AuthenticationType` | `string` | No | `Default` | Supported values: `Default`, `ApiKey`, `ManagedIdentity`. | +| `ApiKey` | `string` | No | — | Admin API key. Required when `AuthenticationType` is `ApiKey`. | +| `IdentityClientId` | `string` | No | — | Optional managed identity client ID used by `DefaultAzureCredential` or `ManagedIdentityCredential`. | +| `IndexPrefix` | `string` | No | — | Optional prefix applied to framework-managed index names. | :::info When `Endpoint` is provided, the framework registers a `SearchIndexClient` singleton that all keyed services share. If `Endpoint` is empty or null, no client is registered and the data source is effectively disabled. @@ -187,6 +223,10 @@ curl -H "api-key: your-admin-api-key" \ A successful response returns a JSON list of indexes (possibly empty). +:::info +The framework writes embeddings into the `embedding` vector field for AI Documents, AI Memory, and AI Data Sources. In Azure Search Explorer, make sure the vector field is retrievable and clear **Hide vector values in search results** if you want the raw float array to appear in the portal response. +::: + ### 2. Verify from the Application Inject `ISearchIndexManager` (keyed by `"AzureAISearch"`) and check if the connection is live: diff --git a/src/CrestApps.Core.Docs/docs/data-sources/index.md b/src/CrestApps.Core.Docs/docs/data-sources/index.md index 968b9ec3..7f8599ae 100644 --- a/src/CrestApps.Core.Docs/docs/data-sources/index.md +++ b/src/CrestApps.Core.Docs/docs/data-sources/index.md @@ -433,21 +433,22 @@ All six services must be registered with the same `providerName` key. The framew ## Configuration Guide -Data source backends are configured in `appsettings.json` under the `CrestApps:Search` section. Each backend has its own configuration section: +Data source backends are configured in `appsettings.json` under the `CrestApps` section. Each backend has its own configuration subsection: ```json { "CrestApps": { - "Search": { - "Elasticsearch": { - "Url": "https://localhost:9200", - "Username": "elastic", - "Password": "your-password" - }, - "AzureAISearch": { - "Endpoint": "https://my-search.search.windows.net", - "ApiKey": "your-admin-api-key" - } + "Elasticsearch": { + "Url": "https://localhost:9200", + "Username": "elastic", + "Password": "your-password" + }, + "AzureAISearch": { + "Endpoint": "https://my-search.search.windows.net", + "AuthenticationType": "ApiKey", + "ApiKey": "your-admin-api-key", + "IdentityClientId": "", + "IndexPrefix": "" } } } diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs index 891c6bb1..1a034a67 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs @@ -260,15 +260,7 @@ context.Resource is not AIProfile || return []; } - if (!indexProfile.TryGet(out DataSourceIndexProfileMetadata metadata)) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Unable to retrieve embedding configuration from index profile '{IndexProfileName}'.", settings.IndexProfileName); - } - - return []; - } + indexProfile.TryGet(out DataSourceIndexProfileMetadata metadata); var deploymentName = metadata?.EmbeddingDeploymentName ?? indexProfile.EmbeddingDeploymentName; diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs index 2cad1eef..38c7e1b9 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs @@ -100,7 +100,7 @@ public async Task CreateAsync(SearchIndexProfile profil profile.IndexFullName.SanitizeForLog(), profile.ProviderName.SanitizeForLog()); - return Fail($"Unable to validate whether the remote index '{profile.IndexFullName}' already exists.", nameof(SearchIndexProfile.IndexName)); + return Fail(GetRemoteIndexValidationErrorMessage(profile, ex), nameof(SearchIndexProfile.IndexName)); } try @@ -141,4 +141,41 @@ private static ValidationResultDetails Fail(string message, params string[] memb return result; } + + private static string GetRemoteIndexValidationErrorMessage(SearchIndexProfile profile, Exception ex) + { + if (TryGetRequestFailedStatusCode(ex, out var statusCode) && (statusCode == 401 || statusCode == 403)) + { + return $"Unable to validate whether the remote index '{profile.IndexFullName}' already exists because the remote search provider rejected the configured credentials. Verify the endpoint and use credentials with index management permissions."; + } + + return $"Unable to validate whether the remote index '{profile.IndexFullName}' already exists."; + } + + private static bool TryGetRequestFailedStatusCode(Exception ex, out int statusCode) + { + statusCode = default; + + var exceptionType = ex.GetType(); + if (!string.Equals(exceptionType.FullName, "Azure.RequestFailedException", StringComparison.Ordinal)) + { + return false; + } + + var statusProperty = exceptionType.GetProperty("Status"); + if (statusProperty?.PropertyType != typeof(int)) + { + return false; + } + + var value = statusProperty.GetValue(ex); + if (value is not int typedStatusCode) + { + return false; + } + + statusCode = typedStatusCode; + + return true; + } } diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs index 1b794e3c..fcde0bc3 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs @@ -90,9 +90,9 @@ private bool ShouldRunAlignment(out DateOnly runDateUtc) _lastRunDateUtc != runDateUtc; } - private async Task AlignDataSourcesAsync(IServiceProvider services, CancellationToken cancellationToken) + private async Task AlignDataSourcesAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) { - var dataSourceStore = services.GetService(); + var dataSourceStore = serviceProvider.GetService(); if (dataSourceStore == null) { if (_logger.IsEnabled(LogLevel.Trace)) @@ -112,7 +112,7 @@ private async Task AlignDataSourcesAsync(IServiceProvider services, Cancellation return; } - var indexingService = services.GetRequiredService(); + var indexingService = serviceProvider.GetRequiredService(); if (_logger.IsEnabled(LogLevel.Information)) { diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/AzureAISearchConnectionOptions.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/AzureAISearchConnectionOptions.cs index 84854e1e..d6e54287 100644 --- a/src/Primitives/CrestApps.Core.Azure.AISearch/AzureAISearchConnectionOptions.cs +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/AzureAISearchConnectionOptions.cs @@ -1,3 +1,5 @@ +using System; + namespace CrestApps.Core.Azure.AISearch; /// @@ -6,6 +8,21 @@ namespace CrestApps.Core.Azure.AISearch; /// public sealed class AzureAISearchConnectionOptions { + /// + /// The default Azure AI Search authentication type value. + /// + public const string DefaultAuthenticationType = "Default"; + + /// + /// The API key Azure AI Search authentication type value. + /// + public const string ApiKeyAuthenticationType = "ApiKey"; + + /// + /// The managed identity Azure AI Search authentication type value. + /// + public const string ManagedIdentityAuthenticationType = "ManagedIdentity"; + /// /// The Azure AI Search service endpoint (e.g. "https://my-search.search.windows.net"). /// @@ -13,7 +30,8 @@ public sealed class AzureAISearchConnectionOptions /// /// The admin API key used for authentication. - /// When empty, DefaultAzureCredential is used instead. + /// When empty, DefaultAzureCredential is used instead unless + /// explicitly requires API key authentication. /// public string ApiKey { get; set; } @@ -21,4 +39,66 @@ public sealed class AzureAISearchConnectionOptions /// Optional prefix applied to MVC-managed remote index names. /// public string IndexPrefix { get; set; } + + /// + /// Optional authentication mode. + /// Supported values are Default, ApiKey, and ManagedIdentity. + /// + public string AuthenticationType { get; set; } + + /// + /// Optional managed identity client ID used when DefaultAzureCredential authenticates with Azure. + /// + public string IdentityClientId { get; set; } + + /// + /// Backward-compatible alias for . + /// + public string IndexesPrefix { get; set; } + + /// + /// Gets the configured index prefix, including backward-compatible aliases. + /// + public string GetResolvedIndexPrefix() + { + if (!string.IsNullOrWhiteSpace(IndexPrefix)) + { + return IndexPrefix; + } + + return IndexesPrefix; + } + + /// + /// Gets a value indicating whether API key authentication was selected explicitly. + /// + public bool UsesApiKeyAuthentication() + { + return string.Equals(GetAuthenticationType(), ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets the configured authentication type. + /// + public string GetAuthenticationType() + { + if (string.IsNullOrWhiteSpace(AuthenticationType)) + { + return DefaultAuthenticationType; + } + + var normalizedAuthenticationType = AuthenticationType.Trim(); + + if (string.Equals(normalizedAuthenticationType, ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ApiKeyAuthenticationType; + } + + if (string.Equals(normalizedAuthenticationType, ManagedIdentityAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ManagedIdentityAuthenticationType; + } + + return DefaultAuthenticationType; + } } diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchClientFactory.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchClientFactory.cs index d721a9a8..693ba899 100644 --- a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchClientFactory.cs +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchClientFactory.cs @@ -75,8 +75,39 @@ private static SearchIndexClient CreateSearchIndexClient(AzureAISearchConnection throw new InvalidOperationException("The Azure AI Search endpoint is invalid."); } - return !string.IsNullOrWhiteSpace(configuration.ApiKey) - ? new SearchIndexClient(endpoint, new AzureKeyCredential(configuration.ApiKey.Trim())) - : new SearchIndexClient(endpoint, new DefaultAzureCredential()); + return configuration.GetAuthenticationType() switch + { + AzureAISearchConnectionOptions.ApiKeyAuthenticationType => CreateApiKeyClient(configuration, endpoint), + AzureAISearchConnectionOptions.ManagedIdentityAuthenticationType => CreateManagedIdentityClient(configuration, endpoint), + _ => CreateDefaultCredentialClient(configuration, endpoint), + }; + } + + private static SearchIndexClient CreateApiKeyClient(AzureAISearchConnectionOptions configuration, Uri endpoint) + { + if (string.IsNullOrWhiteSpace(configuration.ApiKey)) + { + throw new InvalidOperationException("Azure AI Search API key authentication is configured, but no admin API key was provided."); + } + + return new SearchIndexClient(endpoint, new AzureKeyCredential(configuration.ApiKey.Trim())); + } + + private static SearchIndexClient CreateManagedIdentityClient(AzureAISearchConnectionOptions configuration, Uri endpoint) + { + return string.IsNullOrWhiteSpace(configuration.IdentityClientId) + ? new SearchIndexClient(endpoint, new ManagedIdentityCredential(ManagedIdentityId.SystemAssigned)) + : new SearchIndexClient(endpoint, new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(configuration.IdentityClientId.Trim()))); + } + + private static SearchIndexClient CreateDefaultCredentialClient(AzureAISearchConnectionOptions configuration, Uri endpoint) + { + var credentialOptions = new DefaultAzureCredentialOptions(); + if (!string.IsNullOrWhiteSpace(configuration.IdentityClientId)) + { + credentialOptions.ManagedIdentityClientId = configuration.IdentityClientId.Trim(); + } + + return new SearchIndexClient(endpoint, new DefaultAzureCredential(credentialOptions)); } } diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs index 88a82af6..3313e590 100644 --- a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs @@ -42,13 +42,18 @@ public AzureAISearchIndexManager( public string ComposeIndexFullName(IIndexProfileInfo profile) { ArgumentNullException.ThrowIfNull(profile); + var normalizedIndexName = profile.IndexName?.Trim(); if (string.IsNullOrWhiteSpace(normalizedIndexName)) { return normalizedIndexName; } - return string.IsNullOrWhiteSpace(_options.IndexPrefix) ? normalizedIndexName : string.Concat(_options.IndexPrefix.Trim(), normalizedIndexName); + var indexPrefix = _options.GetResolvedIndexPrefix(); + + return string.IsNullOrWhiteSpace(indexPrefix) + ? normalizedIndexName + : string.Concat(indexPrefix.Trim(), normalizedIndexName); } /// @@ -59,6 +64,7 @@ public string ComposeIndexFullName(IIndexProfileInfo profile) public async Task ExistsAsync(IIndexProfileInfo profile, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(profile); + var indexFullName = profile.IndexFullName ?? ComposeIndexFullName(profile); if (string.IsNullOrEmpty(indexFullName)) { @@ -97,6 +103,7 @@ public async Task CreateAsync(IIndexProfileInfo profile, IReadOnlyCollection(); @@ -106,7 +113,9 @@ public async Task CreateAsync(IIndexProfileInfo profile, IReadOnlyCollection(); + using var scope = _serviceProvider.CreateScope(); + var indexingService = scope.ServiceProvider.GetRequiredService(); switch (workItem.Type) { diff --git a/tests/CrestApps.Core.Tests/Core/Services/SearchProviderClientFactoryTests.cs b/tests/CrestApps.Core.Tests/Core/Services/SearchProviderClientFactoryTests.cs index cf52c374..8be1b080 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/SearchProviderClientFactoryTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/SearchProviderClientFactoryTests.cs @@ -1,8 +1,11 @@ using Azure.Search.Documents; +using Azure.Search.Documents.Indexes; using CrestApps.Core.Azure.AISearch; using CrestApps.Core.Azure.AISearch.Services; using CrestApps.Core.Elasticsearch; using CrestApps.Core.Elasticsearch.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -85,4 +88,76 @@ public void AzureCreateSearchClient_ShouldRequireIndexName() Assert.Throws(() => factory.CreateSearchClient(" ")); } + + [Fact] + public void AzureCreateSearchIndexClient_ShouldRequireApiKeyWhenApiKeyAuthenticationIsConfigured() + { + var factory = new AzureAISearchClientFactory(Options.Create(new AzureAISearchConnectionOptions + { + Endpoint = "https://example.search.windows.net", + AuthenticationType = "ApiKey", + })); + + var exception = Assert.Throws(() => factory.CreateSearchIndexClient()); + + Assert.Contains("admin API key", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AzureCreateSearchIndexClient_ShouldAllowDefaultAuthenticationWithoutApiKey() + { + var factory = new AzureAISearchClientFactory(Options.Create(new AzureAISearchConnectionOptions + { + Endpoint = "https://example.search.windows.net", + AuthenticationType = "Default", + })); + + var client = factory.CreateSearchIndexClient(); + + Assert.NotNull(client); + } + + [Fact] + public void AzureCreateSearchIndexClient_ShouldAllowManagedIdentityAuthenticationWithoutApiKey() + { + var factory = new AzureAISearchClientFactory(Options.Create(new AzureAISearchConnectionOptions + { + Endpoint = "https://example.search.windows.net", + AuthenticationType = "ManagedIdentity", + IdentityClientId = "11111111-1111-1111-1111-111111111111", + })); + + var client = factory.CreateSearchIndexClient(); + + Assert.NotNull(client); + } + + [Fact] + public void AddCoreAzureAISearchServices_ShouldBindConfiguredOptions() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CrestApps:AzureAISearch:Endpoint"] = "https://example.search.windows.net", + ["CrestApps:AzureAISearch:IndexPrefix"] = "legacy-", + ["CrestApps:AzureAISearch:AuthenticationType"] = "ApiKey", + ["CrestApps:AzureAISearch:ApiKey"] = "test-key", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCoreAzureAISearchServices(configuration.GetSection("CrestApps:AzureAISearch")); + + using var serviceProvider = services.BuildServiceProvider(); + + var options = serviceProvider.GetRequiredService>().Value; + var client = serviceProvider.GetRequiredService(); + + Assert.Equal("test-key", options.ApiKey); + Assert.Equal("legacy-", options.GetResolvedIndexPrefix()); + Assert.True(options.UsesApiKeyAuthentication()); + Assert.Equal("ApiKey", options.AuthenticationType); + Assert.NotNull(client); + } }