From bc873319039505f7dd322a2686c4fbe6224957bd Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Wed, 17 Jul 2024 21:47:31 -0400 Subject: [PATCH] Adds streaming to GetContents --- .../Data/ApplicationDbContextInitializer.cs | 2 +- ...Shared.cs => ClaimsPrincipalExtensions.cs} | 4 +- src/Web/Common/GuidExtensions.cs | 30 ++++++++ src/Web/Common/MissingClaimException.cs | 3 + .../Data/ContentDbContextInitializer.cs | 71 +++++++++++++++++++ src/Web/Contents/DependencyInjection.cs | 18 +++++ src/Web/Contents/Handlers/GetContents.cs | 24 ++++--- .../Contents/Handlers/GetContentsByUser.cs | 28 ++++++-- src/Web/Messages/DependencyInjection.cs | 16 +++++ src/Web/Messages/Handlers/GetMessages.cs | 12 ++-- .../Messages/Handlers/GetMessagesByUser.cs | 13 ++-- src/Web/Program.cs | 22 +++--- 12 files changed, 201 insertions(+), 42 deletions(-) rename src/Web/Common/{Shared.cs => ClaimsPrincipalExtensions.cs} (91%) create mode 100644 src/Web/Common/GuidExtensions.cs create mode 100644 src/Web/Common/MissingClaimException.cs create mode 100644 src/Web/Contents/Data/ContentDbContextInitializer.cs create mode 100644 src/Web/Contents/DependencyInjection.cs create mode 100644 src/Web/Messages/DependencyInjection.cs diff --git a/src/Infrastructure/Data/ApplicationDbContextInitializer.cs b/src/Infrastructure/Data/ApplicationDbContextInitializer.cs index 71abda5..233b97a 100644 --- a/src/Infrastructure/Data/ApplicationDbContextInitializer.cs +++ b/src/Infrastructure/Data/ApplicationDbContextInitializer.cs @@ -14,7 +14,7 @@ namespace Hutopy.Infrastructure.Data; public static class InitializerExtensions { - public static async Task InitialiseDatabaseAsync(this WebApplication app) + public static async Task InitialiseApplicationDatabaseAsync(this WebApplication app) { using var scope = app.Services.CreateScope(); diff --git a/src/Web/Common/Shared.cs b/src/Web/Common/ClaimsPrincipalExtensions.cs similarity index 91% rename from src/Web/Common/Shared.cs rename to src/Web/Common/ClaimsPrincipalExtensions.cs index e36a0ec..3f57da4 100644 --- a/src/Web/Common/Shared.cs +++ b/src/Web/Common/ClaimsPrincipalExtensions.cs @@ -2,8 +2,6 @@ namespace Hutopy.Web.Common; -public class Shared(string claimName) : Exception; - public static class ClaimsPrincipalExtensions { public static Guid GetUserId(this ClaimsPrincipal claims) @@ -30,7 +28,7 @@ public static class ClaimsPrincipalExtensions { var claim = claims.FindFirst(key); - if (claim is null) throw new Shared(key); + if (claim is null) throw new MissingClaimException(key); if (typeof(TValue) == typeof(Guid)) { diff --git a/src/Web/Common/GuidExtensions.cs b/src/Web/Common/GuidExtensions.cs new file mode 100644 index 0000000..d02c4a7 --- /dev/null +++ b/src/Web/Common/GuidExtensions.cs @@ -0,0 +1,30 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace Hutopy.Web.Common; + +public static class GuidHelper +{ + // TODO: Delete when NET9 is release! + public static Guid GenerateUuidV7() + { + Span uuidv7 = stackalloc byte[16]; + ulong unixTimeTicks = (ulong)DateTimeOffset.UtcNow.Subtract(DateTimeOffset.UnixEpoch).Ticks; + ulong unixTsMs = (unixTimeTicks & 0x0FFFFFFFFFFFF000) << 4; + ulong unixTsMsVer = unixTsMs | 0b0111UL << 12; + ulong randA = unixTimeTicks & 0x0000000000000FFF; + // merge "unix_ts_ms", "ver" and "rand_a" + ulong hi = unixTsMsVer | randA; + BinaryPrimitives.WriteUInt64BigEndian(uuidv7, hi); + // fill "rand_b" and "var" + RandomNumberGenerator.Fill(uuidv7[8..]); + // set "var" + byte varOctet = uuidv7[8]; + varOctet = (byte)(varOctet & 0b00111111); + varOctet = (byte)(varOctet | 0b10111111); + uuidv7[8] = varOctet; + + var value = Convert.ToHexString(uuidv7); + return Guid.Parse(value); + } +} diff --git a/src/Web/Common/MissingClaimException.cs b/src/Web/Common/MissingClaimException.cs new file mode 100644 index 0000000..266d109 --- /dev/null +++ b/src/Web/Common/MissingClaimException.cs @@ -0,0 +1,3 @@ +namespace Hutopy.Web.Common; + +public class MissingClaimException(string claimName) : Exception; diff --git a/src/Web/Contents/Data/ContentDbContextInitializer.cs b/src/Web/Contents/Data/ContentDbContextInitializer.cs new file mode 100644 index 0000000..b5726ff --- /dev/null +++ b/src/Web/Contents/Data/ContentDbContextInitializer.cs @@ -0,0 +1,71 @@ +using Hutopy.Infrastructure.Identity; +using Hutopy.Web.Common; +using Microsoft.EntityFrameworkCore; + +namespace Hutopy.Web.Contents.Data; + +public static class InitializerExtensions +{ + public static async Task InitialiseContentDatabaseAsync(this WebApplication app, CancellationToken ct = default) + { + using var scope = app.Services.CreateScope(); + + var initializer = scope.ServiceProvider.GetRequiredService(); + + await initializer.InitialiseAsync(ct); + + await initializer.SeedAsync(ct); + } +} + +internal class ContentDbContextInitializer( + ILogger logger, + ApplicationUserManager userManager, + ContentDbContext context) +{ + public async Task InitialiseAsync(CancellationToken ct = default) + { + try + { + await context.Database.MigrateAsync(ct); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while initialising the database."); + throw; + } + } + + public async Task SeedAsync(CancellationToken ct = default) + { + try + { + var administratorUser = await userManager.FindByNameAsync("administrator@localhost"); + var administratorId= Guid.Parse(administratorUser!.Id); + await TrySeedAsync(administratorId, 100, ct); + + await context.SaveChangesAsync(ct); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while seeding the database."); + throw; + } + } + + private async Task TrySeedAsync(Guid creatorId, int contentCount, CancellationToken ct = default) + { + for (var c = 0; c < contentCount; c++) + { + await context.Contents.AddAsync( + new Content + { + Id = GuidHelper.GenerateUuidV7(), + CreatedBy = creatorId, + Title = $"Title {c}", + Description = $"Description {c}" + }, + ct); + } + } +} diff --git a/src/Web/Contents/DependencyInjection.cs b/src/Web/Contents/DependencyInjection.cs new file mode 100644 index 0000000..7946338 --- /dev/null +++ b/src/Web/Contents/DependencyInjection.cs @@ -0,0 +1,18 @@ +using Hutopy.Web.Contents.Data; +using Microsoft.EntityFrameworkCore; + +namespace Hutopy.Web.Contents; + +public static class DependencyInjection +{ + public static IServiceCollection AddContentModule( + this IServiceCollection services, + Action? configureAction = null) + { + services.AddDbContext(configureAction); + + services.AddScoped(); + + return services; + } +} diff --git a/src/Web/Contents/Handlers/GetContents.cs b/src/Web/Contents/Handlers/GetContents.cs index aa73599..26d1348 100644 --- a/src/Web/Contents/Handlers/GetContents.cs +++ b/src/Web/Contents/Handlers/GetContents.cs @@ -4,27 +4,35 @@ using Microsoft.EntityFrameworkCore; namespace Hutopy.Web.Contents.Handlers; +public sealed class GetContentsRequest +{ + public Guid ContentId { get; set; } +} + public class GetContents( ContentDbContext context) - : EndpointWithoutRequest + : Endpoint { public override void Configure() { Get("/api/contents/{ContentId:guid}"); - Options( o => o.WithTags("Contents")); + Options(o => o.WithTags("Contents")); AllowAnonymous(); } public override async Task HandleAsync( + GetContentsRequest req, CancellationToken ct) { - var contentId = Route("ContentId"); - - var comments = await context + var content = await context .Contents - .Where(c => c.Id == contentId) - .ToListAsync(cancellationToken: ct); + .FirstOrDefaultAsync( + c => c.Id == req.ContentId, + cancellationToken: ct); - await SendAsync(comments.First(), cancellation: ct); + if (content is null) + await SendNotFoundAsync(cancellation: ct); + else + await SendAsync(content, cancellation: ct); } } diff --git a/src/Web/Contents/Handlers/GetContentsByUser.cs b/src/Web/Contents/Handlers/GetContentsByUser.cs index 70152af..a5e0a8f 100644 --- a/src/Web/Contents/Handlers/GetContentsByUser.cs +++ b/src/Web/Contents/Handlers/GetContentsByUser.cs @@ -4,26 +4,40 @@ using Microsoft.EntityFrameworkCore; namespace Hutopy.Web.Contents.Handlers; +public sealed class GetContentsByUserRequest +{ + public Guid UserId { get; set; } + [BindFrom("max_items")] public int MaxItems { get; set; } = 10; + [BindFrom("last_id")] public Guid? LastId { get; set; } +} + public class GetContentsByUser( ContentDbContext context) - : EndpointWithoutRequest> + : Endpoint> { public override void Configure() { Get("/api/contents/user/{UserId:guid}"); - Options( o => o.WithTags("Contents")); + Options(o => o.WithTags("Contents")); AllowAnonymous(); } public override async Task HandleAsync( + GetContentsByUserRequest req, CancellationToken ct) { - var userId = Route("UserId"); - - var posts = await context + var query = context .Contents - .Where(c => c.CreatedBy == userId) - .ToListAsync(cancellationToken: ct); + .Where(c => c.CreatedBy == req.UserId); + + if (req.LastId is not null) + query = query.OrderBy(c => c.Id).Where(c => c.Id > req.LastId.Value); + else + query = query.OrderBy(c => c.Id); + + query = query.Take(req.MaxItems); + + var posts = await query.ToListAsync(cancellationToken: ct); await SendAsync(posts, cancellation: ct); } diff --git a/src/Web/Messages/DependencyInjection.cs b/src/Web/Messages/DependencyInjection.cs new file mode 100644 index 0000000..8e89468 --- /dev/null +++ b/src/Web/Messages/DependencyInjection.cs @@ -0,0 +1,16 @@ +using Hutopy.Web.Messages.Data; +using Microsoft.EntityFrameworkCore; + +namespace Hutopy.Web.Messages; + +public static class DependencyInjection +{ + public static IServiceCollection AddMessagingModule( + this IServiceCollection services, + Action? configureAction = null) + { + services.AddDbContext(configureAction); + + return services; + } +} diff --git a/src/Web/Messages/Handlers/GetMessages.cs b/src/Web/Messages/Handlers/GetMessages.cs index 59bb41e..6325548 100644 --- a/src/Web/Messages/Handlers/GetMessages.cs +++ b/src/Web/Messages/Handlers/GetMessages.cs @@ -4,9 +4,14 @@ using Microsoft.EntityFrameworkCore; namespace Hutopy.Web.Messages.Handlers; +public sealed class GetMessagesRequest +{ + public Guid SubjectId { get; set; } +} + public class GetMessages( MessagingDbContext context) - : EndpointWithoutRequest> + : Endpoint> { public override void Configure() { @@ -16,13 +21,12 @@ public class GetMessages( } public override async Task HandleAsync( + GetMessagesRequest req, CancellationToken ct) { - var subjectId = Route("SubjectId"); - var comments = await context .Messages - .Where(c => c.SubjectId == subjectId) + .Where(c => c.SubjectId == req.SubjectId) .ToListAsync(cancellationToken: ct); await SendAsync(comments, cancellation: ct); diff --git a/src/Web/Messages/Handlers/GetMessagesByUser.cs b/src/Web/Messages/Handlers/GetMessagesByUser.cs index 5d101f6..6f1e447 100644 --- a/src/Web/Messages/Handlers/GetMessagesByUser.cs +++ b/src/Web/Messages/Handlers/GetMessagesByUser.cs @@ -1,13 +1,17 @@ using FastEndpoints; using Hutopy.Web.Messages.Data; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Hutopy.Web.Messages.Handlers; +public sealed class GetMessagesByUserRequest +{ + public Guid UserId { get; set; } +} + public class GetMessagesByUser( MessagingDbContext context) - : EndpointWithoutRequest> + : Endpoint> { public override void Configure() { @@ -16,13 +20,12 @@ public class GetMessagesByUser( } public override async Task HandleAsync( + GetMessagesByUserRequest req, CancellationToken ct) { - var userId = Route("UserId"); - var posts = await context .Messages - .Where(c => c.CreatedBy == userId) + .Where(c => c.CreatedBy == req.UserId) .ToListAsync(cancellationToken: ct); await SendAsync(posts, cancellation: ct); diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 3aad5cb..fec1d10 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -5,8 +5,9 @@ using Hutopy.Infrastructure; using Hutopy.Infrastructure.Data; using Hutopy.Infrastructure.Identity; using Hutopy.Web; +using Hutopy.Web.Contents; using Hutopy.Web.Contents.Data; -using Hutopy.Web.Messages.Data; +using Hutopy.Web.Messages; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using NSwag; @@ -37,7 +38,7 @@ builder.Services.AddCors(options => .AllowAnyHeader() .AllowCredentials(); }); - + options.AddPolicy("AllowHutopyUiPreview", policy => { policy.WithOrigins("https://zealous-bay-08204590f-preview.eastus2.5.azurestaticapps.net") @@ -78,20 +79,12 @@ builder.Services.AddOpenApiDocument((configure, sp) => configure.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("JWT")); }); -builder.Services.AddFastEndpoints(); - var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Missing ConnectionStrings:DefaultConnection"); -builder.Services.AddDbContext((_, options) => -{ - options.UseSqlServer(connectionString); -}); - -builder.Services.AddDbContext((_, options) => -{ - options.UseSqlServer(connectionString); -}); +builder.Services.AddFastEndpoints(); +builder.Services.AddContentModule(options => options.UseSqlServer(connectionString)); +builder.Services.AddMessagingModule(options => options.UseSqlServer(connectionString)); builder.Services.Configure(builder.Configuration.GetRequiredSection(JwtOptions.SectionName)); @@ -109,7 +102,8 @@ app.UseAuthentication(); app.UseAuthorization(); // Initialize and seed the db. -await app.InitialiseDatabaseAsync(); +await app.InitialiseApplicationDatabaseAsync(); +await app.InitialiseContentDatabaseAsync(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment())