Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/AzLocal.Client/AzLocal.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<ProjectReference Include="..\AzLocal.Core\AzLocal.Core.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Azure.Core" Version="1.44.1" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
Expand Down
31 changes: 27 additions & 4 deletions src/AzLocal.Client/AzlocalClientFactory.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
namespace AzLocal.Client;
namespace AzLocal.Client;

public class AzlocalClientFactory
/// <summary>
/// Creates pre-configured <see cref="HttpClient"/> instances and exposes the
/// <see cref="AzlocalCredential"/> for use with Azure SDK clients in local development.
/// </summary>
public sealed class AzlocalClientFactory
{
private readonly string _baseUrl;
private const string DefaultBaseUrl = "http://localhost:4566";

public AzlocalClientFactory(string baseUrl = "http://localhost:4566")
/// <summary>The credential that satisfies Azure SDK authentication against the local emulator.</summary>
public AzlocalCredential Credential { get; } = new();

public AzlocalClientFactory(string baseUrl = DefaultBaseUrl)
{
_baseUrl = baseUrl;
_baseUrl = baseUrl.TrimEnd('/');
}

/// <summary>
/// Creates an <see cref="HttpClient"/> with its base address set to the emulator URL,
/// and a pre-set <c>Authorization</c> header carrying the fake bearer token.
/// </summary>
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;
}

/// <summary>Returns the blob storage base URL for the given account.</summary>
public Uri GetBlobEndpoint(string account) => new($"{_baseUrl}/azu/{account}");
}
19 changes: 17 additions & 2 deletions src/AzLocal.Client/AzlocalCredential.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
namespace AzLocal.Client;
using Azure.Core;

public class AzlocalCredential
namespace AzLocal.Client;

/// <summary>
/// An <see cref="TokenCredential"/> that returns the static fake token vended by
/// <c>ImdsMiddleware</c>. Pass this to any Azure SDK client constructor to authenticate
/// against the local AzLocal emulator without needing a real Azure AD tenant.
/// </summary>
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<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> ValueTask.FromResult(Token);
}
22 changes: 20 additions & 2 deletions src/AzLocal.Client/AzlocalFixture.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
namespace AzLocal.Client;
namespace AzLocal.Client;

public class AzlocalFixture
/// <summary>
/// xUnit test fixture that provides a pre-configured <see cref="AzlocalClientFactory"/>
/// pointed at the local AzLocal emulator. Use as a class fixture in integration tests:
/// <code>
/// public class MyTests : IClassFixture&lt;AzlocalFixture&gt;
/// {
/// public MyTests(AzlocalFixture fixture) { ... }
/// }
/// </code>
/// </summary>
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;
}
3 changes: 3 additions & 0 deletions src/AzLocal.Services/BlobStorage/BlobRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}
236 changes: 234 additions & 2 deletions src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs
Original file line number Diff line number Diff line change
@@ -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<BlobServiceHandler> _logger;

public string ServiceName => "BlobStorage";

public BlobServiceHandler(IStateStore state, IBlobFileStore blobs, ILogger<BlobServiceHandler> 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<IResult> ListContainersAsync(string account, HttpContext ctx)
{
var containers = await _state.ListAsync<BlobContainer>($"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<IResult> ListBlobsAsync(string account, string container, HttpContext ctx)
{
var blobs = await _state.ListAsync<BlobItem>($"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<IResult> 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<IResult> DeleteContainerAsync(string account, string container, HttpContext ctx)
{
await _state.DeleteAsync(ContainerKey(account, container));

var blobs = await _state.ListAsync<BlobItem>($"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<IResult> 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<IResult> DownloadBlobAsync(string account, string container, string blobName, HttpContext ctx)
{
var meta = await _state.GetAsync<BlobItem>(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<IResult> 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<IResult> GetBlobPropertiesAsync(string account, string container, string blobName, HttpContext ctx)
{
var meta = await _state.GetAsync<BlobItem>(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<XmlWriter> 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
}
1 change: 1 addition & 0 deletions src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

public class KeyVaultSecretHandler
{

}
1 change: 1 addition & 0 deletions src/AzLocal.Services/ServiceBus/ServiceBusRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

public static class ServiceBusRoutes
{

}
Loading