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..3db9a2c 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.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); + + 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); }