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`).