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.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/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..46bd900 100644
--- a/src/AzLocal.Services/Arm/SubscriptionHandler.cs
+++ b/src/AzLocal.Services/Arm/SubscriptionHandler.cs
@@ -1,5 +1,70 @@
-namespace AzLocal.Services.Arm;
+using AzLocal.Core.Interfaces;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
-public class SubscriptionHandler
+namespace AzLocal.Services.Arm;
+
+///
+/// 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";
+
+ 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);
+ }
+
+ /// 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 } }));
+ }
+
+ /// Returns the fake subscription if the requested ID matches, otherwise 404.
+ private Task GetSubscriptionAsync(string subscriptionId, HttpContext ctx)
+ {
+ SetRequestId(ctx);
+ 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));
+ }
+
+ 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..340307b 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 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";
}