Skip to content

Commit 4ec17f8

Browse files
Support PUT /indexes/{name} for create-or-update (closes #30) (#31)
* 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) <noreply@anthropic.com> * Fix HACK comment tag in new PUT handler Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 825d11a commit 4ec17f8

4 files changed

Lines changed: 129 additions & 0 deletions

File tree

AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,78 @@ public async Task SearchDocuments_WithPaging_ShouldReturnPagedResults()
319319
await indexClient.DeleteIndexAsync(indexName);
320320
}
321321

322+
[Fact]
323+
public async Task CreateOrUpdateIndex_WhenIndexDoesNotExist_ShouldCreate()
324+
{
325+
// Arrange
326+
const string indexName = "test-createorupdate-create";
327+
var indexClient = factory.CreateSearchIndexClient();
328+
329+
// Ensure a clean slate
330+
try
331+
{
332+
await indexClient.DeleteIndexAsync(indexName);
333+
}
334+
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
335+
{
336+
// expected
337+
}
338+
339+
var index = new SearchIndex(indexName)
340+
{
341+
Fields =
342+
[
343+
new SearchField(nameof(Product.Id), SearchFieldDataType.String) { IsKey = true, IsStored = true, IsSearchable = true, IsFilterable = true },
344+
new SearchField(nameof(Product.Name), SearchFieldDataType.String) { IsSearchable = true, IsStored = true }
345+
]
346+
};
347+
348+
// Act - PUT to a non-existent index should create it
349+
var result = await indexClient.CreateOrUpdateIndexAsync(index);
350+
351+
// Assert
352+
Assert.NotNull(result);
353+
Assert.Equal(indexName, result.Value.Name);
354+
355+
var retrieved = await indexClient.GetIndexAsync(indexName);
356+
Assert.NotNull(retrieved);
357+
Assert.Equal(indexName, retrieved.Value.Name);
358+
Assert.Equal(2, retrieved.Value.Fields.Count);
359+
360+
// Cleanup
361+
await indexClient.DeleteIndexAsync(indexName);
362+
}
363+
364+
[Fact]
365+
public async Task CreateOrUpdateIndex_WhenIndexExists_ShouldUpdateSchema()
366+
{
367+
// Arrange
368+
const string indexName = "test-createorupdate-update";
369+
var indexClient = factory.CreateSearchIndexClient();
370+
371+
await CreateIndexAsync(indexClient, indexName);
372+
373+
// Pull the current index and add a backward-compatible field
374+
var existing = (await indexClient.GetIndexAsync(indexName)).Value;
375+
var originalFieldCount = existing.Fields.Count;
376+
377+
existing.Fields.Add(new SearchField("Tags", SearchFieldDataType.String) { IsFilterable = true, IsStored = true });
378+
379+
// Act - PUT on an existing index should update its schema
380+
var result = await indexClient.CreateOrUpdateIndexAsync(existing);
381+
382+
// Assert
383+
Assert.NotNull(result);
384+
Assert.Equal(indexName, result.Value.Name);
385+
386+
var retrieved = await indexClient.GetIndexAsync(indexName);
387+
Assert.Equal(originalFieldCount + 1, retrieved.Value.Fields.Count);
388+
Assert.Contains(retrieved.Value.Fields, f => f.Name == "Tags");
389+
390+
// Cleanup
391+
await indexClient.DeleteIndexAsync(indexName);
392+
}
393+
322394
[Fact]
323395
public async Task DeleteIndex_ShouldSucceed()
324396
{

AzureSearchEmulator/Controllers/IndexesController.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,47 @@ public async Task<IActionResult> Post() //([FromBody] SearchIndex? index)
6464
return Created(index);
6565
}
6666

67+
[HttpPut]
68+
[Route("indexes({key})")]
69+
[Route("indexes/{key}")]
70+
public async Task<IActionResult> Put(string key)
71+
{
72+
// Strip quotes that may be captured from OData-style URLs
73+
key = key.Trim('\'');
74+
75+
// HACK.JS: For some reason, having this as a parameter with [FromBody] fails to deserialize properly.
76+
using var sr = new StreamReader(Request.Body);
77+
var indexJson = await sr.ReadToEndAsync();
78+
var index = JsonSerializer.Deserialize<SearchIndex>(indexJson, jsonSerializerOptions);
79+
80+
if (index == null || !ModelState.IsValid)
81+
{
82+
return BadRequest(ModelState);
83+
}
84+
85+
if (!string.Equals(index.Name, key, StringComparison.OrdinalIgnoreCase))
86+
{
87+
ModelState.AddModelError(nameof(index.Name), "The index name in the request body must match the name in the URL.");
88+
return BadRequest(ModelState);
89+
}
90+
91+
var existing = await searchIndexRepository.Get(key);
92+
93+
if (existing == null)
94+
{
95+
await searchIndexRepository.Create(index);
96+
return Created(index);
97+
}
98+
99+
await searchIndexRepository.Update(index);
100+
101+
// Clear cached Lucene resources so schema changes take effect
102+
luceneIndexReaderFactory.ClearCachedReader(index.Name);
103+
luceneDirectoryFactory.ClearCachedDirectory(index.Name);
104+
105+
return Ok(index);
106+
}
107+
67108
[HttpDelete]
68109
[Route("indexes({key})")]
69110
[Route("indexes/{key}")]

AzureSearchEmulator/Repositories/FileSearchIndexRepository.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ public Task Create(SearchIndex index)
6262
return WriteAllTextAsync(file, json);
6363
}
6464

65+
public Task Update(SearchIndex index)
66+
{
67+
if (!Directory.Exists(_options.IndexesDirectory))
68+
{
69+
Directory.CreateDirectory(_options.IndexesDirectory);
70+
}
71+
72+
string file = GetIndexFileName(index.Name);
73+
74+
string json = JsonSerializer.Serialize(index, jsonSerializerOptions);
75+
76+
return WriteAllTextAsync(file, json);
77+
}
78+
6579
public Task<bool> Delete(SearchIndex index)
6680
{
6781
if (!Directory.Exists(_options.IndexesDirectory))

AzureSearchEmulator/Repositories/ISearchIndexRepository.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ public interface ISearchIndexRepository
1010

1111
Task Create(SearchIndex index);
1212

13+
Task Update(SearchIndex index);
14+
1315
Task<bool> Delete(SearchIndex index);
1416
}

0 commit comments

Comments
 (0)