Skip to content

Commit 0b959f1

Browse files
committed
feat: Implement book cover image upload functionality with Azure Blob Storage.
1 parent 8227167 commit 0b959f1

11 files changed

Lines changed: 239 additions & 0 deletions

File tree

src/ApiService/BookStore.ApiService/Aggregates/BookAggregate.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class BookAggregate
1414
public List<Guid> AuthorIds { get; private set; } = [];
1515
public List<Guid> CategoryIds { get; private set; } = [];
1616
public bool IsDeleted { get; private set; }
17+
public string? CoverImageUrl { get; private set; }
1718

1819
// Marten uses this for rehydration
1920
void Apply(BookAdded @event)
@@ -44,6 +45,8 @@ void Apply(BookUpdated @event)
4445

4546
void Apply(BookRestored _) => IsDeleted = false;
4647

48+
void Apply(BookCoverUpdated @event) => CoverImageUrl = @event.CoverImageUrl;
49+
4750
// Command methods
4851
public static BookAdded Create(
4952
Guid id,
@@ -170,4 +173,19 @@ public BookRestored Restore()
170173

171174
return new BookRestored(Id);
172175
}
176+
177+
public BookCoverUpdated UpdateCoverImage(string coverImageUrl)
178+
{
179+
if (IsDeleted)
180+
{
181+
throw new InvalidOperationException("Cannot update cover for a deleted book");
182+
}
183+
184+
if (string.IsNullOrWhiteSpace(coverImageUrl))
185+
{
186+
throw new ArgumentException("Cover image URL is required", nameof(coverImageUrl));
187+
}
188+
189+
return new BookCoverUpdated(Id, coverImageUrl);
190+
}
173191
}

src/ApiService/BookStore.ApiService/BookStore.ApiService.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
<ItemGroup>
2424
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
25+
<PackageReference Include="Aspire.Azure.Storage.Blobs" Version="13.1.0" />
2526
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
2627
<PackageReference Include="Marten" Version="8.17.0" />
2728
<PackageReference Include="Marten.AspNetCore" Version="8.17.0" />

src/ApiService/BookStore.ApiService/Commands/Books/BookCommands.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,17 @@ public record RestoreBook(Guid Id)
5858
/// </summary>
5959
public string? ETag { get; init; }
6060
}
61+
62+
/// <summary>
63+
/// Command to update a book's cover image
64+
/// </summary>
65+
public record UpdateBookCover(
66+
Guid BookId,
67+
Stream ImageStream,
68+
string ContentType)
69+
{
70+
/// <summary>
71+
/// ETag for optimistic concurrency control
72+
/// </summary>
73+
public string? ETag { get; init; }
74+
}

src/ApiService/BookStore.ApiService/Endpoints/Admin/AdminBookEndpoints.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ public static RouteGroupBuilder MapAdminBookEndpoints(this RouteGroupBuilder gro
4949
.WithName("GetAllBooksAdmin")
5050
.WithSummary("Get all books");
5151

52+
_ = group.MapPost("/{id:guid}/cover", UploadCover)
53+
.WithName("UploadBookCover")
54+
.WithSummary("Upload book cover image")
55+
.DisableAntiforgery()
56+
.Accepts<IFormFile>("multipart/form-data");
57+
5258
return group;
5359
}
5460

@@ -135,5 +141,33 @@ static async Task<IResult> GetAllBooks(
135141

136142
return Results.Ok(books);
137143
}
144+
145+
static async Task<IResult> UploadCover(
146+
Guid id,
147+
IFormFile file,
148+
[FromServices] IMessageBus bus,
149+
HttpContext context)
150+
{
151+
// Validate file
152+
if (file.Length == 0)
153+
return Results.BadRequest("No file uploaded");
154+
155+
if (file.Length > 5 * 1024 * 1024) // 5MB limit
156+
return Results.BadRequest("File too large (max 5MB)");
157+
158+
var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp" };
159+
if (!allowedTypes.Contains(file.ContentType))
160+
return Results.BadRequest("Invalid file type (only JPEG, PNG, WebP allowed)");
161+
162+
var etag = context.Request.Headers["If-Match"].FirstOrDefault();
163+
164+
await using var stream = file.OpenReadStream();
165+
var command = new Commands.UpdateBookCover(id, stream, file.ContentType)
166+
{
167+
ETag = etag
168+
};
169+
170+
return await bus.InvokeAsync<IResult>(command);
171+
}
138172
}
139173
}

src/ApiService/BookStore.ApiService/Events/BookEvents.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ public record BookUpdated(
2424
public record BookSoftDeleted(Guid Id);
2525

2626
public record BookRestored(Guid Id);
27+
28+
public record BookCoverUpdated(Guid Id, string CoverImageUrl);

src/ApiService/BookStore.ApiService/Events/Notifications/DomainEventNotifications.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,14 @@ public record PublisherCreatedNotification(
7474
{
7575
public string EventType => "PublisherCreated";
7676
}
77+
78+
/// <summary>
79+
/// Notification when a book cover is updated
80+
/// </summary>
81+
public record BookCoverUpdatedNotification(
82+
Guid EntityId,
83+
string CoverUrl) : IDomainEventNotification
84+
{
85+
public string EventType => "BookCoverUpdated";
86+
public DateTimeOffset Timestamp { get; } = DateTimeOffset.UtcNow;
87+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using BookStore.ApiService.Aggregates;
2+
using BookStore.ApiService.Commands;
3+
using BookStore.ApiService.Events.Notifications;
4+
using BookStore.ApiService.Infrastructure;
5+
using BookStore.ApiService.Services;
6+
using Marten;
7+
8+
namespace BookStore.ApiService.Handlers.Books;
9+
10+
public static class BookCoverHandlers
11+
{
12+
public static async Task<(IResult, BookCoverUpdatedNotification)> Handle(
13+
UpdateBookCover command,
14+
IDocumentSession session,
15+
BlobStorageService blobStorage,
16+
HttpContext context)
17+
{
18+
// Get current stream state for ETag validation
19+
var streamState = await session.Events.FetchStreamStateAsync(command.BookId);
20+
if (streamState == null)
21+
{
22+
return (Results.NotFound(), null!);
23+
}
24+
25+
var currentETag = ETagHelper.GenerateETag(streamState.Version);
26+
27+
// Check If-Match header for optimistic concurrency
28+
if (!string.IsNullOrEmpty(command.ETag) &&
29+
!ETagHelper.CheckIfMatch(context, currentETag))
30+
{
31+
return (ETagHelper.PreconditionFailed(), null!);
32+
}
33+
34+
var aggregate = await session.Events.AggregateStreamAsync<BookAggregate>(command.BookId);
35+
if (aggregate == null)
36+
{
37+
return (Results.NotFound(), null!);
38+
}
39+
40+
// Upload to blob storage
41+
var coverUrl = await blobStorage.UploadBookCoverAsync(
42+
command.BookId,
43+
command.ImageStream,
44+
command.ContentType);
45+
46+
// Update aggregate
47+
var @event = aggregate.UpdateCoverImage(coverUrl);
48+
_ = session.Events.Append(command.BookId, @event);
49+
50+
// Get new stream state and return new ETag
51+
var newStreamState = await session.Events.FetchStreamStateAsync(command.BookId);
52+
var newETag = ETagHelper.GenerateETag(newStreamState!.Version);
53+
ETagHelper.AddETagHeader(context, newETag);
54+
55+
// Return notification for SignalR
56+
var notification = new BookCoverUpdatedNotification(
57+
aggregate.Id,
58+
coverUrl);
59+
60+
return (Results.Ok(new { CoverUrl = coverUrl }), notification);
61+
}
62+
}

src/ApiService/BookStore.ApiService/Program.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
// Add service defaults & Aspire client integrations.
2020
builder.AddServiceDefaults();
2121

22+
// Add Azure Blob Storage client (Azurite locally, Azure in production)
23+
builder.AddAzureBlobServiceClient("blobs");
24+
2225
// Configure JSON serialization for consistent API responses
2326
builder.Services.ConfigureHttpJsonOptions(options =>
2427
{
@@ -88,6 +91,7 @@
8891
_ = options.Events.AddEventType<BookStore.ApiService.Events.BookUpdated>();
8992
_ = options.Events.AddEventType<BookStore.ApiService.Events.BookSoftDeleted>();
9093
_ = options.Events.AddEventType<BookStore.ApiService.Events.BookRestored>();
94+
_ = options.Events.AddEventType<BookStore.ApiService.Events.BookCoverUpdated>();
9195

9296
_ = options.Events.AddEventType<BookStore.ApiService.Events.AuthorAdded>();
9397
_ = options.Events.AddEventType<BookStore.ApiService.Events.AuthorUpdated>();
@@ -158,6 +162,7 @@
158162
// Explicitly include static handler classes for discovery
159163
_ = opts.Discovery.IncludeType(typeof(BookStore.ApiService.Handlers.Authors.AuthorHandlers));
160164
_ = opts.Discovery.IncludeType(typeof(BookStore.ApiService.Handlers.Books.BookHandlers));
165+
_ = opts.Discovery.IncludeType(typeof(BookStore.ApiService.Handlers.Books.BookCoverHandlers));
161166
_ = opts.Discovery.IncludeType(typeof(BookStore.ApiService.Handlers.Categories.CategoryHandlers));
162167
_ = opts.Discovery.IncludeType(typeof(BookStore.ApiService.Handlers.Publishers.PublisherHandlers));
163168

@@ -178,6 +183,9 @@
178183
// Add SignalR for real-time notifications
179184
builder.Services.AddSignalR();
180185

186+
// Add Blob Storage service
187+
builder.Services.AddSingleton<BookStore.ApiService.Services.BlobStorageService>();
188+
181189
// Add Marten health checks
182190
builder.Services.AddHealthChecks()
183191
.AddNpgSql(builder.Configuration.GetConnectionString("bookstore")!);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using Azure.Storage.Blobs;
2+
using Azure.Storage.Blobs.Models;
3+
4+
namespace BookStore.ApiService.Services;
5+
6+
public class BlobStorageService(BlobServiceClient blobServiceClient)
7+
{
8+
const string ContainerName = "book-covers";
9+
static readonly string[] SupportedExtensions = ["jpg", "png", "webp"];
10+
11+
public async Task<string> UploadBookCoverAsync(
12+
Guid bookId,
13+
Stream imageStream,
14+
string contentType,
15+
CancellationToken cancellationToken = default)
16+
{
17+
var container = await GetContainerAsync(cancellationToken);
18+
19+
// Determine file extension from content type
20+
var extension = contentType switch
21+
{
22+
"image/jpeg" => "jpg",
23+
"image/png" => "png",
24+
"image/webp" => "webp",
25+
_ => "jpg" // Default to jpg for safety
26+
};
27+
28+
var blobName = $"{bookId}.{extension}";
29+
var blob = container.GetBlobClient(blobName);
30+
31+
await blob.UploadAsync(
32+
imageStream,
33+
new BlobHttpHeaders { ContentType = contentType },
34+
cancellationToken: cancellationToken);
35+
36+
return blob.Uri.ToString();
37+
}
38+
39+
public async Task<BlobDownloadResult> GetBookCoverAsync(
40+
Guid bookId,
41+
CancellationToken cancellationToken = default)
42+
{
43+
var container = await GetContainerAsync(cancellationToken);
44+
45+
// Try to find the blob with any supported extension
46+
foreach (var ext in SupportedExtensions)
47+
{
48+
var blob = container.GetBlobClient($"{bookId}.{ext}");
49+
if (await blob.ExistsAsync(cancellationToken))
50+
{
51+
return await blob.DownloadContentAsync(cancellationToken);
52+
}
53+
}
54+
55+
throw new FileNotFoundException($"Book cover not found for book {bookId}");
56+
}
57+
58+
public async Task DeleteBookCoverAsync(
59+
Guid bookId,
60+
CancellationToken cancellationToken = default)
61+
{
62+
var container = await GetContainerAsync(cancellationToken);
63+
64+
// Delete blob with any supported extension
65+
foreach (var ext in SupportedExtensions)
66+
{
67+
var blob = container.GetBlobClient($"{bookId}.{ext}");
68+
await blob.DeleteIfExistsAsync(cancellationToken: cancellationToken);
69+
}
70+
}
71+
72+
async Task<BlobContainerClient> GetContainerAsync(
73+
CancellationToken cancellationToken = default)
74+
{
75+
var container = blobServiceClient.GetBlobContainerClient(ContainerName);
76+
await container.CreateIfNotExistsAsync(
77+
PublicAccessType.Blob,
78+
cancellationToken: cancellationToken);
79+
return container;
80+
}
81+
}

src/BookStore.AppHost/AppHost.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@
77

88
var bookStoreDb = postgres.AddDatabase("bookstore");
99

10+
// Add Azure Storage with Azurite emulator for local development
11+
var storage = builder.AddAzureStorage("storage")
12+
.RunAsEmulator(); // Runs Azurite container automatically
13+
14+
var blobs = storage.AddBlobs("blobs");
15+
1016
var apiService = builder.AddProject<Projects.BookStore_ApiService>("apiservice")
1117
.WithReference(bookStoreDb)
18+
.WithReference(blobs) // Add blob storage reference
1219
.WithHttpHealthCheck("/health")
1320
.WithExternalHttpEndpoints()
1421
.WithUrlForEndpoint("http", url =>

0 commit comments

Comments
 (0)