From de80e63c60e9d2d8ed3339a46d2b9175a43c7342 Mon Sep 17 00:00:00 2001 From: "Joseph P. Short" Date: Fri, 24 Apr 2026 12:00:41 -0400 Subject: [PATCH 1/2] Support PUT /indexes/{name} for create-or-update semantics Adds a PUT handler that creates the index when absent and overwrites its schema when present, matching Azure AI Search's CreateOrUpdateIndex behavior so the Azure SDK's CreateOrUpdateIndexAsync works against the emulator. On update, cached Lucene reader/directory entries are cleared so schema changes take effect. Closes #30. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../EmulatorIntegrationTests.cs | 72 +++++++++++++++++++ .../Controllers/IndexesController.cs | 41 +++++++++++ .../Repositories/FileSearchIndexRepository.cs | 14 ++++ .../Repositories/ISearchIndexRepository.cs | 2 + 4 files changed, 129 insertions(+) diff --git a/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs index 08d77b9..60d1a7b 100644 --- a/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs +++ b/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs @@ -319,6 +319,78 @@ public async Task SearchDocuments_WithPaging_ShouldReturnPagedResults() await indexClient.DeleteIndexAsync(indexName); } + [Fact] + public async Task CreateOrUpdateIndex_WhenIndexDoesNotExist_ShouldCreate() + { + // Arrange + const string indexName = "test-createorupdate-create"; + var indexClient = factory.CreateSearchIndexClient(); + + // Ensure a clean slate + try + { + await indexClient.DeleteIndexAsync(indexName); + } + catch (Azure.RequestFailedException ex) when (ex.Status == 404) + { + // expected + } + + var index = new SearchIndex(indexName) + { + Fields = + [ + new SearchField(nameof(Product.Id), SearchFieldDataType.String) { IsKey = true, IsStored = true, IsSearchable = true, IsFilterable = true }, + new SearchField(nameof(Product.Name), SearchFieldDataType.String) { IsSearchable = true, IsStored = true } + ] + }; + + // Act - PUT to a non-existent index should create it + var result = await indexClient.CreateOrUpdateIndexAsync(index); + + // Assert + Assert.NotNull(result); + Assert.Equal(indexName, result.Value.Name); + + var retrieved = await indexClient.GetIndexAsync(indexName); + Assert.NotNull(retrieved); + Assert.Equal(indexName, retrieved.Value.Name); + Assert.Equal(2, retrieved.Value.Fields.Count); + + // Cleanup + await indexClient.DeleteIndexAsync(indexName); + } + + [Fact] + public async Task CreateOrUpdateIndex_WhenIndexExists_ShouldUpdateSchema() + { + // Arrange + const string indexName = "test-createorupdate-update"; + var indexClient = factory.CreateSearchIndexClient(); + + await CreateIndexAsync(indexClient, indexName); + + // Pull the current index and add a backward-compatible field + var existing = (await indexClient.GetIndexAsync(indexName)).Value; + var originalFieldCount = existing.Fields.Count; + + existing.Fields.Add(new SearchField("Tags", SearchFieldDataType.String) { IsFilterable = true, IsStored = true }); + + // Act - PUT on an existing index should update its schema + var result = await indexClient.CreateOrUpdateIndexAsync(existing); + + // Assert + Assert.NotNull(result); + Assert.Equal(indexName, result.Value.Name); + + var retrieved = await indexClient.GetIndexAsync(indexName); + Assert.Equal(originalFieldCount + 1, retrieved.Value.Fields.Count); + Assert.Contains(retrieved.Value.Fields, f => f.Name == "Tags"); + + // Cleanup + await indexClient.DeleteIndexAsync(indexName); + } + [Fact] public async Task DeleteIndex_ShouldSucceed() { diff --git a/AzureSearchEmulator/Controllers/IndexesController.cs b/AzureSearchEmulator/Controllers/IndexesController.cs index d3ed7fc..0eaa335 100644 --- a/AzureSearchEmulator/Controllers/IndexesController.cs +++ b/AzureSearchEmulator/Controllers/IndexesController.cs @@ -64,6 +64,47 @@ public async Task Post() //([FromBody] SearchIndex? index) return Created(index); } + [HttpPut] + [Route("indexes({key})")] + [Route("indexes/{key}")] + public async Task Put(string key) + { + // Strip quotes that may be captured from OData-style URLs + key = key.Trim('\''); + + // HACK.PI: For some reason, having this as a parameter with [FromBody] fails to deserialize properly. + using var sr = new StreamReader(Request.Body); + var indexJson = await sr.ReadToEndAsync(); + var index = JsonSerializer.Deserialize(indexJson, jsonSerializerOptions); + + if (index == null || !ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (!string.Equals(index.Name, key, StringComparison.OrdinalIgnoreCase)) + { + ModelState.AddModelError(nameof(index.Name), "The index name in the request body must match the name in the URL."); + return BadRequest(ModelState); + } + + var existing = await searchIndexRepository.Get(key); + + if (existing == null) + { + await searchIndexRepository.Create(index); + return Created(index); + } + + await searchIndexRepository.Update(index); + + // Clear cached Lucene resources so schema changes take effect + luceneIndexReaderFactory.ClearCachedReader(index.Name); + luceneDirectoryFactory.ClearCachedDirectory(index.Name); + + return Ok(index); + } + [HttpDelete] [Route("indexes({key})")] [Route("indexes/{key}")] diff --git a/AzureSearchEmulator/Repositories/FileSearchIndexRepository.cs b/AzureSearchEmulator/Repositories/FileSearchIndexRepository.cs index 29a7899..fecaec4 100644 --- a/AzureSearchEmulator/Repositories/FileSearchIndexRepository.cs +++ b/AzureSearchEmulator/Repositories/FileSearchIndexRepository.cs @@ -62,6 +62,20 @@ public Task Create(SearchIndex index) return WriteAllTextAsync(file, json); } + public Task Update(SearchIndex index) + { + if (!Directory.Exists(_options.IndexesDirectory)) + { + Directory.CreateDirectory(_options.IndexesDirectory); + } + + string file = GetIndexFileName(index.Name); + + string json = JsonSerializer.Serialize(index, jsonSerializerOptions); + + return WriteAllTextAsync(file, json); + } + public Task Delete(SearchIndex index) { if (!Directory.Exists(_options.IndexesDirectory)) diff --git a/AzureSearchEmulator/Repositories/ISearchIndexRepository.cs b/AzureSearchEmulator/Repositories/ISearchIndexRepository.cs index 20b2c6f..f2f7422 100644 --- a/AzureSearchEmulator/Repositories/ISearchIndexRepository.cs +++ b/AzureSearchEmulator/Repositories/ISearchIndexRepository.cs @@ -10,5 +10,7 @@ public interface ISearchIndexRepository Task Create(SearchIndex index); + Task Update(SearchIndex index); + Task Delete(SearchIndex index); } From 0741f7e10f8f427ceac9c0842bf107336e0a84da Mon Sep 17 00:00:00 2001 From: "Joseph P. Short" Date: Fri, 24 Apr 2026 12:04:00 -0400 Subject: [PATCH 2/2] Fix HACK comment tag in new PUT handler Co-Authored-By: Claude Opus 4.7 (1M context) --- AzureSearchEmulator/Controllers/IndexesController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AzureSearchEmulator/Controllers/IndexesController.cs b/AzureSearchEmulator/Controllers/IndexesController.cs index 0eaa335..3db9a2c 100644 --- a/AzureSearchEmulator/Controllers/IndexesController.cs +++ b/AzureSearchEmulator/Controllers/IndexesController.cs @@ -72,7 +72,7 @@ public async Task Put(string key) // Strip quotes that may be captured from OData-style URLs key = key.Trim('\''); - // HACK.PI: For some reason, having this as a parameter with [FromBody] fails to deserialize properly. + // HACK.JS: For some reason, having this as a parameter with [FromBody] fails to deserialize properly. using var sr = new StreamReader(Request.Body); var indexJson = await sr.ReadToEndAsync(); var index = JsonSerializer.Deserialize(indexJson, jsonSerializerOptions);