diff --git a/src/AzLocal.Client/AzLocal.Client.csproj b/src/AzLocal.Client/AzLocal.Client.csproj
index 138e27a..1d69839 100644
--- a/src/AzLocal.Client/AzLocal.Client.csproj
+++ b/src/AzLocal.Client/AzLocal.Client.csproj
@@ -4,6 +4,10 @@
+
+
+
+
net10.0
enable
diff --git a/src/AzLocal.Client/AzlocalClientFactory.cs b/src/AzLocal.Client/AzlocalClientFactory.cs
index 59e7658..f80463c 100644
--- a/src/AzLocal.Client/AzlocalClientFactory.cs
+++ b/src/AzLocal.Client/AzlocalClientFactory.cs
@@ -1,11 +1,34 @@
-namespace AzLocal.Client;
+namespace AzLocal.Client;
-public class AzlocalClientFactory
+///
+/// Creates pre-configured instances and exposes the
+/// for use with Azure SDK clients in local development.
+///
+public sealed class AzlocalClientFactory
{
private readonly string _baseUrl;
+ private const string DefaultBaseUrl = "http://localhost:4566";
- public AzlocalClientFactory(string baseUrl = "http://localhost:4566")
+ /// The credential that satisfies Azure SDK authentication against the local emulator.
+ public AzlocalCredential Credential { get; } = new();
+
+ public AzlocalClientFactory(string baseUrl = DefaultBaseUrl)
{
- _baseUrl = baseUrl;
+ _baseUrl = baseUrl.TrimEnd('/');
}
+
+ ///
+ /// Creates an with its base address set to the emulator URL,
+ /// and a pre-set Authorization header carrying the fake bearer token.
+ ///
+ public HttpClient CreateHttpClient()
+ {
+ var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "fake-azlocal-token");
+ return client;
+ }
+
+ /// Returns the blob storage base URL for the given account.
+ public Uri GetBlobEndpoint(string account) => new($"{_baseUrl}/azu/{account}");
}
diff --git a/src/AzLocal.Client/AzlocalCredential.cs b/src/AzLocal.Client/AzlocalCredential.cs
index f5bb658..7c13b57 100644
--- a/src/AzLocal.Client/AzlocalCredential.cs
+++ b/src/AzLocal.Client/AzlocalCredential.cs
@@ -1,5 +1,20 @@
-namespace AzLocal.Client;
+using Azure.Core;
-public class AzlocalCredential
+namespace AzLocal.Client;
+
+///
+/// An that returns the static fake token vended by
+/// ImdsMiddleware. Pass this to any Azure SDK client constructor to authenticate
+/// against the local AzLocal emulator without needing a real Azure AD tenant.
+///
+public sealed class AzlocalCredential : TokenCredential
{
+ // Matches the token returned by ImdsMiddleware so the SDK sees a consistent value.
+ private static readonly AccessToken Token = new("fake-azlocal-token", DateTimeOffset.MaxValue);
+
+ public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ => Token;
+
+ public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ => ValueTask.FromResult(Token);
}
diff --git a/src/AzLocal.Client/AzlocalFixture.cs b/src/AzLocal.Client/AzlocalFixture.cs
index ab07016..dcfd976 100644
--- a/src/AzLocal.Client/AzlocalFixture.cs
+++ b/src/AzLocal.Client/AzlocalFixture.cs
@@ -1,5 +1,23 @@
-namespace AzLocal.Client;
+namespace AzLocal.Client;
-public class AzlocalFixture
+///
+/// xUnit test fixture that provides a pre-configured
+/// pointed at the local AzLocal emulator. Use as a class fixture in integration tests:
+///
+/// public class MyTests : IClassFixture<AzlocalFixture>
+/// {
+/// public MyTests(AzlocalFixture fixture) { ... }
+/// }
+///
+///
+public sealed class AzlocalFixture : IAsyncDisposable
{
+ public AzlocalClientFactory Clients { get; }
+
+ public AzlocalFixture(string baseUrl = "http://localhost:4566")
+ {
+ Clients = new AzlocalClientFactory(baseUrl);
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
diff --git a/src/AzLocal.Services/BlobStorage/BlobRoutes.cs b/src/AzLocal.Services/BlobStorage/BlobRoutes.cs
index 45c3d90..09626e3 100644
--- a/src/AzLocal.Services/BlobStorage/BlobRoutes.cs
+++ b/src/AzLocal.Services/BlobStorage/BlobRoutes.cs
@@ -2,4 +2,7 @@
public static class BlobRoutes
{
+ public const string ListContainers = "/azu/{account}";
+ public const string Container = "/azu/{account}/{container}";
+ public const string BlobItem = "/azu/{account}/{container}/{*blobName}";
}
diff --git a/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs b/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs
index 58dc5b6..dd8550f 100644
--- a/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs
+++ b/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs
@@ -1,5 +1,237 @@
-namespace AzLocal.Services.BlobStorage;
+using AzLocal.Core.Interfaces;
+using AzLocal.Core.Models;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using System.Text;
+using System.Xml;
-public class BlobServiceHandler
+namespace AzLocal.Services.BlobStorage;
+
+public class BlobServiceHandler : IServiceHandler
{
+ private readonly IStateStore _state;
+ private readonly IBlobFileStore _blobs;
+ private readonly ILogger _logger;
+
+ public string ServiceName => "BlobStorage";
+
+ public BlobServiceHandler(IStateStore state, IBlobFileStore blobs, ILogger logger)
+ {
+ _state = state;
+ _blobs = blobs;
+ _logger = logger;
+ }
+
+ public void MapRoutes(WebApplication app)
+ {
+ app.MapGet(BlobRoutes.ListContainers, ListContainersAsync);
+ app.MapGet(BlobRoutes.Container, ListBlobsAsync);
+ app.MapPut(BlobRoutes.Container, CreateContainerAsync);
+ app.MapDelete(BlobRoutes.Container, DeleteContainerAsync);
+ app.MapPut(BlobRoutes.BlobItem, UploadBlobAsync);
+ app.MapGet(BlobRoutes.BlobItem, DownloadBlobAsync);
+ app.MapDelete(BlobRoutes.BlobItem, DeleteBlobAsync);
+ app.MapMethods(BlobRoutes.BlobItem, ["HEAD"], GetBlobPropertiesAsync);
+ }
+
+ #region Container handlers
+
+ private async Task ListContainersAsync(string account, HttpContext ctx)
+ {
+ var containers = await _state.ListAsync($"blob/containers/{account}/");
+ _logger.LogDebug("ListContainers account={Account} count={Count}", account, containers.Count);
+
+ var xml = BuildXml(writer =>
+ {
+ writer.WriteStartElement("EnumerationResults");
+ writer.WriteAttributeString("ServiceEndpoint", $"http://localhost/azu/{account}");
+ writer.WriteStartElement("Containers");
+ foreach (var c in containers)
+ {
+ writer.WriteStartElement("Container");
+ writer.WriteElementString("Name", c.Name);
+ writer.WriteStartElement("Properties");
+ writer.WriteElementString("Last-Modified", c.CreatedOn.ToString("R"));
+ writer.WriteEndElement();
+ writer.WriteEndElement();
+ }
+ writer.WriteEndElement();
+ writer.WriteElementString("NextMarker", string.Empty);
+ writer.WriteEndElement();
+ });
+
+ SetRequestId(ctx);
+ return Results.Content(xml, "application/xml");
+ }
+
+ private async Task ListBlobsAsync(string account, string container, HttpContext ctx)
+ {
+ var blobs = await _state.ListAsync($"blob/items/{account}/{container}/");
+ _logger.LogDebug("ListBlobs account={Account} container={Container} count={Count}", account, container, blobs.Count);
+
+ var xml = BuildXml(writer =>
+ {
+ writer.WriteStartElement("EnumerationResults");
+ writer.WriteAttributeString("ServiceEndpoint", $"http://localhost/azu/{account}");
+ writer.WriteAttributeString("ContainerName", container);
+ writer.WriteStartElement("Blobs");
+ foreach (var b in blobs)
+ {
+ writer.WriteStartElement("Blob");
+ writer.WriteElementString("Name", b.Name);
+ writer.WriteStartElement("Properties");
+ writer.WriteElementString("Last-Modified", b.LastModified.ToString("R"));
+ writer.WriteElementString("Etag", $"\"{b.ETag}\"");
+ writer.WriteElementString("Content-Length", b.SizeBytes.ToString());
+ writer.WriteElementString("Content-Type", b.ContentType);
+ writer.WriteElementString("BlobType", b.BlobType);
+ writer.WriteEndElement();
+ writer.WriteEndElement();
+ }
+ writer.WriteEndElement();
+ writer.WriteElementString("NextMarker", string.Empty);
+ writer.WriteEndElement();
+ });
+
+ SetRequestId(ctx);
+ return Results.Content(xml, "application/xml");
+ }
+
+ private async Task CreateContainerAsync(string account, string container, HttpContext ctx)
+ {
+ var key = ContainerKey(account, container);
+ if (await _state.ExistsAsync(key))
+ {
+ _logger.LogWarning("CreateContainer conflict account={Account} container={Container}", account, container);
+ return Results.Conflict();
+ }
+
+ await _state.SetAsync(key, new BlobContainer { Name = container, Location = "local" });
+ _logger.LogInformation("Container created account={Account} container={Container}", account, container);
+ SetRequestId(ctx);
+ return Results.Created();
+ }
+
+ private async Task DeleteContainerAsync(string account, string container, HttpContext ctx)
+ {
+ await _state.DeleteAsync(ContainerKey(account, container));
+
+ var blobs = await _state.ListAsync($"blob/items/{account}/{container}/");
+ foreach (var b in blobs)
+ await _state.DeleteAsync(BlobKey(account, container, b.Name));
+
+ await _blobs.DeleteContainerAsync(container);
+ _logger.LogInformation("Container deleted account={Account} container={Container} blobsRemoved={Count}", account, container, blobs.Count);
+ SetRequestId(ctx);
+ return Results.Accepted();
+ }
+
+ #endregion
+
+ #region Blob handlers
+
+ private async Task UploadBlobAsync(string account, string container, string blobName, HttpContext ctx)
+ {
+ var contentType = ctx.Request.ContentType ?? "application/octet-stream";
+ await _blobs.WriteAsync(container, blobName, ctx.Request.Body, contentType);
+
+ var etag = Guid.NewGuid().ToString("N");
+ var now = DateTimeOffset.UtcNow;
+
+ await _state.SetAsync(BlobKey(account, container, blobName), new BlobItem
+ {
+ Name = blobName,
+ ContainerName = container,
+ ContentType = contentType,
+ SizeBytes = ctx.Request.ContentLength ?? 0,
+ ETag = etag,
+ LastModified = now
+ });
+
+ _logger.LogInformation("Blob uploaded account={Account} container={Container} blob={Blob} contentType={ContentType}",
+ account, container, blobName, contentType);
+
+ ctx.Response.Headers["ETag"] = $"\"{etag}\"";
+ ctx.Response.Headers["Last-Modified"] = now.ToString("R");
+ SetRequestId(ctx);
+ return Results.Created();
+ }
+
+ private async Task DownloadBlobAsync(string account, string container, string blobName, HttpContext ctx)
+ {
+ var meta = await _state.GetAsync(BlobKey(account, container, blobName));
+ if (meta is null)
+ {
+ _logger.LogWarning("Blob not found account={Account} container={Container} blob={Blob}", account, container, blobName);
+ return Results.NotFound();
+ }
+
+ Stream stream;
+ try
+ {
+ stream = await _blobs.ReadAsync(container, blobName);
+ }
+ catch (FileNotFoundException ex)
+ {
+ // Metadata exists but the file was deleted out-of-band — treat as not found.
+ _logger.LogWarning(ex, "Blob file missing despite metadata account={Account} container={Container} blob={Blob}", account, container, blobName);
+ return Results.NotFound();
+ }
+
+ ctx.Response.Headers["ETag"] = $"\"{meta.ETag}\"";
+ ctx.Response.Headers["Last-Modified"] = meta.LastModified.ToString("R");
+ SetRequestId(ctx);
+ return Results.Stream(stream, meta.ContentType);
+ }
+
+ private async Task DeleteBlobAsync(string account, string container, string blobName, HttpContext ctx)
+ {
+ await _state.DeleteAsync(BlobKey(account, container, blobName));
+ await _blobs.DeleteAsync(container, blobName);
+ _logger.LogInformation("Blob deleted account={Account} container={Container} blob={Blob}", account, container, blobName);
+ SetRequestId(ctx);
+ return Results.Accepted();
+ }
+
+ private async Task GetBlobPropertiesAsync(string account, string container, string blobName, HttpContext ctx)
+ {
+ var meta = await _state.GetAsync(BlobKey(account, container, blobName));
+ if (meta is null) return Results.NotFound();
+
+ ctx.Response.Headers["ETag"] = $"\"{meta.ETag}\"";
+ ctx.Response.Headers["Last-Modified"] = meta.LastModified.ToString("R");
+ ctx.Response.Headers["Content-Type"] = meta.ContentType;
+ ctx.Response.Headers["Content-Length"] = meta.SizeBytes.ToString();
+ ctx.Response.Headers["x-ms-blob-type"] = meta.BlobType;
+ SetRequestId(ctx);
+ return Results.Ok();
+ }
+
+ #endregion
+
+ #region Private helpers
+
+ private static string ContainerKey(string account, string container) =>
+ $"blob/containers/{account}/{container}".ToLowerInvariant();
+
+ private static string BlobKey(string account, string container, string blob) =>
+ $"blob/items/{account}/{container}/{blob}".ToLowerInvariant();
+
+ // x-ms-request-id is checked by the Azure SDK to correlate requests in logs.
+ private static void SetRequestId(HttpContext ctx) =>
+ ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString();
+
+ // Uses XmlWriter so blob/container names with <, >, & are safely escaped.
+ private static string BuildXml(Action build)
+ {
+ var sb = new StringBuilder();
+ using var writer = XmlWriter.Create(sb, new XmlWriterSettings { Indent = true });
+ writer.WriteStartDocument();
+ build(writer);
+ writer.WriteEndDocument();
+ return sb.ToString();
+ }
+
+ #endregion
}
diff --git a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs
index b43c665..98dddac 100644
--- a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs
+++ b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs
@@ -2,4 +2,5 @@
public class KeyVaultSecretHandler
{
+
}
diff --git a/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs b/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs
index be1450a..ce1b32b 100644
--- a/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs
+++ b/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs
@@ -2,4 +2,5 @@
public static class ServiceBusRoutes
{
+
}