From e103a6b8f7fa9e2d4344a91f063b0099a6dd0b6e Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Fri, 22 May 2026 09:41:44 +0000 Subject: [PATCH 1/2] Refactor emulator services and improve route handling Refactored multiple service handlers to implement the `IServiceHandler` interface for consistent service handling. Added route mappings and methods for ARM Resource Groups, Subscriptions, Key Vault Secrets, Blob Storage, and Service Bus queue emulators. Introduced dynamic base URL configuration for improved flexibility. Enhanced maintainability with helper methods for key generation, request ID setting, and response formatting. Added XML documentation for route constants across services. Updated `.gitignore` to exclude build output while preserving source files in "Arm" project folders. --- .gitignore | 3 + src/AzLocal.Services/Arm/ArmRoutes.cs | 13 ++ .../Arm/ResourceGroupHandler.cs | 121 ++++++++++- .../Arm/SubscriptionHandler.cs | 61 +++++- .../BlobStorage/BlobRoutes.cs | 5 + .../BlobStorage/BlobServiceHandler.cs | 9 +- .../KeyVault/KeyVaultRoutes.cs | 9 +- .../KeyVault/KeyVaultSecretHandler.cs | 152 ++++++++++++- .../ServiceBus/ServiceBusQueueHandler.cs | 203 +++++++++++++++++- .../ServiceBus/ServiceBusRoutes.cs | 12 +- 10 files changed, 574 insertions(+), 14 deletions(-) create mode 100644 src/AzLocal.Services/Arm/ArmRoutes.cs diff --git a/.gitignore b/.gitignore index 0808c4a..6f69276 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ +# Restore source files in project folders named Arm — the rules above target build output only +!src/**/[Aa][Rr][Mm]/ +!src/**/[Aa][Rr][Mm]/** bld/ [Bb]in/ [Oo]bj/ diff --git a/src/AzLocal.Services/Arm/ArmRoutes.cs b/src/AzLocal.Services/Arm/ArmRoutes.cs new file mode 100644 index 0000000..716fad4 --- /dev/null +++ b/src/AzLocal.Services/Arm/ArmRoutes.cs @@ -0,0 +1,13 @@ +namespace AzLocal.Services.Arm; + +/// +/// Route constants for the ARM (Azure Resource Manager) emulator endpoints. +/// Mirrors the real ARM REST API path structure under the /arm prefix. +/// +public static class ArmRoutes +{ + public const string Subscriptions = "/arm/subscriptions"; + public const string Subscription = "/arm/subscriptions/{subscriptionId}"; + public const string ResourceGroups = "/arm/subscriptions/{subscriptionId}/resourcegroups"; + public const string ResourceGroup = "/arm/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}"; +} diff --git a/src/AzLocal.Services/Arm/ResourceGroupHandler.cs b/src/AzLocal.Services/Arm/ResourceGroupHandler.cs index e49e421..73254ad 100644 --- a/src/AzLocal.Services/Arm/ResourceGroupHandler.cs +++ b/src/AzLocal.Services/Arm/ResourceGroupHandler.cs @@ -1,5 +1,122 @@ -namespace AzLocal.Services.Arm; +using AzLocal.Core.Interfaces; +using AzLocal.Core.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Text.Json; -public class ResourceGroupHandler +namespace AzLocal.Services.Arm; + +/// +/// Emulates the ARM Resource Groups API. Supports create, get, list, and delete +/// so the Azure SDK can manage resource group lifecycle against the local emulator. +/// +public class ResourceGroupHandler : IServiceHandler { + private readonly IStateStore _state; + private readonly ILogger _logger; + + public string ServiceName => "ARM/ResourceGroups"; + + public ResourceGroupHandler(IStateStore state, ILogger logger) + { + _state = state; + _logger = logger; + } + + public void MapRoutes(WebApplication app) + { + app.MapGet(ArmRoutes.ResourceGroups, ListResourceGroupsAsync); + app.MapGet(ArmRoutes.ResourceGroup, GetResourceGroupAsync); + app.MapPut(ArmRoutes.ResourceGroup, CreateOrUpdateResourceGroupAsync); + app.MapDelete(ArmRoutes.ResourceGroup, DeleteResourceGroupAsync); + } + + /// Returns all resource groups for the given subscription as an ARM paged list. + private async Task ListResourceGroupsAsync(string subscriptionId, HttpContext ctx) + { + var groups = await _state.ListAsync($"arm/resourcegroups/{subscriptionId}/"); + _logger.LogDebug("ListResourceGroups subscriptionId={Sub} count={Count}", subscriptionId, groups.Count); + + SetRequestId(ctx); + return Results.Ok(new { value = groups.Select(g => ResourceGroupResponse(subscriptionId, g)) }); + } + + /// Returns a single resource group by name, or 404 if it does not exist. + private async Task GetResourceGroupAsync(string subscriptionId, string resourceGroupName, HttpContext ctx) + { + var rg = await _state.GetAsync(RgKey(subscriptionId, resourceGroupName)); + if (rg is null) + { + _logger.LogWarning("GetResourceGroup not found sub={Sub} rg={RG}", subscriptionId, resourceGroupName); + return Results.NotFound(); + } + + SetRequestId(ctx); + return Results.Ok(ResourceGroupResponse(subscriptionId, rg)); + } + + /// Creates or updates a resource group. Reads location from the request body. + private async Task CreateOrUpdateResourceGroupAsync( + string subscriptionId, string resourceGroupName, HttpContext ctx) + { + string location = "local"; + try + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body); + if (doc.RootElement.TryGetProperty("location", out var loc)) + location = loc.GetString() ?? "local"; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "CreateResourceGroup could not parse body — using default location"); + } + + var existing = await _state.GetAsync(RgKey(subscriptionId, resourceGroupName)); + var isNew = existing is null; + + var rg = new ResourceGroup + { + Name = resourceGroupName, + Location = location, + SubscriptionId = subscriptionId, + ProvisioningState = "Succeeded" + }; + + await _state.SetAsync(RgKey(subscriptionId, resourceGroupName), rg); + _logger.LogInformation("ResourceGroup {Action} sub={Sub} rg={RG} location={Location}", + isNew ? "created" : "updated", subscriptionId, resourceGroupName, location); + + SetRequestId(ctx); + return isNew ? Results.Created() : Results.Ok(ResourceGroupResponse(subscriptionId, rg)); + } + + /// Deletes a resource group. No-ops if it does not exist. + private async Task DeleteResourceGroupAsync(string subscriptionId, string resourceGroupName, HttpContext ctx) + { + await _state.DeleteAsync(RgKey(subscriptionId, resourceGroupName)); + _logger.LogInformation("ResourceGroup deleted sub={Sub} rg={RG}", subscriptionId, resourceGroupName); + + SetRequestId(ctx); + return Results.Accepted(); + } + + #region Private helpers + + private static string RgKey(string subscriptionId, string name) => + $"arm/resourcegroups/{subscriptionId}/{name}".ToLowerInvariant(); + + private static void SetRequestId(HttpContext ctx) => + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); + + private static object ResourceGroupResponse(string subscriptionId, ResourceGroup rg) => new + { + id = $"/subscriptions/{subscriptionId}/resourceGroups/{rg.Name}", + name = rg.Name, + location = rg.Location, + tags = rg.Tags, + properties = new { provisioningState = rg.ProvisioningState } + }; + + #endregion } diff --git a/src/AzLocal.Services/Arm/SubscriptionHandler.cs b/src/AzLocal.Services/Arm/SubscriptionHandler.cs index 7227674..4538b55 100644 --- a/src/AzLocal.Services/Arm/SubscriptionHandler.cs +++ b/src/AzLocal.Services/Arm/SubscriptionHandler.cs @@ -1,5 +1,62 @@ -namespace AzLocal.Services.Arm; +using AzLocal.Core.Interfaces; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; -public class SubscriptionHandler +namespace AzLocal.Services.Arm; + +/// +/// Emulates the ARM Subscriptions API. Returns a single hardcoded fake subscription +/// so the Azure SDK can resolve subscription context without a real Azure tenant. +/// +public class SubscriptionHandler : IServiceHandler { + private readonly ILogger _logger; + + public string ServiceName => "ARM/Subscriptions"; + + // Fake subscription returned for every request — matches the fake tenant from AuthStubMiddleware. + private static readonly object FakeSubscription = new + { + subscriptionId = "00000000-0000-0000-0000-000000000001", + displayName = "AzLocal Dev Subscription", + state = "Enabled", + tenantId = "00000000-0000-0000-0000-000000000002" + }; + + public SubscriptionHandler(ILogger logger) + { + _logger = logger; + } + + public void MapRoutes(WebApplication app) + { + app.MapGet(ArmRoutes.Subscriptions, (Delegate)ListSubscriptionsAsync); + app.MapGet(ArmRoutes.Subscription, GetSubscriptionAsync); + } + + /// Returns the single fake subscription as a paged ARM list response. + private Task ListSubscriptionsAsync(HttpContext ctx) + { + _logger.LogDebug("ListSubscriptions"); + SetRequestId(ctx); + return Task.FromResult(Results.Ok(new { value = new[] { FakeSubscription } })); + } + + /// Returns the fake subscription if the requested ID matches, otherwise 404. + private Task GetSubscriptionAsync(string subscriptionId, HttpContext ctx) + { + SetRequestId(ctx); + if (!subscriptionId.Equals("00000000-0000-0000-0000-000000000001", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("GetSubscription not found subscriptionId={SubscriptionId}", subscriptionId); + return Task.FromResult(Results.NotFound()); + } + + _logger.LogDebug("GetSubscription subscriptionId={SubscriptionId}", subscriptionId); + return Task.FromResult(Results.Ok(FakeSubscription)); + } + + private static void SetRequestId(HttpContext ctx) => + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); } diff --git a/src/AzLocal.Services/BlobStorage/BlobRoutes.cs b/src/AzLocal.Services/BlobStorage/BlobRoutes.cs index 09626e3..1a239d2 100644 --- a/src/AzLocal.Services/BlobStorage/BlobRoutes.cs +++ b/src/AzLocal.Services/BlobStorage/BlobRoutes.cs @@ -1,5 +1,10 @@ namespace AzLocal.Services.BlobStorage; +/// +/// Route constants for the Blob Storage emulator endpoints. +/// {*blobName} is a catch-all segment so blob names containing forward slashes +/// (e.g. "folder/subfolder/file.txt") are captured as a single parameter. +/// public static class BlobRoutes { public const string ListContainers = "/azu/{account}"; diff --git a/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs b/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs index dd8550f..bd7ae71 100644 --- a/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs +++ b/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs @@ -2,6 +2,7 @@ using AzLocal.Core.Models; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Text; using System.Xml; @@ -13,14 +14,16 @@ public class BlobServiceHandler : IServiceHandler private readonly IStateStore _state; private readonly IBlobFileStore _blobs; private readonly ILogger _logger; + private readonly string _baseUrl; public string ServiceName => "BlobStorage"; - public BlobServiceHandler(IStateStore state, IBlobFileStore blobs, ILogger logger) + public BlobServiceHandler(IStateStore state, IBlobFileStore blobs, ILogger logger, IConfiguration config) { _state = state; _blobs = blobs; _logger = logger; + _baseUrl = (config["AzLocal:BaseUrl"] ?? "http://localhost").TrimEnd('/'); } public void MapRoutes(WebApplication app) @@ -45,7 +48,7 @@ private async Task ListContainersAsync(string account, HttpContext ctx) var xml = BuildXml(writer => { writer.WriteStartElement("EnumerationResults"); - writer.WriteAttributeString("ServiceEndpoint", $"http://localhost/azu/{account}"); + writer.WriteAttributeString("ServiceEndpoint", $"{_baseUrl}/azu/{account}"); writer.WriteStartElement("Containers"); foreach (var c in containers) { @@ -73,7 +76,7 @@ private async Task ListBlobsAsync(string account, string container, Htt var xml = BuildXml(writer => { writer.WriteStartElement("EnumerationResults"); - writer.WriteAttributeString("ServiceEndpoint", $"http://localhost/azu/{account}"); + writer.WriteAttributeString("ServiceEndpoint", $"{_baseUrl}/azu/{account}"); writer.WriteAttributeString("ContainerName", container); writer.WriteStartElement("Blobs"); foreach (var b in blobs) diff --git a/src/AzLocal.Services/KeyVault/KeyVaultRoutes.cs b/src/AzLocal.Services/KeyVault/KeyVaultRoutes.cs index 9f57b22..1443ec5 100644 --- a/src/AzLocal.Services/KeyVault/KeyVaultRoutes.cs +++ b/src/AzLocal.Services/KeyVault/KeyVaultRoutes.cs @@ -1,5 +1,12 @@ -namespace AzLocal.Services.KeyVault; +namespace AzLocal.Services.KeyVault; +/// +/// Route constants for the Key Vault Secrets emulator endpoints. +/// {vault} is the vault name, matching how the real Key Vault API scopes secrets per vault. +/// public static class KeyVaultRoutes { + public const string Secrets = "/kv/{vault}/secrets"; + public const string SecretByName = "/kv/{vault}/secrets/{secretName}"; + public const string SecretVersion = "/kv/{vault}/secrets/{secretName}/{version}"; } diff --git a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs index 98dddac..bf1c5a6 100644 --- a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs +++ b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs @@ -1,6 +1,154 @@ -namespace AzLocal.Services.KeyVault; +using AzLocal.Core.Interfaces; +using AzLocal.Core.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Text.Json; -public class KeyVaultSecretHandler +namespace AzLocal.Services.KeyVault; + +public class KeyVaultSecretHandler : IServiceHandler { + private readonly IStateStore _state; + private readonly ILogger _logger; + private readonly string _baseUrl; + + public string ServiceName => "KeyVault"; + + public KeyVaultSecretHandler(IStateStore state, ILogger logger, IConfiguration config) + { + _state = state; + _logger = logger; + _baseUrl = (config["AzLocal:BaseUrl"] ?? "http://localhost").TrimEnd('/'); + } + + public void MapRoutes(WebApplication app) + { + app.MapGet(KeyVaultRoutes.Secrets, ListSecretsAsync); + app.MapPut(KeyVaultRoutes.SecretByName, SetSecretAsync); + app.MapGet(KeyVaultRoutes.SecretByName, GetSecretAsync); + app.MapGet(KeyVaultRoutes.SecretVersion, GetSecretVersionAsync); + app.MapDelete(KeyVaultRoutes.SecretByName, DeleteSecretAsync); + } + + #region Route handlers + + private async Task ListSecretsAsync(string vault, HttpContext ctx) + { + var secrets = await _state.ListAsync($"keyvault/secrets/{vault}/"); + _logger.LogDebug("ListSecrets vault={Vault} count={Count}", vault, secrets.Count); + + // Return only the latest enabled version of each secret — values are never included in list responses. + var latest = secrets + .Where(s => s.Enabled) + .GroupBy(s => s.Name) + .Select(g => g.OrderByDescending(s => s.UpdatedOn).First()) + .Select(s => new { id = SecretUrl(vault, s.Name, s.Version), attributes = Attributes(s) }); + + SetRequestId(ctx); + return Results.Ok(new { value = latest }); + } + + private async Task SetSecretAsync(string vault, string secretName, HttpContext ctx) + { + string value; + try + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body); + value = doc.RootElement.GetProperty("value").GetString() ?? string.Empty; + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException) + { + _logger.LogWarning(ex, "SetSecret bad request vault={Vault} secret={Secret}", vault, secretName); + return Results.BadRequest("Request body must be JSON with a 'value' property."); + } + + var secret = new KeyVaultSecret + { + Name = secretName, + Value = value, + Version = Guid.NewGuid().ToString("N"), + CreatedOn = DateTimeOffset.UtcNow, + UpdatedOn = DateTimeOffset.UtcNow + }; + secret.Id = SecretUrl(vault, secretName, secret.Version); + + await _state.SetAsync(SecretKey(vault, secretName, secret.Version), secret); + _logger.LogInformation("Secret set vault={Vault} secret={Secret} version={Version}", vault, secretName, secret.Version); + + SetRequestId(ctx); + return Results.Ok(SecretResponse(vault, secret)); + } + + private async Task GetSecretAsync(string vault, string secretName, HttpContext ctx) + { + var all = await _state.ListAsync($"keyvault/secrets/{vault}/{secretName}/"); + var secret = all.Where(s => s.Enabled).OrderByDescending(s => s.UpdatedOn).FirstOrDefault(); + if (secret is null) + { + _logger.LogWarning("Secret not found vault={Vault} secret={Secret}", vault, secretName); + return Results.NotFound(); + } + + SetRequestId(ctx); + return Results.Ok(SecretResponse(vault, secret)); + } + + private async Task GetSecretVersionAsync(string vault, string secretName, string version, HttpContext ctx) + { + var secret = await _state.GetAsync(SecretKey(vault, secretName, version)); + if (secret is null) + { + _logger.LogWarning("Secret version not found vault={Vault} secret={Secret} version={Version}", vault, secretName, version); + return Results.NotFound(); + } + + SetRequestId(ctx); + return Results.Ok(SecretResponse(vault, secret)); + } + + private async Task DeleteSecretAsync(string vault, string secretName, HttpContext ctx) + { + var all = await _state.ListAsync($"keyvault/secrets/{vault}/{secretName}/"); + foreach (var s in all) + { + s.Enabled = false; + await _state.SetAsync(SecretKey(vault, secretName, s.Version), s); + } + + _logger.LogInformation("Secret deleted (soft) vault={Vault} secret={Secret} versions={Count}", vault, secretName, all.Count); + SetRequestId(ctx); + return Results.Ok(); + } + + #endregion + + #region Private helpers + + private static string SecretKey(string vault, string name, string version) => + $"keyvault/secrets/{vault}/{name}/{version}".ToLowerInvariant(); + + private string SecretUrl(string vault, string name, string version) => + $"{_baseUrl}/kv/{vault}/secrets/{name}/{version}"; + + private static void SetRequestId(HttpContext ctx) => + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); + + private static object Attributes(KeyVaultSecret s) => new + { + enabled = s.Enabled, + created = s.CreatedOn.ToUnixTimeSeconds(), + updated = s.UpdatedOn.ToUnixTimeSeconds(), + expires = s.ExpiresOn?.ToUnixTimeSeconds() + }; + + private static object SecretResponse(string vault, KeyVaultSecret secret) => new + { + value = secret.Value, + id = SecretUrl(vault, secret.Name, secret.Version), + attributes = Attributes(secret) + }; + #endregion } diff --git a/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs b/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs index d9d65c5..5e9381d 100644 --- a/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs +++ b/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs @@ -1,5 +1,204 @@ -namespace AzLocal.Services.ServiceBus; +using AzLocal.Core.Interfaces; +using AzLocal.Core.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Text.Json; -public class ServiceBusQueueHandler +namespace AzLocal.Services.ServiceBus; + +public class ServiceBusQueueHandler : IServiceHandler { + private readonly IStateStore _state; + private readonly ILogger _logger; + + public string ServiceName => "ServiceBus"; + + public ServiceBusQueueHandler(IStateStore state, ILogger logger) + { + _state = state; + _logger = logger; + } + + public void MapRoutes(WebApplication app) + { + app.MapPost(ServiceBusRoutes.Messages, SendMessageAsync); + app.MapGet(ServiceBusRoutes.Messages, PeekMessagesAsync); + app.MapPost(ServiceBusRoutes.MessageHead, ReceiveMessageAsync); // peek-lock + app.MapDelete(ServiceBusRoutes.MessageHead, ReceiveAndDeleteAsync); // destructive receive + app.MapDelete(ServiceBusRoutes.MessageLock, CompleteMessageAsync); // complete after peek-lock + app.MapPost(ServiceBusRoutes.DeadLetter, DeadLetterMessageAsync); + } + + #region Route handlers + + /// Enqueues a new message. Body is the raw message payload. + private async Task SendMessageAsync(string @namespace, string queue, HttpContext ctx) + { + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + + var message = new ServiceBusMessage + { + MessageId = Guid.NewGuid().ToString(), + Body = body, + EnqueuedAt = DateTimeOffset.UtcNow + }; + + // Carry any x-ms-prop-* headers as application properties. + foreach (var header in ctx.Request.Headers.Where(h => h.Key.StartsWith("x-ms-prop-", StringComparison.OrdinalIgnoreCase))) + message.ApplicationProperties[header.Key[10..]] = header.Value.ToString(); + + await _state.SetAsync(ActiveKey(@namespace, queue, message.MessageId), message); + _logger.LogInformation("Message sent ns={Namespace} queue={Queue} messageId={MessageId}", @namespace, queue, message.MessageId); + + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); + ctx.Response.Headers["Content-Location"] = $"/sb/{@namespace}/{queue}/messages/{message.MessageId}"; + return Results.Created(); + } + + /// Returns up to 32 messages without removing them from the queue. + private async Task PeekMessagesAsync(string @namespace, string queue, HttpContext ctx) + { + var messages = await _state.ListAsync(ActivePrefix(@namespace, queue)); + var page = messages.OrderBy(m => m.EnqueuedAt).Take(32).ToList(); + _logger.LogDebug("PeekMessages ns={Namespace} queue={Queue} count={Count}", @namespace, queue, page.Count); + + SetRequestId(ctx); + return Results.Ok(page); + } + + /// + /// Receives the oldest message under a peek-lock. The caller must complete or dead-letter + /// the message using the returned LockToken before the lock expires. + /// Returns 204 when the queue is empty. + /// + private async Task ReceiveMessageAsync(string @namespace, string queue, HttpContext ctx) + { + var messages = await _state.ListAsync(ActivePrefix(@namespace, queue)); + var message = messages.OrderBy(m => m.EnqueuedAt).FirstOrDefault(); + if (message is null) + { + _logger.LogDebug("ReceiveMessage ns={Namespace} queue={Queue} — queue empty", @namespace, queue); + return Results.NoContent(); + } + + message.DeliveryCount++; + await _state.SetAsync(ActiveKey(@namespace, queue, message.MessageId), message); + + var lockToken = Guid.NewGuid().ToString(); + await _state.SetAsync(LockKey(@namespace, queue, lockToken), message.MessageId); + + _logger.LogInformation("Message received (peek-lock) ns={Namespace} queue={Queue} messageId={MessageId} lock={Lock}", + @namespace, queue, message.MessageId, lockToken); + + SetRequestId(ctx); + ctx.Response.Headers["BrokerProperties"] = JsonSerializer.Serialize(new + { + MessageId = message.MessageId, + LockToken = lockToken, + DeliveryCount = message.DeliveryCount, + EnqueuedTimeUtc = message.EnqueuedAt + }); + return Results.Ok(message.Body); + } + + /// + /// Receives and immediately deletes the oldest message (no lock required to complete). + /// Returns 204 when the queue is empty. + /// + private async Task ReceiveAndDeleteAsync(string @namespace, string queue, HttpContext ctx) + { + var messages = await _state.ListAsync(ActivePrefix(@namespace, queue)); + var message = messages.OrderBy(m => m.EnqueuedAt).FirstOrDefault(); + if (message is null) return Results.NoContent(); + + await _state.DeleteAsync(ActiveKey(@namespace, queue, message.MessageId)); + _logger.LogInformation("Message receive-deleted ns={Namespace} queue={Queue} messageId={MessageId}", @namespace, queue, message.MessageId); + + SetRequestId(ctx); + ctx.Response.Headers["BrokerProperties"] = JsonSerializer.Serialize(new + { + MessageId = message.MessageId, + DeliveryCount = message.DeliveryCount + 1, + EnqueuedTimeUtc = message.EnqueuedAt + }); + return Results.Ok(message.Body); + } + + /// Completes a previously peek-locked message, removing it from the queue. + private async Task CompleteMessageAsync(string @namespace, string queue, string lockToken, HttpContext ctx) + { + var messageId = await _state.GetAsync(LockKey(@namespace, queue, lockToken)); + if (messageId is null) + { + _logger.LogWarning("CompleteMessage lock not found ns={Namespace} queue={Queue} lock={Lock}", @namespace, queue, lockToken); + return Results.NotFound(); + } + + await _state.DeleteAsync(ActiveKey(@namespace, queue, messageId)); + await _state.DeleteAsync(LockKey(@namespace, queue, lockToken)); + _logger.LogInformation("Message completed ns={Namespace} queue={Queue} messageId={MessageId}", @namespace, queue, messageId); + + SetRequestId(ctx); + return Results.Ok(); + } + + /// Moves a peek-locked message to the dead-letter queue. + private async Task DeadLetterMessageAsync(string @namespace, string queue, string lockToken, HttpContext ctx) + { + var messageId = await _state.GetAsync(LockKey(@namespace, queue, lockToken)); + if (messageId is null) + { + _logger.LogWarning("DeadLetter lock not found ns={Namespace} queue={Queue} lock={Lock}", @namespace, queue, lockToken); + return Results.NotFound(); + } + + var message = await _state.GetAsync(ActiveKey(@namespace, queue, messageId)); + if (message is not null) + { + string? reason = null; + try + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body); + doc.RootElement.TryGetProperty("deadLetterReason", out var prop); + reason = prop.GetString(); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "DeadLetter could not parse reason body — continuing without reason"); + } + + message.DeadLetterReason = reason; + await _state.SetAsync(DeadLetterKey(@namespace, queue, messageId), message); + await _state.DeleteAsync(ActiveKey(@namespace, queue, messageId)); + } + + await _state.DeleteAsync(LockKey(@namespace, queue, lockToken)); + _logger.LogInformation("Message dead-lettered ns={Namespace} queue={Queue} messageId={MessageId}", @namespace, queue, messageId); + + SetRequestId(ctx); + return Results.Ok(); + } + + #endregion + + #region Private helpers + + private static string ActivePrefix(string ns, string queue) => + $"servicebus/{ns}/{queue}/active/".ToLowerInvariant(); + + private static string ActiveKey(string ns, string queue, string messageId) => + $"servicebus/{ns}/{queue}/active/{messageId}".ToLowerInvariant(); + + private static string LockKey(string ns, string queue, string lockToken) => + $"servicebus/{ns}/{queue}/locks/{lockToken}".ToLowerInvariant(); + + private static string DeadLetterKey(string ns, string queue, string messageId) => + $"servicebus/{ns}/{queue}/deadletter/{messageId}".ToLowerInvariant(); + + private static void SetRequestId(HttpContext ctx) => + ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); + + #endregion } diff --git a/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs b/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs index ce1b32b..125d3f0 100644 --- a/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs +++ b/src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs @@ -1,6 +1,14 @@ -namespace AzLocal.Services.ServiceBus; +namespace AzLocal.Services.ServiceBus; +/// +/// Route constants for the Service Bus queue emulator endpoints. +/// Peek-lock flow: POST head → receive with lock token → DELETE lock to complete, or POST deadletterqueue to dead-letter. +/// Destructive flow: DELETE head → receive and immediately remove in one step. +/// public static class ServiceBusRoutes { - + public const string Messages = "/sb/{namespace}/{queue}/messages"; + public const string MessageHead = "/sb/{namespace}/{queue}/messages/head"; + public const string MessageLock = "/sb/{namespace}/{queue}/messages/{lockToken}"; + public const string DeadLetter = "/sb/{namespace}/{queue}/messages/{lockToken}/deadletterqueue"; } From 7d9d20612542eb5ab1f7cb6e957cefcbce758b7e Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Fri, 22 May 2026 09:53:00 +0000 Subject: [PATCH 2/2] Add Azure SDK clients and emulator DI configuration Enhanced AzLocal with Azure SDK client factories for Blob Storage, Key Vault, and Service Bus. Introduced `EmulatorBuilder` for centralized DI configuration, supporting multiple state storage modes. Updated `Program.cs` to integrate middleware and auto-register service routes. Added emulator-specific settings to `appsettings.json` and improved `SubscriptionHandler` to dynamically source configuration. Refactored `KeyVaultSecretHandler` and improved logging and documentation across the codebase. --- src/AzLocal.Client/AzLocal.Client.csproj | 3 +- src/AzLocal.Client/AzlocalClientFactory.cs | 119 +++++++++++++++++- src/AzLocal.Host/HostBuilder.cs | 55 +++++++- src/AzLocal.Host/Program.cs | 28 ++++- src/AzLocal.Host/appsettings.json | 13 +- .../Arm/SubscriptionHandler.cs | 42 ++++--- .../KeyVault/KeyVaultSecretHandler.cs | 2 +- 7 files changed, 234 insertions(+), 28 deletions(-) diff --git a/src/AzLocal.Client/AzLocal.Client.csproj b/src/AzLocal.Client/AzLocal.Client.csproj index 1d69839..ea22fdb 100644 --- a/src/AzLocal.Client/AzLocal.Client.csproj +++ b/src/AzLocal.Client/AzLocal.Client.csproj @@ -5,7 +5,8 @@ - + + diff --git a/src/AzLocal.Client/AzlocalClientFactory.cs b/src/AzLocal.Client/AzlocalClientFactory.cs index f80463c..ccf15dd 100644 --- a/src/AzLocal.Client/AzlocalClientFactory.cs +++ b/src/AzLocal.Client/AzlocalClientFactory.cs @@ -1,8 +1,13 @@ +using Azure.Security.KeyVault.Secrets; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; + namespace AzLocal.Client; /// -/// Creates pre-configured instances and exposes the -/// for use with Azure SDK clients in local development. +/// One-stop factory for creating Azure SDK clients pre-configured to talk to the +/// local AzLocal emulator. Pass new AzlocalClientFactory() and call the +/// method for the service you need — no connection strings, no Azure tenant required. /// public sealed class AzlocalClientFactory { @@ -17,9 +22,103 @@ public AzlocalClientFactory(string baseUrl = DefaultBaseUrl) _baseUrl = baseUrl.TrimEnd('/'); } + // ------------------------------------------------------------------------- + // Blob Storage + // ------------------------------------------------------------------------- + + /// + /// Returns a pointed at the emulator for the given + /// storage account. Use this to manage containers and list blobs. + /// + /// var blobs = factory.CreateBlobServiceClient("myaccount"); + /// await blobs.CreateBlobContainerAsync("uploads"); + /// + /// + public BlobServiceClient CreateBlobServiceClient(string account) + { + var options = BuildBlobOptions(); + return new BlobServiceClient(GetBlobEndpoint(account), Credential, options); + } + + /// + /// Returns a pointed at a specific container. + /// + /// var container = factory.CreateBlobContainerClient("myaccount", "uploads"); + /// await container.UploadBlobAsync("file.txt", stream); + /// + /// + public BlobContainerClient CreateBlobContainerClient(string account, string container) + { + var uri = new Uri($"{_baseUrl}/azu/{account}/{container}"); + return new BlobContainerClient(uri, Credential, BuildBlobOptions()); + } + + /// + /// Returns a for a specific blob. + /// + /// var blob = factory.CreateBlobClient("myaccount", "uploads", "folder/file.txt"); + /// await blob.UploadAsync(stream, overwrite: true); + /// + /// + public BlobClient CreateBlobClient(string account, string container, string blobName) + { + var uri = new Uri($"{_baseUrl}/azu/{account}/{container}/{blobName}"); + return new BlobClient(uri, Credential, BuildBlobOptions()); + } + + // ------------------------------------------------------------------------- + // Key Vault + // ------------------------------------------------------------------------- + + /// + /// Returns a pointed at the emulator for the given vault. + /// + /// var secrets = factory.CreateSecretClient("myvault"); + /// await secrets.SetSecretAsync("my-api-key", "super-secret"); + /// var value = await secrets.GetSecretAsync("my-api-key"); + /// + /// + public SecretClient CreateSecretClient(string vault) + { + var uri = new Uri($"{_baseUrl}/kv/{vault}"); + var options = new SecretClientOptions + { + Retry = { MaxRetries = 0 } + }; + return new SecretClient(uri, Credential, options); + } + + // ------------------------------------------------------------------------- + // Service Bus + // ------------------------------------------------------------------------- + + /// + /// Returns a pre-configured for the Service Bus emulator. + /// The Azure Service Bus SDK uses AMQP (not HTTP), so direct SDK integration is not + /// supported. Use this client to call the emulator's HTTP queue endpoints directly. + /// + /// var sb = factory.CreateServiceBusHttpClient("mynamespace"); + /// await sb.PostAsync("myqueue/messages", new StringContent("hello")); + /// + /// + public HttpClient CreateServiceBusHttpClient(string @namespace) + { + var client = new HttpClient + { + BaseAddress = new Uri($"{_baseUrl}/sb/{@namespace}/") + }; + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "fake-azlocal-token"); + return client; + } + + // ------------------------------------------------------------------------- + // Raw HTTP (ARM or any other emulated service) + // ------------------------------------------------------------------------- + /// - /// Creates an with its base address set to the emulator URL, - /// and a pre-set Authorization header carrying the fake bearer token. + /// Returns a general-purpose with the emulator base address + /// and auth header set. Use this for ARM calls or any service not covered above. /// public HttpClient CreateHttpClient() { @@ -29,6 +128,16 @@ public HttpClient CreateHttpClient() return client; } - /// Returns the blob storage base URL for the given account. + /// Returns the blob storage base URI for the given account. public Uri GetBlobEndpoint(string account) => new($"{_baseUrl}/azu/{account}"); + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static BlobClientOptions BuildBlobOptions() => new() + { + // Disable retries so tests fail fast instead of hanging on connection errors. + Retry = { MaxRetries = 0 } + }; } diff --git a/src/AzLocal.Host/HostBuilder.cs b/src/AzLocal.Host/HostBuilder.cs index fb900a1..fe5d50e 100644 --- a/src/AzLocal.Host/HostBuilder.cs +++ b/src/AzLocal.Host/HostBuilder.cs @@ -1,9 +1,60 @@ -namespace AzLocal.Host; +using AzLocal.Core.Interfaces; +using AzLocal.Middleware; +using AzLocal.Services.Arm; +using AzLocal.Services.BlobStorage; +using AzLocal.Services.KeyVault; +using AzLocal.Services.ServiceBus; +using AzLocal.State; -public static class HostBuilder +namespace AzLocal.Host; + +/// +/// Configures all DI registrations for the AzLocal emulator. +/// Call once in Program.cs before building the app. +/// +public static class EmulatorBuilder { + /// + /// Registers all emulator services into the DI container: + /// + /// State store — selected by AzLocal:StateMode (InMemory / JsonSnapshot / Sqlite) + /// Blob file store + /// All implementations (Blob, KeyVault, ServiceBus, ARM) + /// + /// public static WebApplicationBuilder Configure(WebApplicationBuilder builder) { + RegisterStateStore(builder); + + builder.Services.AddSingleton(); + + // Each IServiceHandler registers its own routes in MapRoutes(). + // Program.cs iterates all of them so adding a new service here is the only change needed. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.AddAuthorization(); + return builder; } + + private static void RegisterStateStore(WebApplicationBuilder builder) + { + var mode = builder.Configuration["AzLocal:StateMode"] ?? "InMemory"; + switch (mode) + { + case "Sqlite": + builder.Services.AddSingleton(); + break; + case "JsonSnapshot": + builder.Services.AddSingleton(); + break; + default: + builder.Services.AddSingleton(); + break; + } + } } diff --git a/src/AzLocal.Host/Program.cs b/src/AzLocal.Host/Program.cs index 1760df1..812dda6 100644 --- a/src/AzLocal.Host/Program.cs +++ b/src/AzLocal.Host/Program.cs @@ -1,6 +1,32 @@ +using AzLocal.Core.Interfaces; +using AzLocal.Host; +using AzLocal.Middleware; + var builder = WebApplication.CreateBuilder(args); +EmulatorBuilder.Configure(builder); + var app = builder.Build(); -app.MapGet("/", () => "Hello World!"); +// Middleware pipeline — order matters. +app.UseMiddleware(); // 1. log every request +app.UseMiddleware(); // 2. intercept IMDS token requests +app.UseMiddleware(); // 3. stamp all requests as authenticated +app.UseAuthorization(); // 4. enforce [Authorize] using the stub identity +app.UseMiddleware(); // 5. parse and store Azure request context + +// Auto-discover and register every IServiceHandler's routes. +// Adding a new service only requires registering it in HostBuilder — nothing changes here. +foreach (var handler in app.Services.GetServices()) +{ + handler.MapRoutes(app); + app.Logger.LogInformation("Registered service: {ServiceName}", handler.ServiceName); +} + +app.MapGet("/", () => new +{ + status = "AzLocal emulator running", + version = "1.0", + docs = "http://localhost:4566/" +}); app.Run(); diff --git a/src/AzLocal.Host/appsettings.json b/src/AzLocal.Host/appsettings.json index 10f68b8..161c961 100644 --- a/src/AzLocal.Host/appsettings.json +++ b/src/AzLocal.Host/appsettings.json @@ -2,8 +2,19 @@ "Logging": { "LogLevel": { "Default": "Information", + "AzLocal": "Debug", "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "AzLocal": { + "StateMode": "InMemory", + "BaseUrl": "http://localhost:4566", + "BlobStorePath": "", + "SnapshotPath": "", + "SqlitePath": "", + "SubscriptionId": "00000000-0000-0000-0000-000000000001", + "TenantId": "00000000-0000-0000-0000-000000000002", + "DefaultLocation": "eastus" + } } diff --git a/src/AzLocal.Services/Arm/SubscriptionHandler.cs b/src/AzLocal.Services/Arm/SubscriptionHandler.cs index 4538b55..46bd900 100644 --- a/src/AzLocal.Services/Arm/SubscriptionHandler.cs +++ b/src/AzLocal.Services/Arm/SubscriptionHandler.cs @@ -1,60 +1,68 @@ using AzLocal.Core.Interfaces; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AzLocal.Services.Arm; /// -/// Emulates the ARM Subscriptions API. Returns a single hardcoded fake subscription -/// so the Azure SDK can resolve subscription context without a real Azure tenant. +/// Emulates the ARM Subscriptions API. Returns a single fake subscription sourced from +/// AzLocal:SubscriptionId and AzLocal:TenantId in configuration so the +/// Azure SDK can resolve subscription context without a real Azure tenant. /// public class SubscriptionHandler : IServiceHandler { private readonly ILogger _logger; + private readonly object _fakeSubscription; + private readonly string _subscriptionId; public string ServiceName => "ARM/Subscriptions"; - // Fake subscription returned for every request — matches the fake tenant from AuthStubMiddleware. - private static readonly object FakeSubscription = new - { - subscriptionId = "00000000-0000-0000-0000-000000000001", - displayName = "AzLocal Dev Subscription", - state = "Enabled", - tenantId = "00000000-0000-0000-0000-000000000002" - }; - - public SubscriptionHandler(ILogger logger) + public SubscriptionHandler(IConfiguration config, ILogger logger) { _logger = logger; + _subscriptionId = config["AzLocal:SubscriptionId"] ?? "00000000-0000-0000-0000-000000000001"; + var tenantId = config["AzLocal:TenantId"] ?? "00000000-0000-0000-0000-000000000002"; + + _fakeSubscription = new + { + subscriptionId = _subscriptionId, + displayName = "AzLocal Dev Subscription", + state = "Enabled", + tenantId + }; } public void MapRoutes(WebApplication app) { + // Cast to Delegate so the framework treats this as a route handler and writes the + // IResult to the response — without the cast, the HttpContext-only signature matches + // RequestDelegate and the return value is silently discarded. app.MapGet(ArmRoutes.Subscriptions, (Delegate)ListSubscriptionsAsync); - app.MapGet(ArmRoutes.Subscription, GetSubscriptionAsync); + app.MapGet(ArmRoutes.Subscription, GetSubscriptionAsync); } - /// Returns the single fake subscription as a paged ARM list response. + /// Returns the configured fake subscription as a paged ARM list response. private Task ListSubscriptionsAsync(HttpContext ctx) { _logger.LogDebug("ListSubscriptions"); SetRequestId(ctx); - return Task.FromResult(Results.Ok(new { value = new[] { FakeSubscription } })); + return Task.FromResult(Results.Ok(new { value = new[] { _fakeSubscription } })); } /// Returns the fake subscription if the requested ID matches, otherwise 404. private Task GetSubscriptionAsync(string subscriptionId, HttpContext ctx) { SetRequestId(ctx); - if (!subscriptionId.Equals("00000000-0000-0000-0000-000000000001", StringComparison.OrdinalIgnoreCase)) + if (!subscriptionId.Equals(_subscriptionId, StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning("GetSubscription not found subscriptionId={SubscriptionId}", subscriptionId); return Task.FromResult(Results.NotFound()); } _logger.LogDebug("GetSubscription subscriptionId={SubscriptionId}", subscriptionId); - return Task.FromResult(Results.Ok(FakeSubscription)); + return Task.FromResult(Results.Ok(_fakeSubscription)); } private static void SetRequestId(HttpContext ctx) => diff --git a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs index bf1c5a6..340307b 100644 --- a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs +++ b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs @@ -143,7 +143,7 @@ private static void SetRequestId(HttpContext ctx) => expires = s.ExpiresOn?.ToUnixTimeSeconds() }; - private static object SecretResponse(string vault, KeyVaultSecret secret) => new + private object SecretResponse(string vault, KeyVaultSecret secret) => new { value = secret.Value, id = SecretUrl(vault, secret.Name, secret.Version),