From b47bac67ca937f1c9beccedfb3501be65a761dd2 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 17 May 2025 23:47:19 +0200 Subject: [PATCH] some changes --- Femto.Api/Femto.Api.csproj | 4 - Femto.Api/Infrastructure/EventBus.cs | 32 ++++++++ Femto.Api/Program.cs | 10 ++- .../DbContextDomainExtensions.cs | 35 +++++++++ .../Outbox/IOutboxMessageHandler.cs | 2 +- Femto.Common/Infrastructure/Outbox/Outbox.cs | 2 +- .../Infrastructure/Outbox/OutboxEntry.cs | 14 ++-- .../Infrastructure/Outbox/OutboxProcessor.cs | 2 +- .../Outbox/OutboxServiceExtension.cs | 8 +- .../SaveChangesPipelineBehaviour.cs | 29 +------- Femto.Common/Integration/Event.cs | 6 ++ .../{IIntegrationEvent.cs => IEvent.cs} | 2 +- Femto.Common/Integration/IEventBus.cs | 13 ++++ Femto.Common/Integration/IEventHandler.cs | 24 ++++++ .../Integration/IIntegrationEventBus.cs | 6 -- .../Migrations/20250425121459_Init.sql | 74 ------------------- .../Events/UserWasCreatedIntegrationEvent.cs | 5 ++ .../Femto.Modules.Auth.Contracts.csproj | 13 ++++ Femto.Modules.Auth/Application/AuthStartup.cs | 54 +++++++++++--- .../Contracts/AuthenticationService.cs | 23 ------ .../Contracts/IAuthenticationService.cs | 7 -- Femto.Modules.Auth/Contracts/UserInfo.cs | 3 - Femto.Modules.Auth/Femto.Modules.Auth.csproj | 6 +- .../Infrastructure/OutboxMessageHandler.cs | 22 ++++++ .../UserWasCreatedHandler.cs | 20 +++++ Femto.Modules.Auth/Models/UserSession.cs | 3 +- Femto.Modules.Blog/Application/BlogContext.cs | 2 + Femto.Modules.Blog/Application/BlogStartup.cs | 63 +++++++++++++++- .../Configurations/AuthorConfiguration.cs | 14 ++++ .../OutboxEntryConfiguration.cs | 2 - Femto.Modules.Blog/Domain/Authors/Author.cs | 17 +++++ ...ntegrationEvent.cs => PostCreatedEvent.cs} | 4 +- Femto.Modules.Blog/Femto.Modules.Blog.csproj | 5 +- .../PostCreatedIntegrationEventHandler.cs | 4 +- .../Handlers/UserCreatedEventHandler.cs | 29 ++++++++ .../Infrastructure/OutboxMessageHandler.cs | 22 ++++++ FemtoBackend.sln | 6 ++ 37 files changed, 397 insertions(+), 190 deletions(-) create mode 100644 Femto.Api/Infrastructure/EventBus.cs create mode 100644 Femto.Common/Infrastructure/DbContextDomainExtensions.cs create mode 100644 Femto.Common/Integration/Event.cs rename Femto.Common/Integration/{IIntegrationEvent.cs => IEvent.cs} (63%) create mode 100644 Femto.Common/Integration/IEventBus.cs create mode 100644 Femto.Common/Integration/IEventHandler.cs delete mode 100644 Femto.Common/Integration/IIntegrationEventBus.cs delete mode 100644 Femto.Database/Migrations/20250425121459_Init.sql create mode 100644 Femto.Modules.Auth.Contracts/Events/UserWasCreatedIntegrationEvent.cs create mode 100644 Femto.Modules.Auth.Contracts/Femto.Modules.Auth.Contracts.csproj delete mode 100644 Femto.Modules.Auth/Contracts/AuthenticationService.cs delete mode 100644 Femto.Modules.Auth/Contracts/IAuthenticationService.cs delete mode 100644 Femto.Modules.Auth/Contracts/UserInfo.cs create mode 100644 Femto.Modules.Auth/Infrastructure/OutboxMessageHandler.cs create mode 100644 Femto.Modules.Auth/Models/DomainEventHandlers/UserWasCreatedHandler.cs create mode 100644 Femto.Modules.Blog/Application/Configurations/AuthorConfiguration.cs create mode 100644 Femto.Modules.Blog/Domain/Authors/Author.cs rename Femto.Modules.Blog/Events/{PostCreatedIntegrationEvent.cs => PostCreatedEvent.cs} (51%) create mode 100644 Femto.Modules.Blog/Handlers/UserCreatedEventHandler.cs create mode 100644 Femto.Modules.Blog/Infrastructure/OutboxMessageHandler.cs diff --git a/Femto.Api/Femto.Api.csproj b/Femto.Api/Femto.Api.csproj index fdf0752..4625094 100644 --- a/Femto.Api/Femto.Api.csproj +++ b/Femto.Api/Femto.Api.csproj @@ -29,8 +29,4 @@ - - - - diff --git a/Femto.Api/Infrastructure/EventBus.cs b/Femto.Api/Infrastructure/EventBus.cs new file mode 100644 index 0000000..0f4d32c --- /dev/null +++ b/Femto.Api/Infrastructure/EventBus.cs @@ -0,0 +1,32 @@ +using System.Threading.Channels; +using Femto.Common.Integration; + +namespace Femto.Api.Infrastructure; + +public class EventBus(Channel channel) : BackgroundService, IEventBus +{ + private readonly ICollection _subscribers = []; + + public Task Publish(T evt) + where T : IEvent + { + channel.Writer.TryWrite(evt); + + return Task.CompletedTask; + } + + public void Subscribe(IEventBus.Subscriber subscriber) + { + this._subscribers.Add(subscriber); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var message in channel.Reader.ReadAllAsync(stoppingToken)) + { + await Task.WhenAll( + this._subscribers.Select(subscriber => subscriber.Invoke(message, stoppingToken)) + ); + } + } +} diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs index 7efeeb7..f683641 100644 --- a/Femto.Api/Program.cs +++ b/Femto.Api/Program.cs @@ -1,9 +1,12 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading.Channels; using Femto.Api; using Femto.Api.Auth; +using Femto.Api.Infrastructure; using Femto.Common; using Femto.Common.Domain; +using Femto.Common.Integration; using Femto.Modules.Auth.Application; using Femto.Modules.Blog.Application; using Femto.Modules.Media.Application; @@ -27,9 +30,12 @@ if (blobStorageRoot is null) throw new Exception("no blob storage root found"); -builder.Services.InitializeBlogModule(connectionString); +var eventBus = new EventBus(Channel.CreateUnbounded()); +builder.Services.AddHostedService(_ => eventBus); + +builder.Services.InitializeBlogModule(connectionString, eventBus); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); -builder.Services.InitializeAuthenticationModule(connectionString); +builder.Services.InitializeAuthenticationModule(connectionString, eventBus); builder.Services.AddScoped(); builder.Services.AddScoped(s => s.GetRequiredService()); diff --git a/Femto.Common/Infrastructure/DbContextDomainExtensions.cs b/Femto.Common/Infrastructure/DbContextDomainExtensions.cs new file mode 100644 index 0000000..b3e1c7c --- /dev/null +++ b/Femto.Common/Infrastructure/DbContextDomainExtensions.cs @@ -0,0 +1,35 @@ +using Femto.Common.Domain; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Femto.Common.Infrastructure; + +public static class DbContextDomainExtensions +{ + public static async Task EmitDomainEvents(this DbContext context, ILogger logger, IPublisher publisher, CancellationToken cancellationToken) + { + var domainEvents = context + .ChangeTracker.Entries() + .SelectMany(e => + { + var events = e.Entity.DomainEvents; + e.Entity.ClearDomainEvents(); + return events; + }) + .ToList(); + + logger.LogTrace("loaded {Count} domain events", domainEvents.Count); + + foreach (var domainEvent in domainEvents) + { + logger.LogTrace( + "publishing {Type} domain event {Id}", + domainEvent.GetType().Name, + domainEvent.EventId + ); + await publisher.Publish(domainEvent, cancellationToken); + } + + } +} \ No newline at end of file diff --git a/Femto.Common/Infrastructure/Outbox/IOutboxMessageHandler.cs b/Femto.Common/Infrastructure/Outbox/IOutboxMessageHandler.cs index c58eee9..9c3b327 100644 --- a/Femto.Common/Infrastructure/Outbox/IOutboxMessageHandler.cs +++ b/Femto.Common/Infrastructure/Outbox/IOutboxMessageHandler.cs @@ -4,5 +4,5 @@ namespace Femto.Common.Infrastructure.Outbox; public interface IOutboxMessageHandler { - Task Publish(TNotification notification, CancellationToken executionContextCancellationToken); + Task HandleMessage(TNotification notification, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Femto.Common/Infrastructure/Outbox/Outbox.cs b/Femto.Common/Infrastructure/Outbox/Outbox.cs index e2878d5..a201fa9 100644 --- a/Femto.Common/Infrastructure/Outbox/Outbox.cs +++ b/Femto.Common/Infrastructure/Outbox/Outbox.cs @@ -13,7 +13,7 @@ public class Outbox(TContext context, IOutboxMessageMapping mapping) w TMessage message, CancellationToken cancellationToken ) - where TMessage : IIntegrationEvent + where TMessage : IEvent { var eventName = mapping.GetEventName(typeof(TMessage)); if (eventName is null) diff --git a/Femto.Common/Infrastructure/Outbox/OutboxEntry.cs b/Femto.Common/Infrastructure/Outbox/OutboxEntry.cs index b34a4aa..1d5aeda 100644 --- a/Femto.Common/Infrastructure/Outbox/OutboxEntry.cs +++ b/Femto.Common/Infrastructure/Outbox/OutboxEntry.cs @@ -3,7 +3,7 @@ namespace Femto.Common.Infrastructure.Outbox; public class OutboxEntry { private const int MaxRetries = 5; - + public Guid Id { get; private set; } public string EventType { get; private set; } = null!; @@ -18,7 +18,7 @@ public class OutboxEntry public int RetryCount { get; private set; } = 0; public string? LastError { get; private set; } public OutboxEntryStatus Status { get; private set; } - + private OutboxEntry() { } public OutboxEntry(Guid eventId, Guid aggregateId, string eventType, string payload) @@ -35,7 +35,7 @@ public class OutboxEntry this.ProcessedAt = DateTime.UtcNow; this.Status = OutboxEntryStatus.Completed; } - + public void Fail(string error) { if (this.RetryCount >= MaxRetries) @@ -53,7 +53,7 @@ public class OutboxEntry public enum OutboxEntryStatus { - Pending, - Completed, - Failed -} \ No newline at end of file + Pending = 0, + Completed = 1, + Failed = 2, +} diff --git a/Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs b/Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs index 46f72b6..00fcddf 100644 --- a/Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs +++ b/Femto.Common/Infrastructure/Outbox/OutboxProcessor.cs @@ -56,7 +56,7 @@ public class OutboxProcessor( message.AggregateId ); - await handler.Publish(notification, executionContext.CancellationToken); + await handler.HandleMessage(notification, executionContext.CancellationToken); message.Succeed(); } diff --git a/Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs b/Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs index d2bdfbc..5aaa2ba 100644 --- a/Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs +++ b/Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs @@ -9,21 +9,23 @@ namespace Femto.Common.Infrastructure.Outbox; public static class OutboxServiceExtension { - public static void AddOutbox( + public static void AddOutbox( this IServiceCollection services, Func? contextFactory = null ) where TContext : DbContext, IOutboxContext + where TMessageHandler : class, IOutboxMessageHandler { - services.AddSingleton(); - + services.AddScoped(c => contextFactory?.Invoke(c) ?? c.GetRequiredService() ); services.AddScoped>(); + services.AddScoped(); + services.AddQuartz(q => { var jobKey = JobKey.Create(nameof(OutboxProcessor)); diff --git a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs index dc6719d..d9aaf03 100644 --- a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs +++ b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs @@ -19,39 +19,12 @@ public class SaveChangesPipelineBehaviour( ) { var response = await next(cancellationToken); - if (context.ChangeTracker.HasChanges()) { - await this.EmitDomainEvents(cancellationToken); - + await context.EmitDomainEvents(logger, publisher, cancellationToken); logger.LogDebug("saving changes"); await context.SaveChangesAsync(cancellationToken); } return response; } - - private async Task EmitDomainEvents(CancellationToken cancellationToken) - { - var domainEvents = context - .ChangeTracker.Entries() - .SelectMany(e => - { - var events = e.Entity.DomainEvents; - e.Entity.ClearDomainEvents(); - return events; - }) - .ToList(); - - logger.LogTrace("loaded {Count} domain events", domainEvents.Count); - - foreach (var domainEvent in domainEvents) - { - logger.LogTrace( - "publishing {Type} domain event {Id}", - domainEvent.GetType().Name, - domainEvent.EventId - ); - await publisher.Publish(domainEvent, cancellationToken); - } - } } diff --git a/Femto.Common/Integration/Event.cs b/Femto.Common/Integration/Event.cs new file mode 100644 index 0000000..7983830 --- /dev/null +++ b/Femto.Common/Integration/Event.cs @@ -0,0 +1,6 @@ +namespace Femto.Common.Integration; + +public abstract record Event : IEvent +{ + public Guid EventId { get; } = Guid.CreateVersion7(); +} \ No newline at end of file diff --git a/Femto.Common/Integration/IIntegrationEvent.cs b/Femto.Common/Integration/IEvent.cs similarity index 63% rename from Femto.Common/Integration/IIntegrationEvent.cs rename to Femto.Common/Integration/IEvent.cs index 8872940..39143cd 100644 --- a/Femto.Common/Integration/IIntegrationEvent.cs +++ b/Femto.Common/Integration/IEvent.cs @@ -2,7 +2,7 @@ using MediatR; namespace Femto.Common.Integration; -public interface IIntegrationEvent : INotification +public interface IEvent : INotification { public Guid EventId { get; } } \ No newline at end of file diff --git a/Femto.Common/Integration/IEventBus.cs b/Femto.Common/Integration/IEventBus.cs new file mode 100644 index 0000000..4082bc1 --- /dev/null +++ b/Femto.Common/Integration/IEventBus.cs @@ -0,0 +1,13 @@ +namespace Femto.Common.Integration; + +public interface IEventBus : IEventPublisher +{ + public delegate Task Subscriber(IEvent evt, CancellationToken cancellationToken); + void Subscribe(Subscriber subscriber); + +} + +public interface IEventPublisher +{ + Task Publish(T evt) where T : IEvent; +} \ No newline at end of file diff --git a/Femto.Common/Integration/IEventHandler.cs b/Femto.Common/Integration/IEventHandler.cs new file mode 100644 index 0000000..5456948 --- /dev/null +++ b/Femto.Common/Integration/IEventHandler.cs @@ -0,0 +1,24 @@ +namespace Femto.Common.Integration; + +public interface IEventHandler +{ + Task Handle(IEvent evt, CancellationToken cancellationToken = default); +} + +public abstract class EventHandler : IEventHandler + where T : IEvent +{ + protected abstract Task Handle(T evt, CancellationToken cancellationToken); + + public async Task Handle(IEvent evt, CancellationToken cancellationToken = default) + { + if (evt is not T typedEvt) + { + throw new InvalidOperationException( + $"Event {evt.GetType()} is not of type {typeof(T)}" + ); + } + + await Handle(typedEvt, cancellationToken); + } +} diff --git a/Femto.Common/Integration/IIntegrationEventBus.cs b/Femto.Common/Integration/IIntegrationEventBus.cs deleted file mode 100644 index 087106c..0000000 --- a/Femto.Common/Integration/IIntegrationEventBus.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Femto.Common.Integration; - -public interface IIntegrationEventBus -{ - void Subscribe() where T : IIntegrationEvent; -} \ No newline at end of file diff --git a/Femto.Database/Migrations/20250425121459_Init.sql b/Femto.Database/Migrations/20250425121459_Init.sql deleted file mode 100644 index f42403f..0000000 --- a/Femto.Database/Migrations/20250425121459_Init.sql +++ /dev/null @@ -1,74 +0,0 @@ --- Migration: Init --- Created at: 25/04/2025 12:14:59 - -CREATE SCHEMA blog; - -CREATE TABLE blog.author -( - id uuid PRIMARY KEY, - username varchar(64) UNIQUE NOT NULL -); - -CREATE TABLE blog.post -( - id uuid PRIMARY KEY, - content text NOT NULL, - posted_on timestamptz NOT NULL DEFAULT now(), - author_id uuid NOT NULL REFERENCES blog.author (id) on DELETE CASCADE -); - -CREATE TABLE blog.post_media -( - id uuid PRIMARY KEY, - post_id uuid NOT NULL REFERENCES blog.post (id) ON DELETE CASCADE, - url text NOT NULL, - type varchar(64), - width int, - height int, - ordering int NOT NULL -); - -CREATE TYPE outbox_status AS ENUM ('pending', 'completed', 'failed'); - -CREATE TABLE blog.outbox -( - - id uuid PRIMARY KEY, - event_type text NOT NULL, - aggregate_id uuid NOT NULL, - payload jsonb NOT NULL, - created_at timestamp DEFAULT now() NOT NULL, - processed_at timestamp, - next_retry_at timestamp, - retry_count int DEFAULT 0 NOT NULL, - last_error text, - status outbox_status DEFAULT 'pending' NOT NULL -); - -CREATE SCHEMA media; - -CREATE TABLE media.saved_blob -( - id uuid PRIMARY KEY, - uploaded_on timestamp DEFAULT now() NOT NULL, - type varchar(64) NOT NULL, - size int -); - -CREATE SCHEMA authn; - -CREATE TABLE authn.user_identity -( - id uuid PRIMARY KEY, - username text NOT NULL UNIQUE, - - password_hash bytea, - password_salt bytea -); - -CREATE TABLE authn.user_session -( - id varchar(256) PRIMARY KEY, - user_id uuid NOT NULL REFERENCES authn.user_identity (id) ON DELETE CASCADE, - expires timestamptz NOT NULL -); diff --git a/Femto.Modules.Auth.Contracts/Events/UserWasCreatedIntegrationEvent.cs b/Femto.Modules.Auth.Contracts/Events/UserWasCreatedIntegrationEvent.cs new file mode 100644 index 0000000..b73020a --- /dev/null +++ b/Femto.Modules.Auth.Contracts/Events/UserWasCreatedIntegrationEvent.cs @@ -0,0 +1,5 @@ +using Femto.Common.Integration; + +namespace Femto.Modules.Auth.Contracts.Events; + +public record UserWasCreatedIntegrationEvent(Guid UserId, string Username) : Event; \ No newline at end of file diff --git a/Femto.Modules.Auth.Contracts/Femto.Modules.Auth.Contracts.csproj b/Femto.Modules.Auth.Contracts/Femto.Modules.Auth.Contracts.csproj new file mode 100644 index 0000000..861f5b5 --- /dev/null +++ b/Femto.Modules.Auth.Contracts/Femto.Modules.Auth.Contracts.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index 90bf459..feede26 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -1,34 +1,44 @@ using Femto.Common.Infrastructure; +using Femto.Common.Infrastructure.Outbox; +using Femto.Common.Integration; using Femto.Modules.Auth.Data; +using Femto.Modules.Auth.Infrastructure; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Quartz; namespace Femto.Modules.Auth.Application; public static class AuthStartup { - public static void InitializeAuthenticationModule( - this IServiceCollection rootContainer, - string connectionString - ) + public static void InitializeAuthenticationModule(this IServiceCollection rootContainer, + string connectionString, IEventBus eventBus) { var hostBuilder = Host.CreateDefaultBuilder(); - hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString)); + hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString, eventBus)); var host = hostBuilder.Build(); rootContainer.AddScoped(_ => new AuthModule(host)); rootContainer.AddHostedService(services => new AuthApplication(host)); + eventBus.Subscribe((evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)); } - private static void ConfigureServices(IServiceCollection services, string connectionString) + private static void ConfigureServices(IServiceCollection services, string connectionString, IEventPublisher publisher) { services.AddDbContext(builder => { builder.UseNpgsql(connectionString); builder.UseSnakeCaseNamingConvention(); }); + + services.AddQuartzHostedService(options => + { + options.WaitForJobsToComplete = true; + }); + + services.AddOutbox(); services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly)); @@ -41,9 +51,35 @@ public static class AuthStartup services.ConfigureDomainServices(); - services.AddMediatR(c => + services.AddSingleton(publisher); + } + + private static async Task EventSubscriber( + IEvent evt, + IServiceProvider provider, + CancellationToken cancellationToken + ) + { + using var scope = provider.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var publisher = scope.ServiceProvider.GetRequiredService(); + + IEventHandler? handler = evt switch { - c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly); - }); + _ => null, + }; + + if (handler is null) + return; + + await handler.Handle(evt, cancellationToken); + + if (context.ChangeTracker.HasChanges()) + { + await context.EmitDomainEvents(logger, publisher, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } } } diff --git a/Femto.Modules.Auth/Contracts/AuthenticationService.cs b/Femto.Modules.Auth/Contracts/AuthenticationService.cs deleted file mode 100644 index 81f48d6..0000000 --- a/Femto.Modules.Auth/Contracts/AuthenticationService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Femto.Modules.Auth.Data; -using Femto.Modules.Auth.Models; - -namespace Femto.Modules.Auth.Contracts; - -internal class AuthenticationService(AuthContext context) : IAuthenticationService -{ - public async Task Register(string username, string password) - { - var user = new UserIdentity(username).WithPassword(password); - await context.AddAsync(user); - await context.SaveChangesAsync(); - - return new(user.Id, user.Username); - } - - public async Task Authenticate(string username, string password) - { - throw new NotImplementedException(); - } -} - -public class AuthenticationError(string message, Exception inner) : Exception(message, inner); diff --git a/Femto.Modules.Auth/Contracts/IAuthenticationService.cs b/Femto.Modules.Auth/Contracts/IAuthenticationService.cs deleted file mode 100644 index 19aea57..0000000 --- a/Femto.Modules.Auth/Contracts/IAuthenticationService.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Femto.Modules.Auth.Contracts; - -public interface IAuthenticationService -{ - public Task Register(string username, string password); - public Task Authenticate(string username, string password); -} \ No newline at end of file diff --git a/Femto.Modules.Auth/Contracts/UserInfo.cs b/Femto.Modules.Auth/Contracts/UserInfo.cs deleted file mode 100644 index dee7448..0000000 --- a/Femto.Modules.Auth/Contracts/UserInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Modules.Auth.Contracts; - -public record UserInfo(Guid UserId, string Username); \ No newline at end of file diff --git a/Femto.Modules.Auth/Femto.Modules.Auth.csproj b/Femto.Modules.Auth/Femto.Modules.Auth.csproj index bd6d1eb..3bb2c77 100644 --- a/Femto.Modules.Auth/Femto.Modules.Auth.csproj +++ b/Femto.Modules.Auth/Femto.Modules.Auth.csproj @@ -14,14 +14,12 @@ + - - - - + diff --git a/Femto.Modules.Auth/Infrastructure/OutboxMessageHandler.cs b/Femto.Modules.Auth/Infrastructure/OutboxMessageHandler.cs new file mode 100644 index 0000000..bbddf03 --- /dev/null +++ b/Femto.Modules.Auth/Infrastructure/OutboxMessageHandler.cs @@ -0,0 +1,22 @@ +using Femto.Common.Infrastructure.Outbox; +using Femto.Common.Integration; +using Microsoft.Extensions.Logging; + +namespace Femto.Modules.Auth.Infrastructure; + +public class OutboxMessageHandler(IEventPublisher publisher, ILogger logger) : IOutboxMessageHandler +{ + public async Task HandleMessage( + TNotification notification, + CancellationToken executionContextCancellationToken + ) + { + if (notification is IEvent evt) + { + await publisher.Publish(evt); + } else + { + logger.LogWarning("ignoring non IEvent {Type} in outbox message handler", typeof(TNotification)); + } + } +} diff --git a/Femto.Modules.Auth/Models/DomainEventHandlers/UserWasCreatedHandler.cs b/Femto.Modules.Auth/Models/DomainEventHandlers/UserWasCreatedHandler.cs new file mode 100644 index 0000000..2a020cc --- /dev/null +++ b/Femto.Modules.Auth/Models/DomainEventHandlers/UserWasCreatedHandler.cs @@ -0,0 +1,20 @@ +using Femto.Common.Infrastructure.Outbox; +using Femto.Modules.Auth.Contracts.Events; +using Femto.Modules.Auth.Data; +using Femto.Modules.Auth.Models.Events; +using MediatR; + +namespace Femto.Modules.Auth.Models.DomainEventHandlers; + +internal class UserWasCreatedHandler(Outbox outbox) + : INotificationHandler +{ + public async Task Handle(UserWasCreatedEvent notification, CancellationToken cancellationToken) + { + await outbox.AddMessage( + notification.User.Id, + new UserWasCreatedIntegrationEvent(notification.User.Id, notification.User.Username), + cancellationToken + ); + } +} diff --git a/Femto.Modules.Auth/Models/UserSession.cs b/Femto.Modules.Auth/Models/UserSession.cs index b74a365..7deb251 100644 --- a/Femto.Modules.Auth/Models/UserSession.cs +++ b/Femto.Modules.Auth/Models/UserSession.cs @@ -1,12 +1,11 @@ namespace Femto.Modules.Auth.Models; -public class UserSession +internal class UserSession { private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30); private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5); public string Id { get; private set; } public DateTimeOffset Expires { get; private set; } - public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer; private UserSession() {} diff --git a/Femto.Modules.Blog/Application/BlogContext.cs b/Femto.Modules.Blog/Application/BlogContext.cs index b90f59a..8b930d3 100644 --- a/Femto.Modules.Blog/Application/BlogContext.cs +++ b/Femto.Modules.Blog/Application/BlogContext.cs @@ -1,4 +1,5 @@ using Femto.Common.Infrastructure.Outbox; +using Femto.Modules.Blog.Domain.Authors; using Femto.Modules.Blog.Domain.Posts; using Microsoft.EntityFrameworkCore; @@ -7,6 +8,7 @@ namespace Femto.Modules.Blog.Application; internal class BlogContext(DbContextOptions options) : DbContext(options), IOutboxContext { public virtual DbSet Posts { get; set; } + public virtual DbSet Authors { get; set; } public virtual DbSet Outbox { get; set; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/Femto.Modules.Blog/Application/BlogStartup.cs b/Femto.Modules.Blog/Application/BlogStartup.cs index fe30c48..98bf8b3 100644 --- a/Femto.Modules.Blog/Application/BlogStartup.cs +++ b/Femto.Modules.Blog/Application/BlogStartup.cs @@ -1,6 +1,11 @@ -using Femto.Common.Infrastructure; +using System.Runtime.CompilerServices; +using Femto.Common.Infrastructure; using Femto.Common.Infrastructure.DbConnection; using Femto.Common.Infrastructure.Outbox; +using Femto.Common.Integration; +using Femto.Modules.Auth.Contracts.Events; +using Femto.Modules.Blog.Handlers; +using Femto.Modules.Blog.Infrastructure; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -14,21 +19,32 @@ public static class BlogStartup { public static void InitializeBlogModule( this IServiceCollection rootContainer, - string connectionString + string connectionString, + IEventBus bus ) { var hostBuilder = Host.CreateDefaultBuilder(); - hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString)); + hostBuilder.ConfigureServices(services => + ConfigureServices(services, connectionString, bus) + ); var host = hostBuilder.Build(); rootContainer.AddHostedService(services => new BlogApplication(host)); rootContainer.AddScoped(_ => new BlogModule(host)); + + bus.Subscribe( + (evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken) + ); } - private static void ConfigureServices(this IServiceCollection services, string connectionString) + private static void ConfigureServices( + this IServiceCollection services, + string connectionString, + IEventPublisher publisher + ) { services.AddTransient(_ => new DbConnectionFactory(connectionString)); @@ -46,6 +62,8 @@ public static class BlogStartup builder.UseLoggerFactory(loggerFactory); builder.EnableSensitiveDataLogging(); }); + + services.AddOutbox(); services.AddMediatR(c => { @@ -53,5 +71,42 @@ public static class BlogStartup }); services.ConfigureDomainServices(); + + services.AddSingleton(publisher); + } + + private static async Task EventSubscriber( + IEvent evt, + IServiceProvider provider, + CancellationToken cancellationToken + ) + { + using var scope = provider.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + var loggerFactory = scope.ServiceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var publisher = scope.ServiceProvider.GetRequiredService(); + + // todo inject these + IEventHandler? handler = evt switch + { + UserWasCreatedIntegrationEvent => new UserCreatedEventHandler( + context, + loggerFactory.CreateLogger() + ), + _ => null, + }; + + if (handler is null) + return; + + await handler.Handle(evt, cancellationToken); + + if (context.ChangeTracker.HasChanges()) + { + await context.EmitDomainEvents(logger, publisher, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } } } diff --git a/Femto.Modules.Blog/Application/Configurations/AuthorConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/AuthorConfiguration.cs new file mode 100644 index 0000000..9a7756c --- /dev/null +++ b/Femto.Modules.Blog/Application/Configurations/AuthorConfiguration.cs @@ -0,0 +1,14 @@ +using Femto.Modules.Blog.Domain.Authors; +using Femto.Modules.Blog.Domain.Posts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Femto.Modules.Blog.Application.Configurations; + +internal class AuthorConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder table) + { + table.ToTable("author"); + } +} diff --git a/Femto.Modules.Blog/Application/Configurations/OutboxEntryConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/OutboxEntryConfiguration.cs index 854903e..0fe66e5 100644 --- a/Femto.Modules.Blog/Application/Configurations/OutboxEntryConfiguration.cs +++ b/Femto.Modules.Blog/Application/Configurations/OutboxEntryConfiguration.cs @@ -9,7 +9,5 @@ internal class OutboxEntryConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.ToTable("outbox"); - - builder.Property(x => x.Payload).HasColumnType("jsonb"); } } diff --git a/Femto.Modules.Blog/Domain/Authors/Author.cs b/Femto.Modules.Blog/Domain/Authors/Author.cs new file mode 100644 index 0000000..4595144 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Authors/Author.cs @@ -0,0 +1,17 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Blog.Domain.Authors; + +public class Author : Entity +{ + public Guid Id { get; private set; } + public string Username { get; private set; } = null!; + + private Author() { } + + public Author(Guid userId, string username) + { + this.Id = userId; + this.Username = username; + } +} diff --git a/Femto.Modules.Blog/Events/PostCreatedIntegrationEvent.cs b/Femto.Modules.Blog/Events/PostCreatedEvent.cs similarity index 51% rename from Femto.Modules.Blog/Events/PostCreatedIntegrationEvent.cs rename to Femto.Modules.Blog/Events/PostCreatedEvent.cs index ef339bb..36c355d 100644 --- a/Femto.Modules.Blog/Events/PostCreatedIntegrationEvent.cs +++ b/Femto.Modules.Blog/Events/PostCreatedEvent.cs @@ -4,5 +4,5 @@ using Femto.Common.Integration; namespace Femto.Modules.Blog.Events; [EventType("post.created")] -public record PostCreatedIntegrationEvent(Guid EventId, Guid PostId, IEnumerable MediaIds) - : IIntegrationEvent; +public record PostCreatedEvent(Guid EventId, Guid PostId, IEnumerable MediaIds) + : IEvent; diff --git a/Femto.Modules.Blog/Femto.Modules.Blog.csproj b/Femto.Modules.Blog/Femto.Modules.Blog.csproj index 7944afa..08c6b99 100644 --- a/Femto.Modules.Blog/Femto.Modules.Blog.csproj +++ b/Femto.Modules.Blog/Femto.Modules.Blog.csproj @@ -38,12 +38,9 @@ - - - - + diff --git a/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs b/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs index c9c0be0..a6a221f 100644 --- a/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs +++ b/Femto.Modules.Blog/Handlers/PostCreatedIntegrationEventHandler.cs @@ -3,9 +3,9 @@ using MediatR; namespace Femto.Modules.Blog.Handlers; -public class PostCreatedIntegrationEventHandler : INotificationHandler +public class PostCreatedIntegrationEventHandler : INotificationHandler { - public async Task Handle(PostCreatedIntegrationEvent notification, CancellationToken cancellationToken) + public async Task Handle(PostCreatedEvent notification, CancellationToken cancellationToken) { // todo } diff --git a/Femto.Modules.Blog/Handlers/UserCreatedEventHandler.cs b/Femto.Modules.Blog/Handlers/UserCreatedEventHandler.cs new file mode 100644 index 0000000..9d72e1c --- /dev/null +++ b/Femto.Modules.Blog/Handlers/UserCreatedEventHandler.cs @@ -0,0 +1,29 @@ +using Femto.Modules.Auth.Contracts.Events; +using Femto.Modules.Blog.Application; +using Femto.Modules.Blog.Domain.Authors; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Femto.Modules.Blog.Handlers; + +internal class UserCreatedEventHandler(BlogContext context, ILogger logger) : Common.Integration.EventHandler +{ + protected override async Task Handle(UserWasCreatedIntegrationEvent evt, CancellationToken cancellationToken) + { + if (await context.Authors.AnyAsync(x => x.Username == evt.Username, cancellationToken)) + { + logger.LogError("can't create author: author with username {Username} already exists", evt.Username); + return; + } + + if (await context.Authors.AnyAsync(x => x.Id == evt.UserId, cancellationToken)) + { + logger.LogError("can't create author: author with id {UserId} already exists", evt.UserId); + return; + } + + var author = new Author(evt.UserId, evt.Username); + + await context.Authors.AddAsync(author, cancellationToken); + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Infrastructure/OutboxMessageHandler.cs b/Femto.Modules.Blog/Infrastructure/OutboxMessageHandler.cs new file mode 100644 index 0000000..c3beb4e --- /dev/null +++ b/Femto.Modules.Blog/Infrastructure/OutboxMessageHandler.cs @@ -0,0 +1,22 @@ +using Femto.Common.Infrastructure.Outbox; +using Femto.Common.Integration; +using Microsoft.Extensions.Logging; + +namespace Femto.Modules.Blog.Infrastructure; + +public class OutboxMessageHandler(IEventPublisher publisher, ILogger logger) : IOutboxMessageHandler +{ + public async Task HandleMessage( + TNotification notification, + CancellationToken executionContextCancellationToken + ) + { + if (notification is IEvent evt) + { + await publisher.Publish(evt); + } else + { + logger.LogWarning("ignoring non IEvent {Type} in outbox message handler", typeof(TNotification)); + } + } +} diff --git a/FemtoBackend.sln b/FemtoBackend.sln index 52f0715..8916568 100644 --- a/FemtoBackend.sln +++ b/FemtoBackend.sln @@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Media", "Femt EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Auth", "Femto.Modules.Auth\Femto.Modules.Auth.csproj", "{7E138EF6-E075-4896-93C0-923024F0CA78}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Auth.Contracts", "Femto.Modules.Auth.Contracts\Femto.Modules.Auth.Contracts.csproj", "{1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,6 +50,10 @@ Global {7E138EF6-E075-4896-93C0-923024F0CA78}.Debug|Any CPU.Build.0 = Debug|Any CPU {7E138EF6-E075-4896-93C0-923024F0CA78}.Release|Any CPU.ActiveCfg = Release|Any CPU {7E138EF6-E075-4896-93C0-923024F0CA78}.Release|Any CPU.Build.0 = Release|Any CPU + {1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AC1DA1D-54B0-44FC-9FDF-9C2E68BB8ABB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution EndGlobalSection