From 43c16b9d637d6a39c9804d31ce077c26e1325cf0 Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Fri, 22 May 2026 08:41:18 +0000 Subject: [PATCH] Enhance AzLocal with Azure SDK support and blob operations Added Azure.Core package reference to enable Azure SDK integration. Updated AzlocalClientFactory to include a sealed design, XML documentation, and methods for creating pre-configured HttpClient instances and generating blob storage endpoints. Introduced the AzlocalCredential class to provide a static fake token for local Azure SDK authentication. Implemented BlobServiceHandler with route mapping for blob storage operations, including container and blob management, detailed logging, and XML-based responses for Azure SDK compatibility. Centralized blob storage API routes in BlobRoutes for maintainability. Added AzlocalFixture for integration testing with pre-configured clients. Introduced placeholders for Key Vault and Service Bus functionality to support future expansion. --- src/AzLocal.Client/AzLocal.Client.csproj | 4 + src/AzLocal.Client/AzlocalClientFactory.cs | 31 ++- src/AzLocal.Client/AzlocalCredential.cs | 19 +- src/AzLocal.Client/AzlocalFixture.cs | 22 +- .../BlobStorage/BlobRoutes.cs | 3 + .../BlobStorage/BlobServiceHandler.cs | 236 +++++++++++++++++- .../KeyVault/KeyVaultSecretHandler.cs | 1 + .../ServiceBus/ServiceBusRoutes.cs | 1 + 8 files changed, 307 insertions(+), 10 deletions(-) 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 { + }