diff --git a/AzureSearchEmulator.IntegrationTests/AliasIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/AliasIntegrationTests.cs new file mode 100644 index 0000000..d10c53f --- /dev/null +++ b/AzureSearchEmulator.IntegrationTests/AliasIntegrationTests.cs @@ -0,0 +1,186 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Azure.Search.Documents.Indexes.Models; +using Xunit; + +namespace AzureSearchEmulator.IntegrationTests; + +/// +/// Alias CRUD and resolution against a containerized emulator. The pinned SDK predates aliases, +/// so the alias routes are driven over raw HTTP; document operations go through the SDK's +/// SearchClient, addressed by the alias name. +/// +public class AliasIntegrationTests(EmulatorFactory factory) + : IClassFixture +{ + private const string ApiVersion = "api-version=2025-11-01-preview"; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static string Unique(string prefix) => $"{prefix}-{Guid.NewGuid():N}"[..(prefix.Length + 9)]; + + private async Task CreateIndexAsync(string name) + { + await factory.CreateSearchIndexClient().CreateIndexAsync( + new SearchIndex(name) + { + Fields = + { + new SearchField("id", SearchFieldDataType.String) { IsKey = true }, + new SearchField("name", SearchFieldDataType.String) { IsSearchable = true }, + } + }, + Token); + } + + private static StringContent Json(string body) => new(body, Encoding.UTF8, "application/json"); + + private static string AliasBody(string name, params string[] indexes) => + JsonSerializer.Serialize(new { name, indexes }); + + [Fact] + public async Task Alias_CrudLifecycle() + { + var index = Unique("alias-idx"); + var alias = Unique("alias"); + await CreateIndexAsync(index); + using var http = factory.CreateHttpClient(); + + var put = await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, index)), Token); + Assert.Equal(HttpStatusCode.Created, put.StatusCode); + + var get = await http.GetAsync($"/aliases('{alias}')?{ApiVersion}", Token); + Assert.Equal(HttpStatusCode.OK, get.StatusCode); + using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(Token)); + Assert.Equal(alias, doc.RootElement.GetProperty("name").GetString()); + Assert.Equal(index, doc.RootElement.GetProperty("indexes")[0].GetString()); + + var list = await http.GetStringAsync($"/aliases?{ApiVersion}", Token); + Assert.Contains(alias, list); + + var update = await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, index)), Token); + Assert.Equal(HttpStatusCode.OK, update.StatusCode); + + var delete = await http.DeleteAsync($"/aliases('{alias}')?{ApiVersion}", Token); + Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode); + + Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync($"/aliases('{alias}')?{ApiVersion}", Token)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await http.DeleteAsync($"/aliases('{alias}')?{ApiVersion}", Token)).StatusCode); + } + + [Fact] + public async Task Alias_ValidationErrors() + { + var index = Unique("alias-idx"); + await CreateIndexAsync(index); + using var http = factory.CreateHttpClient(); + var alias = Unique("alias"); + + // Missing target, two targets, and a target that does not exist + Assert.Equal(HttpStatusCode.BadRequest, + (await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias)), Token)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, + (await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, index, index)), Token)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, + (await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, "missing-index")), Token)).StatusCode); + + // The alias name may not be an existing index's + Assert.Equal(HttpStatusCode.BadRequest, + (await http.PutAsync($"/aliases('{index}')?{ApiVersion}", Json(AliasBody(index, index)), Token)).StatusCode); + + // Name in the body must match the URL + Assert.Equal(HttpStatusCode.BadRequest, + (await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody("other", index)), Token)).StatusCode); + + // POST of an existing alias conflicts + Assert.Equal(HttpStatusCode.Created, (await http.PostAsync($"/aliases?{ApiVersion}", Json(AliasBody(alias, index)), Token)).StatusCode); + Assert.Equal(HttpStatusCode.Conflict, (await http.PostAsync($"/aliases?{ApiVersion}", Json(AliasBody(alias, index)), Token)).StatusCode); + } + + [Fact] + public async Task Index_CannotBeCreatedWithAnAliasName() + { + var index = Unique("alias-idx"); + var alias = Unique("alias"); + await CreateIndexAsync(index); + using var http = factory.CreateHttpClient(); + await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, index)), Token); + + var ex = await Assert.ThrowsAsync(() => CreateIndexAsync(alias)); + Assert.Equal(409, ex.Status); + } + + [Fact] + public async Task DocumentRoutes_ResolveAliasAndFollowRepoint() + { + var first = Unique("alias-idx"); + var second = Unique("alias-idx"); + var alias = Unique("alias"); + await CreateIndexAsync(first); + await CreateIndexAsync(second); + using var http = factory.CreateHttpClient(); + await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, first)), Token); + + // Index through the alias + var indexed = await http.PostAsync( + $"/indexes/{alias}/docs/search.index?api-version=2024-07-01", + Json("""{ "value": [ { "@search.action": "upload", "id": "1", "name": "hello world" } ] }"""), + Token); + Assert.Equal(HttpStatusCode.OK, indexed.StatusCode); + + var client = factory.CreateSearchClient(alias); + Assert.Equal(1, (await client.GetDocumentCountAsync(Token)).Value); + Assert.Equal("hello world", (await client.GetDocumentAsync>("1", cancellationToken: Token)).Value["name"].ToString()); + Assert.Equal(1, (await client.SearchAsync>("hello", cancellationToken: Token)).Value.GetResults().Count()); + + // The data landed in the first index, not under the alias name + Assert.Equal(1, (await factory.CreateSearchClient(first).GetDocumentCountAsync(Token)).Value); + Assert.Equal(0, (await factory.CreateSearchClient(second).GetDocumentCountAsync(Token)).Value); + + await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, second)), Token); + Assert.Equal(0, (await client.GetDocumentCountAsync(Token)).Value); + } + + [Fact] + public async Task DocumentRoutes_ReturnNotFoundWhenTargetIsDeleted() + { + var index = Unique("alias-idx"); + var alias = Unique("alias"); + await CreateIndexAsync(index); + using var http = factory.CreateHttpClient(); + await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, index)), Token); + + await factory.CreateSearchIndexClient().DeleteIndexAsync(index, Token); + + var ex = await Assert.ThrowsAsync( + async () => await factory.CreateSearchClient(alias).GetDocumentCountAsync(Token)); + Assert.Equal(404, ex.Status); + } + + [Fact] + public async Task IndexManagementRoutes_DoNotResolveAliases() + { + var index = Unique("alias-idx"); + var alias = Unique("alias"); + await CreateIndexAsync(index); + using var http = factory.CreateHttpClient(); + await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, index)), Token); + + Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync($"/indexes('{alias}')?{ApiVersion}", Token)).StatusCode); + } + + [Fact] + public async Task ServiceStatistics_CountAliases() + { + var index = Unique("alias-idx"); + var alias = Unique("alias"); + await CreateIndexAsync(index); + using var http = factory.CreateHttpClient(); + await http.PutAsync($"/aliases('{alias}')?{ApiVersion}", Json(AliasBody(alias, index)), Token); + + using var stats = JsonDocument.Parse(await http.GetStringAsync($"/servicestats?{ApiVersion}", Token)); + + Assert.True(stats.RootElement.GetProperty("counters").GetProperty("aliasesCount").GetProperty("usage").GetInt32() >= 1); + } +} diff --git a/AzureSearchEmulator.UnitTests/AliasTests.cs b/AzureSearchEmulator.UnitTests/AliasTests.cs new file mode 100644 index 0000000..714f955 --- /dev/null +++ b/AzureSearchEmulator.UnitTests/AliasTests.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using AzureSearchEmulator.Indexing; +using AzureSearchEmulator.Models; +using AzureSearchEmulator.Repositories; +using Microsoft.Extensions.Options; +using Xunit; + +namespace AzureSearchEmulator.UnitTests; + +/// +/// Covers alias validation, file storage and resolution to an index. +/// +public sealed class AliasTests : IDisposable +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + private readonly string _directory = Path.Combine(Path.GetTempPath(), "alias-tests-" + Guid.NewGuid().ToString("N")); + private readonly FileAliasRepository _aliases; + private readonly FileSearchIndexRepository _indexes; + private readonly IndexResolver _resolver; + + public AliasTests() + { + var options = Microsoft.Extensions.Options.Options.Create(new EmulatorOptions { IndexesDirectory = _directory }); + _aliases = new FileAliasRepository(Options, options); + _indexes = new FileSearchIndexRepository(Options, options); + _resolver = new IndexResolver(_indexes, _aliases); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + private static SearchAlias Alias(string name, params string[] indexes) => + new() { Name = name, Indexes = [.. indexes] }; + + [Fact] + public void ValidAlias_IsAccepted() => + Assert.Null(AliasValidator.FindInvalidAlias(Alias("live", "products-v1"))); + + [Theory] + [InlineData("")] + [InlineData("-leading")] + [InlineData("has.dot")] + [InlineData("has/slash")] + public void InvalidName_IsRejected(string name) => + Assert.NotNull(AliasValidator.FindInvalidAlias(Alias(name, "products-v1"))); + + [Fact] + public void NoTarget_IsRejected() => + Assert.Contains("exactly one", AliasValidator.FindInvalidAlias(Alias("live"))); + + [Fact] + public void TwoTargets_AreRejected() => + Assert.Contains("exactly one", AliasValidator.FindInvalidAlias(Alias("live", "a", "b"))); + + [Fact] + public void TargetWithPathCharacters_IsRejected() => + Assert.NotNull(AliasValidator.FindInvalidAlias(Alias("live", "../a"))); + + [Fact] + public async Task Repository_RoundTripsAnAliasAndItsUnmodelledProperties() + { + var alias = JsonSerializer.Deserialize( + """{ "name": "Live", "indexes": ["a"], "extra": 1 }""", Options)!; + + await _aliases.Create(alias); + + var read = await _aliases.Get("live"); + + Assert.Equal("Live", read!.Name); + Assert.Equal(["a"], read.Indexes); + Assert.Contains("extra", read.AdditionalProperties!.Keys); + } + + [Fact] + public async Task Repository_RefusesDuplicateCreate() + { + await _aliases.Create(Alias("live", "a")); + + await Assert.ThrowsAsync(() => _aliases.Create(Alias("LIVE", "b"))); + } + + [Fact] + public async Task Repository_UpdateRepointsAndDeleteRemoves() + { + await _aliases.Create(Alias("live", "a")); + await _aliases.Update(Alias("live", "b")); + + Assert.Equal(["b"], (await _aliases.Get("live"))!.Indexes); + Assert.Single(await _aliases.GetAll().ToListAsync(TestContext.Current.CancellationToken)); + + Assert.True(await _aliases.Delete(Alias("live", "b"))); + Assert.Null(await _aliases.Get("live")); + Assert.False(await _aliases.Delete(Alias("live", "b"))); + } + + [Fact] + public async Task Repository_ListingDoesNotIncludeIndexes() + { + await _indexes.Create(new SearchIndex { Name = "a" }); + await _aliases.Create(Alias("live", "a")); + + Assert.Equal(["live"], (await _aliases.GetAll().ToListAsync(TestContext.Current.CancellationToken)).Select(i => i.Name)); + Assert.Equal(["a"], (await _indexes.GetAll().ToListAsync(TestContext.Current.CancellationToken)).Select(i => i.Name)); + } + + [Fact] + public async Task Resolver_ReturnsIndexByNameOrThroughAlias() + { + await _indexes.Create(new SearchIndex { Name = "a" }); + await _aliases.Create(Alias("live", "a")); + + Assert.Equal("a", (await _resolver.Get("a"))!.Name); + Assert.Equal("a", (await _resolver.Get("live"))!.Name); + } + + [Fact] + public async Task Resolver_ReturnsNullForUnknownNameAndDanglingAlias() + { + await _aliases.Create(Alias("dangling", "gone")); + + Assert.Null(await _resolver.Get("nope")); + Assert.Null(await _resolver.Get("dangling")); + } +} diff --git a/AzureSearchEmulator/Controllers/AliasesController.cs b/AzureSearchEmulator/Controllers/AliasesController.cs new file mode 100644 index 0000000..52b0ccd --- /dev/null +++ b/AzureSearchEmulator/Controllers/AliasesController.cs @@ -0,0 +1,185 @@ +using System.Text.Json; +using AzureSearchEmulator.Indexing; +using AzureSearchEmulator.Models; +using AzureSearchEmulator.Repositories; +using Microsoft.AspNetCore.Mvc; + +namespace AzureSearchEmulator.Controllers; + +/// +/// The service-level /aliases routes. +/// +/// +/// Shaped like , and for the same reasons: a plain +/// writing System.Text.Json, so properties the emulator does not +/// model survive a round trip, with the value wrapper of a listing written by hand. +/// +[ApiController] +public class AliasesController( + JsonSerializerOptions jsonSerializerOptions, + IAliasRepository aliasRepository, + ISearchIndexRepository searchIndexRepository) + : ControllerBase +{ + [HttpGet] + [Route("aliases")] + public async Task Get() + { + var aliases = new List(); + + await foreach (var alias in aliasRepository.GetAll()) + { + aliases.Add(alias); + } + + return Json(new { value = aliases }); + } + + [HttpGet] + [Route("aliases({key})")] + [Route("aliases/{key}")] + public async Task Get(string key) + { + key = key.Trim('\''); + + if (!AliasValidator.IsValidName(key)) + { + return NotFound(); + } + + var alias = await aliasRepository.Get(key); + + if (alias == null) + { + return NotFound($"Alias '{key}' was not found."); + } + + return Json(alias); + } + + [HttpPost] + [Route("aliases")] + public async Task Post([FromBody] SearchAlias? alias) + { + if (alias == null || !ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (await FindInvalidAlias(alias) is { } error) + { + return BadRequest(error); + } + + try + { + await aliasRepository.Create(alias); + } + catch (SearchAliasExistsException ex) + { + return Conflict(ex.Message); + } + + return Json(alias, StatusCodes.Status201Created); + } + + /// + /// Create-or-update. Re-pointing an alias is its whole purpose, so an update may change the + /// target freely. + /// + [HttpPut] + [Route("aliases({key})")] + [Route("aliases/{key}")] + public async Task Put(string key, [FromBody] SearchAlias? alias) + { + key = key.Trim('\''); + + if (alias == null || !ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (!string.Equals(alias.Name, key, StringComparison.OrdinalIgnoreCase)) + { + ModelState.AddModelError(nameof(alias.Name), "The alias name in the request body must match the name in the URL."); + + return BadRequest(ModelState); + } + + if (await FindInvalidAlias(alias) is { } error) + { + return BadRequest(error); + } + + if (await aliasRepository.Get(key) == null) + { + await aliasRepository.Create(alias); + + return Json(alias, StatusCodes.Status201Created); + } + + await aliasRepository.Update(alias); + + return Json(alias); + } + + [HttpDelete] + [Route("aliases({key})")] + [Route("aliases/{key}")] + public async Task Delete(string key) + { + key = key.Trim('\''); + + if (!AliasValidator.IsValidName(key)) + { + return NotFound(); + } + + var alias = await aliasRepository.Get(key); + + if (alias == null) + { + return NotFound($"Alias '{key}' was not found."); + } + + await aliasRepository.Delete(alias); + + return NoContent(); + } + + private async Task FindInvalidAlias(SearchAlias alias) + { + if (AliasValidator.FindInvalidAlias(alias) is { } error) + { + return error; + } + + if (await searchIndexRepository.Get(alias.Name) != null) + { + return $"Alias name '{alias.Name}' is already used by an index."; + } + + if (await searchIndexRepository.Get(alias.Indexes[0]) == null) + { + return $"Alias '{alias.Name}' targets index '{alias.Indexes[0]}', which does not exist."; + } + + return null; + } + + private ContentResult Json(object value, int statusCode = StatusCodes.Status200OK) + { + if (statusCode == StatusCodes.Status201Created) + { + Response.Headers.Location = + $"{Request.Scheme}://{Request.Host}{Request.PathBase}/aliases('{((SearchAlias)value).Name}')"; + } + + return new ContentResult + { + Content = JsonSerializer.Serialize(value, jsonSerializerOptions), + ContentType = "application/json", + StatusCode = statusCode + }; + } +} diff --git a/AzureSearchEmulator/Controllers/DocumentIndexingController.cs b/AzureSearchEmulator/Controllers/DocumentIndexingController.cs index c54ee8b..e67207e 100644 --- a/AzureSearchEmulator/Controllers/DocumentIndexingController.cs +++ b/AzureSearchEmulator/Controllers/DocumentIndexingController.cs @@ -9,7 +9,7 @@ namespace AzureSearchEmulator.Controllers; public class DocumentIndexingController( JsonSerializerOptions jsonSerializerOptions, - ISearchIndexRepository searchIndexRepository, + IIndexResolver indexResolver, ISearchIndexer searchIndexer, ILogger logger) : ODataController @@ -36,7 +36,7 @@ public async Task IndexDocuments(string indexKey) // Strip quotes that may be captured from OData-style URLs indexKey = indexKey.Trim('\''); - var index = await searchIndexRepository.Get(indexKey); + var index = await indexResolver.Get(indexKey); if (index == null) { diff --git a/AzureSearchEmulator/Controllers/DocumentSearchingController.cs b/AzureSearchEmulator/Controllers/DocumentSearchingController.cs index 15c2550..270273a 100644 --- a/AzureSearchEmulator/Controllers/DocumentSearchingController.cs +++ b/AzureSearchEmulator/Controllers/DocumentSearchingController.cs @@ -10,7 +10,7 @@ namespace AzureSearchEmulator.Controllers; public class DocumentSearchingController( IIndexSearcher indexSearcher, - ISearchIndexRepository searchIndexRepository) + IIndexResolver indexResolver) : ODataController { private static readonly JsonSerializerOptions NextPageJsonOptions = new(JsonSerializerDefaults.Web) @@ -26,7 +26,7 @@ public async Task GetDocumentCount(string indexKey) // Strip quotes that may be captured from OData-style URLs indexKey = indexKey.Trim('\''); - var index = await searchIndexRepository.Get(indexKey); + var index = await indexResolver.Get(indexKey); if (index == null) { @@ -48,7 +48,7 @@ public async Task GetDocument(string indexKey, string key, indexKey = indexKey.Trim('\''); key = key.Trim('\''); - var index = await searchIndexRepository.Get(indexKey); + var index = await indexResolver.Get(indexKey); if (index == null) { @@ -139,7 +139,7 @@ public async Task SearchPost(string indexKey, [FromBody] SearchRe return BadRequest(ModelState); } - var index = await searchIndexRepository.Get(indexKey); + var index = await indexResolver.Get(indexKey); if (index == null) { @@ -237,7 +237,7 @@ public async Task SuggestPost(string indexKey, [FromBody] Suggest return BadRequest(ModelState); } - var index = await searchIndexRepository.Get(indexKey); + var index = await indexResolver.Get(indexKey); if (index == null) { @@ -315,7 +315,7 @@ public async Task AutocompletePost(string indexKey, [FromBody] Au return BadRequest(ModelState); } - var index = await searchIndexRepository.Get(indexKey); + var index = await indexResolver.Get(indexKey); if (index == null) { diff --git a/AzureSearchEmulator/Controllers/IndexesController.cs b/AzureSearchEmulator/Controllers/IndexesController.cs index 50bf957..0a8683f 100644 --- a/AzureSearchEmulator/Controllers/IndexesController.cs +++ b/AzureSearchEmulator/Controllers/IndexesController.cs @@ -13,6 +13,7 @@ public class IndexesController( JsonSerializerOptions jsonSerializerOptions, ISearchIndexRepository searchIndexRepository, ISynonymMapRepository synonymMapRepository, + IAliasRepository aliasRepository, ILuceneDirectoryFactory luceneDirectoryFactory, ILuceneIndexReaderFactory luceneIndexReaderFactory, ILuceneIndexWriterFactory luceneIndexWriterFactory) @@ -89,6 +90,11 @@ public async Task Post([FromBody] SearchIndex? index) return BadRequest(synonymMapError); } + if (await aliasRepository.Get(index.Name) != null) + { + return Conflict($"Index name '{index.Name}' is already used by an alias."); + } + try { await searchIndexRepository.Create(index); @@ -156,6 +162,11 @@ public async Task Put(string key, [FromBody] SearchIndex? index) if (existing == null) { + if (await aliasRepository.Get(index.Name) != null) + { + return Conflict($"Index name '{index.Name}' is already used by an alias."); + } + await searchIndexRepository.Create(index); return IndexJson(index, StatusCodes.Status201Created); } diff --git a/AzureSearchEmulator/Controllers/ServiceStatsController.cs b/AzureSearchEmulator/Controllers/ServiceStatsController.cs index 1b40625..8e1872c 100644 --- a/AzureSearchEmulator/Controllers/ServiceStatsController.cs +++ b/AzureSearchEmulator/Controllers/ServiceStatsController.cs @@ -8,7 +8,8 @@ namespace AzureSearchEmulator.Controllers; [Route("servicestats")] public class ServiceStatsController( ISearchIndexRepository searchIndexRepository, - ISynonymMapRepository synonymMapRepository) : ControllerBase + ISynonymMapRepository synonymMapRepository, + IAliasRepository aliasRepository) : ControllerBase { [HttpGet] public async Task Get() @@ -24,7 +25,13 @@ public async Task Get() { synonymMapCount++; } - + + var aliasCount = 0; + await foreach (var _ in aliasRepository.GetAll()) + { + aliasCount++; + } + var stats = new Dictionary { // Named for a GA version whose surface the emulator actually covers. The previous @@ -71,7 +78,7 @@ public async Task Get() }, aliasesCount = new { - usage = 0, + usage = aliasCount, quota = 30 }, vectorIndexSize = new diff --git a/AzureSearchEmulator/Indexing/AliasValidator.cs b/AzureSearchEmulator/Indexing/AliasValidator.cs new file mode 100644 index 0000000..7fa30c7 --- /dev/null +++ b/AzureSearchEmulator/Indexing/AliasValidator.cs @@ -0,0 +1,45 @@ +using System.Text.RegularExpressions; +using AzureSearchEmulator.Models; + +namespace AzureSearchEmulator.Indexing; + +/// +/// Checks the parts of an alias definition that need no lookup. That the target exists and the +/// name is free of an index's is checked by the controller, which has the repositories. +/// +public static partial class AliasValidator +{ + [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")] + private static partial Regex NamePattern(); + + public static bool IsValidName(string name) => NamePattern().IsMatch(name); + + /// + /// Returns a message describing the first problem with the alias, or null when it is usable. + /// + public static string? FindInvalidAlias(SearchAlias alias) + { + if (string.IsNullOrWhiteSpace(alias.Name)) + { + return "An alias must have a name."; + } + + if (!IsValidName(alias.Name)) + { + return $"Alias name '{alias.Name}' is invalid. Names start with a letter or digit, " + + "contain only letters, digits, dashes and underscores, and are at most 128 characters."; + } + + if (alias.Indexes is not { Count: 1 } || string.IsNullOrWhiteSpace(alias.Indexes[0])) + { + return $"Alias '{alias.Name}' must target exactly one index."; + } + + if (!IsValidName(alias.Indexes[0])) + { + return $"Alias '{alias.Name}' targets '{alias.Indexes[0]}', which is not a valid index name."; + } + + return null; + } +} diff --git a/AzureSearchEmulator/Models/SearchAlias.cs b/AzureSearchEmulator/Models/SearchAlias.cs new file mode 100644 index 0000000..f879de1 --- /dev/null +++ b/AzureSearchEmulator/Models/SearchAlias.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AzureSearchEmulator.Models; + +/// +/// An index alias: a service-level name that document operations resolve to one index. +/// +/// +/// An alias lets a client address a stable name while the index behind it is swapped, for +/// example when a new index is built alongside the old one and traffic is cut over. Azure +/// models the target as an array but accepts exactly one entry, so is a +/// list on the wire and enforces the single entry. +/// +/// Only document routes (search, get, count, suggest, autocomplete, index) resolve an alias. +/// The /indexes management routes address the index itself, as they do in Azure. +/// +public class SearchAlias +{ + public string Name { get; set; } = ""; + + /// + /// The one index the alias resolves to. + /// + public List Indexes { get; set; } = []; + + [JsonPropertyName("@odata.etag")] + public string? ETag { get; set; } + + /// + /// Preserves properties the emulator does not model, for the reason given on + /// . + /// + [JsonExtensionData] + public Dictionary? AdditionalProperties { get; set; } +} diff --git a/AzureSearchEmulator/Models/SearchAliasExistsException.cs b/AzureSearchEmulator/Models/SearchAliasExistsException.cs new file mode 100644 index 0000000..aa7b81d --- /dev/null +++ b/AzureSearchEmulator/Models/SearchAliasExistsException.cs @@ -0,0 +1,3 @@ +namespace AzureSearchEmulator.Models; + +public class SearchAliasExistsException(string aliasKey) : Exception($"Alias with key {aliasKey} already exists"); diff --git a/AzureSearchEmulator/Program.cs b/AzureSearchEmulator/Program.cs index a33c729..00f698e 100644 --- a/AzureSearchEmulator/Program.cs +++ b/AzureSearchEmulator/Program.cs @@ -129,6 +129,8 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Singleton so writers stay open across requests; the container disposes it on shutdown, diff --git a/AzureSearchEmulator/Repositories/FileAliasRepository.cs b/AzureSearchEmulator/Repositories/FileAliasRepository.cs new file mode 100644 index 0000000..9f1ff77 --- /dev/null +++ b/AzureSearchEmulator/Repositories/FileAliasRepository.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using AzureSearchEmulator.Models; +using Microsoft.Extensions.Options; +using static System.IO.File; + +namespace AzureSearchEmulator.Repositories; + +/// +/// Keeps each alias in its own JSON file, next to the index definitions. +/// +/// +/// Deliberately the same shape as , down to the +/// lower-cased file name and the guard on path characters — an alias name arrives from the +/// same untrusted route an index name does, and .. in it would otherwise escape the +/// directory. +/// +/// Unlike an index, an alias owns no Lucene directory, so has only the one +/// file to remove. +/// +public class FileAliasRepository(JsonSerializerOptions jsonSerializerOptions, IOptions options) + : IAliasRepository +{ + private const string FileSuffix = ".alias.json"; + + private readonly EmulatorOptions _options = options.Value; + + public async IAsyncEnumerable GetAll() + { + if (!Directory.Exists(_options.IndexesDirectory)) + { + yield break; + } + + var files = Directory.GetFiles(_options.IndexesDirectory, $"*{FileSuffix}"); + + foreach (var file in files) + { + yield return JsonSerializer.Deserialize(await ReadAllTextAsync(file), jsonSerializerOptions) + ?? throw new InvalidOperationException($"Invalid alias definition file: {file}"); + } + } + + public async Task Get(string key) + { + if (!Directory.Exists(_options.IndexesDirectory)) + { + return null; + } + + string file = GetAliasFileName(key); + + if (!Exists(file)) + { + return null; + } + + return JsonSerializer.Deserialize(await ReadAllTextAsync(file), jsonSerializerOptions); + } + + public Task Create(SearchAlias alias) + { + if (!Directory.Exists(_options.IndexesDirectory)) + { + Directory.CreateDirectory(_options.IndexesDirectory); + } + + string file = GetAliasFileName(alias.Name); + + if (Exists(file)) + { + throw new SearchAliasExistsException(alias.Name); + } + + return WriteAllTextAsync(file, JsonSerializer.Serialize(alias, jsonSerializerOptions)); + } + + public Task Update(SearchAlias alias) + { + if (!Directory.Exists(_options.IndexesDirectory)) + { + Directory.CreateDirectory(_options.IndexesDirectory); + } + + string file = GetAliasFileName(alias.Name); + + return WriteAllTextAsync(file, JsonSerializer.Serialize(alias, jsonSerializerOptions)); + } + + public Task Delete(SearchAlias alias) + { + if (!Directory.Exists(_options.IndexesDirectory)) + { + return Task.FromResult(false); + } + + string file = GetAliasFileName(alias.Name); + + if (!Exists(file)) + { + return Task.FromResult(false); + } + + File.Delete(file); + + return Task.FromResult(true); + } + + private string GetAliasFileName(string key) + { + if (key.Contains('.') || key.Contains('/') || key.Contains('\\')) + { + throw new ArgumentException("Alias file name cannot contain any of the following characters: . \\ /"); + } + + return Path.Combine(_options.IndexesDirectory, $"{key.ToLowerInvariant()}{FileSuffix}"); + } +} diff --git a/AzureSearchEmulator/Repositories/IAliasRepository.cs b/AzureSearchEmulator/Repositories/IAliasRepository.cs new file mode 100644 index 0000000..4dc02bc --- /dev/null +++ b/AzureSearchEmulator/Repositories/IAliasRepository.cs @@ -0,0 +1,24 @@ +using AzureSearchEmulator.Models; + +namespace AzureSearchEmulator.Repositories; + +/// +/// Stores the service's index aliases. +/// +/// +/// Separate from for the reason given on +/// : an alias is a service-level resource with its own routes +/// and lifetime. Whether its target exists is a controller concern; the repository only stores. +/// +public interface IAliasRepository +{ + IAsyncEnumerable GetAll(); + + Task Get(string key); + + Task Create(SearchAlias alias); + + Task Update(SearchAlias alias); + + Task Delete(SearchAlias alias); +} diff --git a/AzureSearchEmulator/Repositories/IIndexResolver.cs b/AzureSearchEmulator/Repositories/IIndexResolver.cs new file mode 100644 index 0000000..5111701 --- /dev/null +++ b/AzureSearchEmulator/Repositories/IIndexResolver.cs @@ -0,0 +1,16 @@ +using AzureSearchEmulator.Models; + +namespace AzureSearchEmulator.Repositories; + +/// +/// Turns the name a document route was addressed with into an index, following an alias if the +/// name is one. +/// +public interface IIndexResolver +{ + /// + /// Returns the index the name refers to, or null when it names neither an index nor an alias + /// whose target still exists. + /// + Task Get(string indexOrAliasName); +} diff --git a/AzureSearchEmulator/Repositories/IndexResolver.cs b/AzureSearchEmulator/Repositories/IndexResolver.cs new file mode 100644 index 0000000..8749c86 --- /dev/null +++ b/AzureSearchEmulator/Repositories/IndexResolver.cs @@ -0,0 +1,26 @@ +using AzureSearchEmulator.Models; + +namespace AzureSearchEmulator.Repositories; + +/// +/// An index is looked up first. The two namespaces cannot overlap — creating either refuses a +/// name the other holds — so the order only matters for a directory edited by hand. An alias +/// whose target was deleted resolves to null, which the routes report as a missing index. +/// +public class IndexResolver(ISearchIndexRepository searchIndexRepository, IAliasRepository aliasRepository) + : IIndexResolver +{ + public async Task Get(string indexOrAliasName) + { + var index = await searchIndexRepository.Get(indexOrAliasName); + + if (index != null) + { + return index; + } + + var alias = await aliasRepository.Get(indexOrAliasName); + + return alias?.Indexes.Count == 1 ? await searchIndexRepository.Get(alias.Indexes[0]) : null; + } +} diff --git a/README.md b/README.md index 221cd1e..808f1e3 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ Currently, there is support (to varying degrees) for the following Azure Search custom `normalizers` defined on the index (see Normalizers below) * Synonym maps: service-level `synonymmaps` in Solr format, named by a field's `synonymMaps` to widen queries at search time (see Synonym maps below) +* Index aliases: service-level `aliases` that document routes resolve to one index + (see Aliases below) * Vector search: `Collection(Edm.Single)` fields with `vectorSearch` profiles and algorithms, `vectorQueries` with all three metrics and both filter modes, and hybrid search fusing text and vector rankings with Reciprocal Rank Fusion (see Vector fields below) @@ -489,6 +491,25 @@ that names a map it cannot use — one that is not searchable, or holds no text the index is created, as is a field naming a map the service does not have. Only the `solr` format is supported, which is the only one Azure Search itself supports. +### Aliases + +An alias is a stable name for one index, so an index can be rebuilt and swapped without clients +changing the name they use: + +```http +PUT /aliases('live')?api-version=2025-11-01-preview +Content-Type: application/json + +{ "name": "live", "indexes": ["products-v2"] } +``` + +`GET /aliases`, and `GET`, `PUT` and `DELETE` on `/aliases('live')`, behave as the service's do. +An alias must target exactly one existing index and may not share a name with an index. Search, +get, count, suggest, autocomplete and document indexing accept the alias wherever they accept an +index name; the `/indexes` routes do not. Re-pointing an alias is a `PUT` with a new target. An +alias whose index was deleted answers `404` until it is re-pointed. Aliases are counted in +`aliasesCount` of `/servicestats`. See `docs/specs/aliases.md`. + ### Filter semantics Null comparison, lexicographic string ranges, and the two full-text filter functions behave diff --git a/docs/specs/aliases.md b/docs/specs/aliases.md new file mode 100644 index 0000000..6850fba --- /dev/null +++ b/docs/specs/aliases.md @@ -0,0 +1,67 @@ +# Index aliases + +An alias is a service-level name that document operations resolve to one index, so a client can +address a stable name while the index behind it is rebuilt and swapped. + +## Storage + +One JSON file per alias, `.alias.json`, in `IndexesDirectory` beside the index +definitions — the same layout as synonym maps (`FileAliasRepository`, behind `IAliasRepository`). +The same guard on `.`, `/` and `\` in a name applies, since the name comes from an untrusted +route. Properties the emulator does not model are kept in `AdditionalProperties`. + +```json +{ "name": "live", "indexes": ["products-v2"], "@odata.etag": null } +``` + +## Routes + +Available with any `api-version`; Azure serves aliases from `2025-11-01-preview`. + +| Route | Behaviour | +|---|---| +| `GET /aliases` | `{ "value": [ ... ] }` | +| `POST /aliases` | create; `201`, `409` if it exists | +| `PUT /aliases('x')`, `/aliases/x` | create-or-update; `201` or `200` | +| `GET /aliases('x')`, `/aliases/x` | `200` or `404` | +| `DELETE /aliases('x')`, `/aliases/x` | `204` or `404` | + +Responses are written with System.Text.Json by a plain `ControllerBase`, like `/synonymmaps`. + +## Validation (`400` unless stated) + +* Name present; starts with a letter or digit; letters, digits, `-` and `_`; at most 128 characters. +* `indexes` has exactly one entry, and that entry is an existing index. +* The name is not the name of an existing index. +* On `PUT`, the body name matches the URL name. +* Creating an index whose name is held by an alias is refused with `409`. + +Re-pointing an alias (`PUT` with a different target) is allowed. Deleting an index an alias +targets is allowed, leaving the alias dangling. + +## Resolution + +`IIndexResolver` looks the name up as an index, then as an alias. The document routes — search +(GET and POST), get, `$count`, suggest, autocomplete and indexing — use it. A dangling alias +resolves to nothing and gets the same `404` as a missing index. The `/indexes` routes address +indexes only and never resolve aliases, as in Azure. + +## Quota + +`/servicestats` reports `aliasesCount.usage` as the number of stored aliases against the existing +quota of 30. The quota is reported, not enforced, as for synonym maps. + +## Errors + +Plain-text bodies naming the alias or index, as the synonym map routes do. + +## Not covered + +* ETag / `If-Match` concurrency. +* Aliases targeting more than one index (Azure does not allow it either). + +## Tests + +`AliasTests` (unit: validation, file storage, resolver) and `AliasIntegrationTests` (CRUD, +validation, resolution through the SDK's `SearchClient`, re-pointing, dangling alias, +`/servicestats`).