This commit is contained in:
john 2025-05-03 15:38:57 +02:00
commit ab2e20f7e1
72 changed files with 2000 additions and 0 deletions

43
.gitignore vendored Normal file
View file

@ -0,0 +1,43 @@
bin/
obj/
out/
*.dll
*.exe
*.app
*.user
*.userosscache
*.suo
*.sln.docstates
.idea/
*.sln.iml
.idea/.idea.DotSettings
.DS_Store
Thumbs.db
project.lock.json
project.fragment.lock.json
artifacts/
*.log
logs/
*.nupkg
.nuget/
packages/
*.snupkg
*~
*.bak
tmp/
temp/
.vscode/
secrets.json
appsettings.*.json
!appsettings.json

View file

@ -0,0 +1,28 @@
using Femto.Api.Controllers.Authors.Dto;
using Femto.Modules.Blog.Domain.Posts.Commands.GetAuthorPosts;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace Femto.Api.Controllers.Authors;
[ApiController]
[Route("authors")]
public class AuthorsController(IMediator mediator) : ControllerBase
{
[HttpGet("{authorId}/posts")]
public async Task<ActionResult<GetAuthorPostsResponse>> GetAuthorPosts(
Guid authorId,
[FromQuery] GetAuthorPostsSearchParams searchParams,
CancellationToken cancellationToken
)
{
var posts = await mediator.Send(
new GetAuthorPostsQuery(authorId, searchParams.Cursor, searchParams.Count),
cancellationToken
);
return new GetAuthorPostsResponse(
posts.Select(p => new AuthorPostDto(p.PostId, p.Text, p.Media.Select(m => m.Url)))
);
}
}

View file

@ -0,0 +1,9 @@
using JetBrains.Annotations;
namespace Femto.Api.Controllers.Authors.Dto;
[PublicAPI]
public record GetAuthorPostsResponse(IEnumerable<AuthorPostDto> Posts);
[PublicAPI]
public record AuthorPostDto(Guid PostId, string Content, IEnumerable<Uri> Media);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Authors;
public record GetAuthorPostsSearchParams(Guid? Cursor, int? Count);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record CreatePostRequest(Guid AuthorId, string Content, IEnumerable<Uri> Media);

View file

@ -0,0 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record CreatePostResponse(Guid PostId);

View file

@ -0,0 +1,6 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record GetPostResponse
{
}

View file

@ -0,0 +1,25 @@
using Femto.Api.Controllers.Posts.Dto;
using Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace Femto.Api.Controllers.Posts;
[ApiController]
[Route("posts")]
public class PostsController(IMediator mediator) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<CreatePostResponse>> Post(
[FromBody] CreatePostRequest req,
CancellationToken cancellationToken
)
{
var guid = await mediator.Send(
new CreatePostCommand(req.AuthorId, req.Content, req.Media),
cancellationToken
);
return new CreatePostResponse(guid);
}
}

View file

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<LangVersion>13</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Femto.Modules.Blog\Femto.Modules.Blog.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Data.SqlClient">
<HintPath>..\..\..\..\.nuget\packages\microsoft.data.sqlclient\6.0.1\ref\net9.0\Microsoft.Data.SqlClient.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

39
Femto.Api/Program.cs Normal file
View file

@ -0,0 +1,39 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Femto.Modules.Blog;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var databaseConnectionString = builder.Configuration.GetConnectionString("Database");
if (databaseConnectionString is null)
throw new Exception("no database connection string found");
builder.Services.UseBlogModule(databaseConnectionString);
builder.Services.AddControllers();
builder
.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapControllers();
app.UseHttpsRedirection();
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5181",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7269;http://localhost:5181",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,7 @@
namespace Femto.Common.Attributes;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class EventTypeAttribute(string name) : Attribute
{
public string Name { get; } = name;
}

View file

@ -0,0 +1,3 @@
namespace Femto.Common.Domain;
public class DomainException(string message) : Exception(message);

View file

@ -0,0 +1,21 @@
namespace Femto.Common.Domain;
public abstract class Entity
{
private readonly ICollection<IDomainEvent> _domainEvents = [];
protected void AddDomainEvent(IDomainEvent evt)
{
this._domainEvents.Add(evt);
}
public IList<IDomainEvent> DomainEvents => this._domainEvents.ToList();
public void ClearDomainEvents() => this._domainEvents.Clear();
protected void CheckRule(IRule rule)
{
if (!rule.Check())
throw new RuleBrokenException(rule.Message);
}
}

View file

@ -0,0 +1,13 @@
using MediatR;
namespace Femto.Common.Domain;
public interface IDomainEvent : INotification
{
public Guid EventId { get; }
}
public abstract record DomainEvent : IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
}

View file

@ -0,0 +1,8 @@
namespace Femto.Common.Domain;
public interface IRule
{
bool Check();
string Message { get; }
}

View file

@ -0,0 +1,3 @@
namespace Femto.Common.Domain;
public class RuleBrokenException(string message) : DomainException(message);

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Infrastructure\" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,8 @@
using MediatR;
namespace Femto.Common.Integration;
public interface IIntegrationEvent : INotification
{
public Guid EventId { get; }
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,44 @@
-- 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,
created_on timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
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,
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 CURRENT_TIMESTAMP 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
)

122
Femto.Database/Migrator.cs Normal file
View file

@ -0,0 +1,122 @@
using Npgsql;
namespace Femto.Database;
internal class Migrator(string migrationDirectory, NpgsqlDataSource dataSource) : IAsyncDisposable
{
private record MigrationScript(string Id, string Script);
public async Task Migrate()
{
var migrations = await LoadMigrationScripts();
await this.EnsureMigrationsTableExists();
var executedMigrations = await GetExecutedMigrations();
EnsureConsistentMigrationHistory(migrations, executedMigrations);
if (executedMigrations.Count == migrations.Count)
{
Console.WriteLine("up to date");
return;
}
await DoMigrations(migrations.Skip(executedMigrations.Count));
}
private async Task<IList<MigrationScript>> LoadMigrationScripts()
{
if (!Directory.Exists(migrationDirectory))
throw new DirectoryNotFoundException($"Migration directory not found: {migrationDirectory}");
var scriptFiles = Directory.EnumerateFiles(migrationDirectory, "*.sql")
.OrderBy(Path.GetFileName)
.ToList();
if (!scriptFiles.Any())
throw new Exception("No migration scripts found in the specified directory.");
var migrationScripts = new List<MigrationScript>();
foreach (var file in scriptFiles)
{
var scriptContent = await File.ReadAllTextAsync(file);
var scriptId = Path.GetFileName(file);
migrationScripts.Add(new MigrationScript(scriptId, scriptContent));
}
return migrationScripts;
}
private async Task EnsureMigrationsTableExists()
{
await using var createSchemaCommand = dataSource.CreateCommand(
"""CREATE SCHEMA IF NOT EXISTS __migrations;"""
);
await createSchemaCommand.ExecuteNonQueryAsync();
await using var createTableCommand = dataSource.CreateCommand(
"""
CREATE TABLE IF NOT EXISTS __migrations.__migration_history ( migration VARCHAR(127) PRIMARY KEY );
"""
);
await createTableCommand.ExecuteNonQueryAsync();
}
private async Task<IList<string>> GetExecutedMigrations()
{
await using var command = dataSource.CreateCommand(
"""SELECT migration FROM __migrations.__migration_history"""
);
var reader = await command.ExecuteReaderAsync();
var migrations = new List<string>();
while (await reader.ReadAsync())
{
migrations.Add(reader.GetString(0));
}
return migrations;
}
private void EnsureConsistentMigrationHistory(
IList<MigrationScript> migrationScripts,
IList<string> executedMigrationIds
)
{
if (executedMigrationIds.Count > migrationScripts.Count)
throw new Exception("inconsistent migration history");
for (var i = 0; i < executedMigrationIds.Count; i++)
{
var migration = migrationScripts[i];
var executedMigrationId = executedMigrationIds[i];
if (migration.Id != executedMigrationId)
throw new Exception($"unexpected migration in history {executedMigrationId}");
}
}
private async Task DoMigrations(IEnumerable<MigrationScript> migrationScripts)
{
foreach (var migration in migrationScripts)
{
Console.WriteLine($"applying migration {migration.Id}");
await using var command = dataSource.CreateCommand(migration.Script);
await command.ExecuteNonQueryAsync();
await using var addToHistoryCommand = dataSource.CreateCommand(
"""INSERT INTO __migrations.__migration_history (migration) VALUES (@migration)"""
);
addToHistoryCommand.Parameters.AddWithValue("@migration", migration.Id);
await addToHistoryCommand.ExecuteNonQueryAsync();
}
}
public ValueTask DisposeAsync()
{
return dataSource.DisposeAsync();
}
}

154
Femto.Database/Program.cs Normal file
View file

@ -0,0 +1,154 @@
using System.CommandLine;
using Femto.Database;
using Femto.Database.Seed;
using Npgsql;
var nameArg = new Argument<string>("name", "the name of the migration");
var migrationsDirectoryOption = new Option<string>(
["--migrations-directory"],
() => "./Migrations",
"the directory where the migrations are stored"
);
var newCommand = new Command("new", "creates a new migrations")
{
nameArg,
migrationsDirectoryOption,
};
newCommand.SetHandler(MakeNewMigration, nameArg, migrationsDirectoryOption);
var connectionStringArg = new Argument<string>(
"--connection-string",
"the connection string to the database"
);
var upCommand = new Command("up", "update the database to the most current migration")
{
migrationsDirectoryOption,
connectionStringArg,
};
upCommand.SetHandler(MigrateUp, migrationsDirectoryOption, connectionStringArg);
var seedCommand = new Command("seed", "seed the database with test data") { connectionStringArg };
seedCommand.SetHandler(Seed, connectionStringArg);
// Add these near the top with other command definitions
var yesOption = new Option<bool>(
["-y", "--yes"],
"Skip confirmation prompt"
);
var resetCommand = new Command("reset", "drops the existing database, runs migrations, and seeds test data")
{
connectionStringArg,
migrationsDirectoryOption,
yesOption
};
resetCommand.SetHandler(CreateTestDatabase, connectionStringArg, migrationsDirectoryOption, yesOption);
var rootCommand = new RootCommand("migrator") { newCommand, upCommand, seedCommand, resetCommand };
return await rootCommand.InvokeAsync(args);
static async Task MakeNewMigration(string name, string migrationsDirectory)
{
Directory.CreateDirectory(migrationsDirectory);
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
var fileName = $"{timestamp}_{name}.sql";
var filePath = Path.Combine(migrationsDirectory, fileName);
// Write an initial comment in the file
File.WriteAllText(filePath, $"-- Migration: {name}\n-- Created at: {DateTime.UtcNow}");
// Notify the user
Console.WriteLine($"Migration created successfully: {filePath}");
}
static async Task MigrateUp(string migrationsDirectory, string connectionString)
{
if (!Directory.Exists(migrationsDirectory))
{
Console.WriteLine("Migrations directory does not exist.");
return;
}
await using var dataSource = NpgsqlDataSource.Create(connectionString);
await using var migrator = new Migrator(migrationsDirectory, dataSource);
await migrator.Migrate();
}
static async Task Seed(string connectionString)
{
var dataSource = NpgsqlDataSource.Create(connectionString);
await TestDataSeeder.Seed(dataSource);
}
static async Task CreateTestDatabase(string connectionString, string migrationsDirectory, bool skipConfirmation)
{
var builder = new NpgsqlConnectionStringBuilder(connectionString);
if (!skipConfirmation)
{
builder.Database = "postgres";
try
{
await using var dataSource = NpgsqlDataSource.Create(builder.ConnectionString);
await using var conn = await dataSource.OpenConnectionAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pg_database WHERE datname = '{builder.Database}'";
var exists = await cmd.ExecuteScalarAsync();
if (exists is true)
{
Console.WriteLine("WARNING: This will drop the existing database and recreate it.");
Console.Write("Are you sure you want to continue? (y/N): ");
var response = Console.ReadLine()?.ToLower();
if (response != "y")
{
Console.WriteLine("Operation cancelled.");
return;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error checking database existence: {ex.Message}");
throw;
}
}
var databaseName = builder.Database;
builder.Database = "postgres"; // Connect to default database to drop the target
try
{
// Connect to postgres database to drop/create the target database
await using var dataSource = NpgsqlDataSource.Create(builder.ConnectionString);
await using var conn = await dataSource.OpenConnectionAsync();
await using var cmd = conn.CreateCommand();
// Drop database if it exists
cmd.CommandText = $"DROP DATABASE IF EXISTS {databaseName} WITH (FORCE)";
await cmd.ExecuteNonQueryAsync();
// Create fresh database
cmd.CommandText = $"CREATE DATABASE {databaseName}";
await cmd.ExecuteNonQueryAsync();
// Now run migrations and seed with the original connection string
Console.WriteLine("Running migrations...");
await MigrateUp(migrationsDirectory, connectionString);
Console.WriteLine("Seeding database...");
await Seed(connectionString);
Console.WriteLine("Database reset completed successfully.");
}
catch (Exception ex)
{
Console.WriteLine($"Error resetting database: {ex.Message}");
throw;
}
}

View file

@ -0,0 +1,41 @@
using Npgsql;
namespace Femto.Database.Seed;
public static class TestDataSeeder
{
public static async Task Seed(NpgsqlDataSource dataSource)
{
await using var addToHistoryCommand = dataSource.CreateCommand(
"""
INSERT INTO blog.author
(id, username)
VALUES
('0196960c-6296-7532-ba66-8fabb38c6ae0', 'johnbotris')
;
INSERT INTO blog.post
(id, author_id, content)
VALUES
('019691a0-48ed-7eba-b8d3-608e25e07d4b', '0196960c-6296-7532-ba66-8fabb38c6ae0', 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', '0196960c-6296-7532-ba66-8fabb38c6ae0', 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', '0196960c-6296-7532-ba66-8fabb38c6ae0','Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'),
('019691a0-4dd3-7e89-909e-94a6fd19a05e', '0196960c-6296-7532-ba66-8fabb38c6ae0','Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.')
;
INSERT INTO blog.post_media
(id, post_id, url, ordering)
VALUES
('019691a2-c1b0-705e-8865-b5053bed9671', '019691a0-48ed-7eba-b8d3-608e25e07d4b', 'https://wallpaperaccess.com/full/1401569.jpg', 0),
('019691b5-bbfa-7481-ad74-25a6fea8db60', '019691a0-48ed-7eba-b8d3-608e25e07d4b', 'https://i.redd.it/4g8k43py9pi81.png', 1),
('019691b5-d813-7f46-87a5-8ee4e987622c', '019691a0-4ace-7bb5-a8f3-e3362920eba0', 'https://wallpapercave.com/wp/wp5305675.jpg', 0),
('019691b5-f345-7d86-9eba-2ca6780d8358', '019691a0-4c3e-726f-b8f6-bcbaabe789ae', 'https://wallpapers.com/images/hd/big-chungus-kpb89wgv5ov3znql.jpg', 0),
('019691b6-07cb-7353-8c33-68456188f462', '019691a0-4c3e-726f-b8f6-bcbaabe789ae', 'https://wallpapers.com/images/hd/big-chungus-2bxloyitgw7q1hfg.jpg', 1),
('019691b6-2608-7088-8110-f0f6e35fa633', '019691a0-4dd3-7e89-909e-94a6fd19a05e', 'https://www.pinclipart.com/picdir/big/535-5356059_big-transparent-chungus-png-background-big-chungus-clipart.png', 0)
;
"""
);
await addToHistoryCommand.ExecuteNonQueryAsync();
}
}

View file

@ -0,0 +1,98 @@
~~**Design Specification: Post Creation with Media Claiming in a Modular Monolith (DDD + MediatR)**
---
## 1. Overview
This document describes the design of a feature that allows users to upload media files and later attach them to a Post within a modular monolith application. The system is built using Domain-Driven Design (DDD) principles and uses MediatR for in-process communication between modules.
---
## 2. Modules Involved
### a. Microblogging Module
- Responsible for Post aggregate lifecycle.
- Attaches media to posts based on Media IDs.
### b. MediaManagement Module
- Handles file uploads and MediaUpload entity lifecycle.
- Validates and claims media for Posts.
---
## 3. Key Entities
### Post (Aggregate Root)
- Properties: Id, AuthorId, Content, List<MediaItem>
- Methods: AttachMedia(MediaItem), Create(), etc.
### MediaItem (Owned Entity)
- Properties: MediaId, Uri, Type, AltText, Order
### MediaUpload (Entity in MediaManagement)
- Properties: Id, UploaderId, Uri, MimeType, Status (Unassigned, Attached), PostId (nullable)
---
## 4. Use Case Flow
### Step 1: Upload Media
**Command**: `UploadMediaCommand`
**Handler**: `UploadMediaHandler` (MediaManagement)
- Validates file and stores it.
- Creates MediaUpload record with `Status = Unassigned`.
- Returns `MediaId` to client.
### Step 2: Create Post
**Command**: `CreatePostCommand`
**Handler**: `CreatePostHandler` (Microblogging)
- Creates new Post aggregate.
- For each referenced MediaId:
- Sends `ClaimMediaRequest` command via MediatR.
### Step 3: Claim Media
**Command**: `ClaimMediaRequest`
**Handler**: `ClaimMediaHandler` (MediaManagement)
- Validates ownership and existence.
- Updates `Status = Attached`, sets `PostId`.
- Publishes `MediaClaimedEvent`.
### Step 4: Attach Media to Post
**Event**: `MediaClaimedEvent`
**Handler**: `MediaClaimedHandler` (Microblogging)
- Loads corresponding Post.
- Calls `post.AttachMedia()`.
- Saves updated Post.
---
## 5. Event & Command Definitions
### UploadMediaCommand
- FileStream, FileName, MimeType, UploaderId
### CreatePostCommand
- AuthorId, Content, List<MediaId>
### ClaimMediaRequest
- MediaId, AuthorId, PostId
### MediaClaimedEvent
- MediaId, PostId, Uri, Type, AltText
---
## 6. Rules and Invariants
- Only the uploader can claim media.
- Media can only be claimed once.
- Posts only accept claimed media.
---
## 7. UML Diagrams
UML Diagrams for the described system are provided below.~~

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

3
Femto.Docs/Program.cs Normal file
View file

@ -0,0 +1,3 @@
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");

View file

@ -0,0 +1,5 @@
namespace Femto.Modules.Blog.Contracts.Dto;
public record GetAuthorPostsDto(Guid PostId, string Text, IList<GetAuthorPostsMediaDto> Media);
public record GetAuthorPostsMediaDto(Uri Url);

View file

@ -0,0 +1,8 @@
using Femto.Common.Attributes;
using Femto.Common.Integration;
namespace Femto.Modules.Blog.Contracts.Events;
[EventType("post.created")]
public record PostCreatedIntegrationEvent(Guid EventId, Guid PostId, IEnumerable<Guid> MediaIds)
: IIntegrationEvent;

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Folder Include="Queries\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,34 @@
using System.Collections.Immutable;
using System.Reflection;
using Femto.Common.Attributes;
using MediatR;
namespace Femto.Modules.Blog.Contracts;
public static class Module
{
public static IDictionary<string, Type> GetIntegrationEventTypes()
{
var mapping = new Dictionary<string, Type>();
var types = typeof(Module).Assembly.GetTypes();
foreach (var type in types)
{
if (!typeof(INotification).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface)
continue;
var attribute = type.GetCustomAttribute<EventTypeAttribute>();
if (attribute == null)
continue;
var eventName = attribute.Name;
if (!string.IsNullOrWhiteSpace(eventName))
{
mapping.TryAdd(eventName, type);
}
}
return mapping;
}
}

View file

@ -0,0 +1,5 @@
namespace Femto.Modules.Blog.Data;
public class Class1
{
}

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
<ProjectReference Include="..\Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.EntityFrameworkCore">
<HintPath>..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\9.0.4\lib\net8.0\Microsoft.EntityFrameworkCore.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,18 @@
using Femto.Modules.Blog.Domain.Posts;
using Femto.Modules.Blog.Infrastructure.Integration;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Data;
internal class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options)
{
public virtual DbSet<Post> Posts { get; set; }
public virtual DbSet<OutboxEntry> Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.HasDefaultSchema("blog");
builder.ApplyConfigurationsFromAssembly(typeof(BlogContext).Assembly);
}
}

View file

@ -0,0 +1,16 @@
using Femto.Modules.Blog.Infrastructure.Integration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Femto.Modules.Blog.Data.Configurations;
internal class OutboxEntryConfiguration : IEntityTypeConfiguration<OutboxEntry>
{
public void Configure(EntityTypeBuilder<OutboxEntry> builder)
{
builder.ToTable("outbox");
builder.Property(x => x.Payload)
.HasColumnType("jsonb");
}
}

View file

@ -0,0 +1,14 @@
using Femto.Modules.Blog.Domain.Posts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Femto.Modules.Blog.Data.Configurations;
internal class PostConfiguration : IEntityTypeConfiguration<Post>
{
public void Configure(EntityTypeBuilder<Post> table)
{
table.ToTable("post");
table.OwnsMany(post => post.Media).WithOwner();
}
}

View file

@ -0,0 +1,7 @@
namespace Femto.Modules.Blog.Domain.Authors;
internal class Author
{
public string Id { get; private set; } = null!;
public string Name { get; private set; } = null!;
}

View file

@ -0,0 +1,6 @@
using MediatR;
namespace Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable<Uri> Media)
: IRequest<Guid>;

View file

@ -0,0 +1,21 @@
using Femto.Modules.Blog.Data;
using MediatR;
namespace Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
internal class CreatePostCommandHandler(BlogContext context)
: IRequestHandler<CreatePostCommand, Guid>
{
public async Task<Guid> Handle(CreatePostCommand request, CancellationToken cancellationToken)
{
var post = new Post(
request.AuthorId,
request.Content,
request.Media.Select((url, idx) => new PostMedia(Guid.CreateVersion7(), url, idx)).ToList()
);
await context.AddAsync(post, cancellationToken);
return post.Id;
}
}

View file

@ -0,0 +1,6 @@
using Femto.Modules.Blog.Contracts.Dto;
using MediatR;
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetAuthorPosts;
public record GetAuthorPostsQuery(Guid AuthorId, Guid? Cursor, int? Count) : IRequest<IList<GetAuthorPostsDto>>;

View file

@ -0,0 +1,61 @@
using Dapper;
using Femto.Modules.Blog.Contracts.Dto;
using Femto.Modules.Blog.Infrastructure.DbConnection;
using MediatR;
using Microsoft.Data.SqlClient;
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetAuthorPosts;
public class GetAuthorPostsQueryHandler(IDbConnectionFactory connectionFactory)
: IRequestHandler<GetAuthorPostsQuery, IList<GetAuthorPostsDto>>
{
public async Task<IList<GetAuthorPostsDto>> Handle(
GetAuthorPostsQuery query,
CancellationToken cancellationToken
)
{
using var conn = connectionFactory.GetConnection();
var sql = $$"""
with post_page as (
select * from blog.post
where blog.post.author_id = @authorId
and (@cursor is null or blog.post.id < @cursor)
order by blog.post.id desc
limit @count
)
select
p.id as PostId,
p.content as Content,
pm.url as MediaUrl
from post_page p
left join blog.post_media pm on pm.post_id = p.id
order by p.id desc
""";
var result = await conn.QueryAsync<QueryResult>(
sql,
new { authorId = query.AuthorId, cursor = query.Cursor, count = query.Count }
);
return result
.GroupBy(row => row.PostId)
.Select(group => new GetAuthorPostsDto(
group.Key,
group.First().Content,
group
.Select(row => row.MediaUrl)
.OfType<string>()
.Select(url => new GetAuthorPostsMediaDto(new Uri(url)))
.ToList()
))
.ToList();
}
internal class QueryResult
{
public Guid PostId { get; set; }
public string Content { get; set; }
public string? MediaUrl { get; set; }
}
}

View file

@ -0,0 +1,5 @@
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Domain.Posts.Events;
internal record PostCreated(Post Post) : DomainEvent;

View file

@ -0,0 +1,19 @@
using Femto.Modules.Blog.Contracts.Events;
using Femto.Modules.Blog.Domain.Posts.Events;
using Femto.Modules.Blog.Infrastructure.Integration;
using MediatR;
namespace Femto.Modules.Blog.Domain.Posts.Handlers;
internal class PostCreatedHandler(Outbox outbox) : INotificationHandler<PostCreated>
{
public async Task Handle(PostCreated notification, CancellationToken cancellationToken)
{
var post = notification.Post;
await outbox.AddMessage(
post.Id,
new PostCreatedIntegrationEvent(Guid.CreateVersion7(), post.Id, post.Media.Select(m => m.Id)),
cancellationToken
);
}
}

View file

@ -0,0 +1,25 @@
using Femto.Common.Domain;
using Femto.Modules.Blog.Domain.Posts.Events;
using Femto.Modules.Blog.Domain.Posts.Rules;
namespace Femto.Modules.Blog.Domain.Posts;
internal class Post : Entity
{
public Guid Id { get; private set; }
public Guid AuthorId { get; private set; }
public string Content { get; private set; } = null!;
public IList<PostMedia> Media { get; private set; }
private Post() { }
public Post(Guid authorId,string content, IList<PostMedia> media)
{
this.Id = Guid.CreateVersion7();
this.AuthorId = authorId;
this.Content = content;
this.Media = media;
this.AddDomainEvent(new PostCreated(this));
}
}

View file

@ -0,0 +1,17 @@
namespace Femto.Modules.Blog.Domain.Posts;
internal class PostMedia
{
public Guid Id { get; private set; }
public Uri Url { get; private set; }
public int Ordering { get; private set; }
private PostMedia() {}
public PostMedia(Guid id, Uri url, int ordering)
{
this.Id = id;
this.Url = url;
this.Ordering = ordering;
}
}

View file

@ -0,0 +1,9 @@
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Domain.Posts.Rules;
internal class PostMustHaveSomeMediaRule(int mediaCount) : IRule
{
public bool Check() => true || mediaCount > 0;
public string Message => "Post must contain some media";
}

View file

@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Femto.Modules.Blog</AssemblyName>
<RootNamespace>Femto.Modules.Blog</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="MediatR">
<HintPath>..\..\..\..\.nuget\packages\mediatr\12.5.0\lib\net6.0\MediatR.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Data.SqlClient">
<HintPath>..\..\..\..\.nuget\packages\microsoft.data.sqlclient\6.0.1\ref\net9.0\Microsoft.Data.SqlClient.dll</HintPath>
</Reference>
<Reference Include="Npgsql">
<HintPath>..\..\..\..\.nuget\packages\npgsql\9.0.3\lib\net8.0\Npgsql.dll</HintPath>
</Reference>
<Reference Include="Npgsql.EntityFrameworkCore.PostgreSQL">
<HintPath>..\..\..\..\.nuget\packages\npgsql.entityframeworkcore.postgresql\9.0.4\lib\net8.0\Npgsql.EntityFrameworkCore.PostgreSQL.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Folder Include="Domain\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
<ProjectReference Include="..\Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,12 @@
using Femto.Modules.Blog.Contracts.Events;
using MediatR;
namespace Femto.Modules.Blog.Handlers;
public class PostCreatedIntegrationEventHandler : INotificationHandler<PostCreatedIntegrationEvent>
{
public async Task Handle(PostCreatedIntegrationEvent notification, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,13 @@
using System.Data;
using Microsoft.Data.SqlClient;
using Npgsql;
namespace Femto.Modules.Blog.Infrastructure.DbConnection;
public class DbConnectionFactory(string connectionString) : IDbConnectionFactory
{
public IDbConnection GetConnection()
{
return new NpgsqlConnection(connectionString);
}
}

View file

@ -0,0 +1,9 @@
using System.Data;
using Microsoft.Data.SqlClient;
namespace Femto.Modules.Blog.Infrastructure.DbConnection;
public interface IDbConnectionFactory
{
IDbConnection GetConnection();
}

View file

@ -0,0 +1,79 @@
using System.Text.Json;
using Femto.Modules.Blog.Data;
using Femto.Modules.Blog.Infrastructure.Integration;
using MediatR;
using Microsoft.Extensions.Logging;
using Quartz;
namespace Femto.Modules.Blog;
[DisallowConcurrentExecution]
internal class MailmanJob(
Outbox outbox,
BlogContext context,
ILogger<MailmanJob> logger,
IMediator mediator
) : IJob
{
public async Task Execute(IJobExecutionContext executionContext)
{
try
{
var messages = await outbox.GetPendingMessages(executionContext.CancellationToken);
logger.LogTrace("loaded {Count} outbox messages to process", messages.Count);
foreach (var message in messages)
{
try
{
var notificationType = OutboxMessageTypeRegistry.GetType(message.EventType);
if (notificationType is null)
{
logger.LogWarning(
"unmapped event type {Type}. skipping.",
message.EventType
);
continue;
}
var notification =
JsonSerializer.Deserialize(message.Payload, notificationType)
as INotification;
if (notification is null)
throw new Exception("notification is null");
logger.LogTrace(
"publishing outbox message {EventType}. Id: {Id}, AggregateId: {AggregateId}",
message.EventType,
message.Id,
message.AggregateId
);
await mediator.Publish(notification, executionContext.CancellationToken);
message.Succeed();
}
catch (Exception e)
{
logger.LogError(
e,
"Error processing event {EventId} for aggregate {AggregateId}",
message.Id,
message.AggregateId
);
message.Fail(e.ToString());
}
await context.SaveChangesAsync(executionContext.CancellationToken);
}
}
catch (Exception e)
{
logger.LogError(e, "Error while processing outbox");
throw;
}
}
}

View file

@ -0,0 +1,40 @@
using System.Reflection;
using System.Text.Json;
using Femto.Common.Attributes;
using Femto.Common.Integration;
using Femto.Modules.Blog.Data;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Infrastructure.Integration;
internal class Outbox(BlogContext context)
{
public async Task AddMessage<TMessage>(Guid aggregateId, TMessage message, CancellationToken cancellationToken)
where TMessage : IIntegrationEvent
{
var eventType = typeof(TMessage).GetCustomAttribute<EventTypeAttribute>();
if (eventType is null)
throw new InvalidOperationException($"{typeof(TMessage).Name} does not have EventType attribute");
await context.Outbox.AddAsync(
new(
message.EventId,
aggregateId,
eventType.Name,
JsonSerializer.Serialize(message)
),
cancellationToken
);
}
public async Task<IList<OutboxEntry>> GetPendingMessages(CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
return await context
.Outbox.Where(message => message.Status == OutboxEntryStatus.Pending)
.Where(message => message.NextRetryAt == null || message.NextRetryAt <= now)
.OrderBy(message => message.CreatedAt)
.ToListAsync(cancellationToken);
}
}

View file

@ -0,0 +1,59 @@
namespace Femto.Modules.Blog.Infrastructure.Integration;
internal class OutboxEntry
{
private const int MaxRetries = 5;
public Guid Id { get; private set; }
public string EventType { get; private set; } = null!;
public Guid AggregateId { get; private set; }
public string Payload { get; private set; } = null!;
public DateTime CreatedAt { get; private set; }
public DateTime? ProcessedAt { get; private set; }
public DateTime? NextRetryAt { get; private set; }
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)
{
this.Id = eventId;
this.EventType = eventType;
this.AggregateId = aggregateId;
this.Payload = payload;
this.CreatedAt = DateTime.UtcNow;
}
public void Succeed()
{
this.ProcessedAt = DateTime.UtcNow;
this.Status = OutboxEntryStatus.Completed;
}
public void Fail(string error)
{
if (this.RetryCount >= MaxRetries)
{
this.Status = OutboxEntryStatus.Failed;
}
else
{
this.LastError = error;
this.NextRetryAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, this.RetryCount));
this.RetryCount++;
}
}
}
public enum OutboxEntryStatus
{
Pending,
Completed,
Failed
}

View file

@ -0,0 +1,23 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Reflection;
using Femto.Common.Attributes;
using MediatR;
namespace Femto.Modules.Blog.Infrastructure.Integration;
internal static class OutboxMessageTypeRegistry
{
private static readonly ConcurrentDictionary<string, Type> Mapping = new();
public static void RegisterOutboxMessages(IImmutableDictionary<string, Type> mapping)
{
foreach (var (key, value) in mapping)
{
Mapping.TryAdd(key, value);
}
}
public static Type? GetType(string eventName) => Mapping.GetValueOrDefault(eventName);
}

View file

@ -0,0 +1,58 @@
using Femto.Common.Domain;
using Femto.Modules.Blog.Data;
using MediatR;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Blog.Infrastructure.PipelineBehaviours;
internal class SaveChangesPipelineBehaviour<TRequest, TResponse>(
BlogContext context,
IPublisher publisher,
ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger
) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
var response = await next(cancellationToken);
if (context.ChangeTracker.HasChanges())
{
await EmitDomainEvents(cancellationToken);
logger.LogDebug("saving changes");
await context.SaveChangesAsync(cancellationToken);
}
return response;
}
private async Task EmitDomainEvents(CancellationToken cancellationToken)
{
var domainEvents = context
.ChangeTracker.Entries<Entity>()
.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);
}
}
}

View file

@ -0,0 +1,91 @@
using System.Collections.Immutable;
using Femto.Modules.Blog.Data;
using Femto.Modules.Blog.Infrastructure;
using Femto.Modules.Blog.Infrastructure.DbConnection;
using Femto.Modules.Blog.Infrastructure.Integration;
using Femto.Modules.Blog.Infrastructure.PipelineBehaviours;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Quartz;
namespace Femto.Modules.Blog;
public static class Module
{
public static void UseBlogModule(this IServiceCollection services, string connectionString)
{
OutboxMessageTypeRegistry.RegisterOutboxMessages(
Contracts.Module.GetIntegrationEventTypes().ToImmutableDictionary()
);
services.AddDbContext<BlogContext>(builder =>
{
builder.UseNpgsql(
connectionString,
o =>
{
o.MapEnum<OutboxEntryStatus>("outbox_status");
}
);
;
builder.UseSnakeCaseNamingConvention();
var loggerFactory = LoggerFactory.Create(b =>
{
// b.AddConsole();
// .AddFilter(
// (category, level) =>
// category == DbLoggerCategory.Database.Command.Name
// && level == LogLevel.Debug
// );
});
builder.UseLoggerFactory(loggerFactory);
builder.EnableSensitiveDataLogging();
});
services.AddMediatR(c =>
{
c.RegisterServicesFromAssembly(typeof(Module).Assembly);
});
services.AddQuartz(q =>
{
q.AddMailmanJob();
});
services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
services.SetupMediatrPipeline();
services.AddTransient<Outbox, Outbox>();
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
}
private static void SetupMediatrPipeline(this IServiceCollection services)
{
services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(SaveChangesPipelineBehaviour<,>)
);
}
private static void AddMailmanJob(this IServiceCollectionQuartzConfigurator q)
{
var jobKey = JobKey.Create(nameof(MailmanJob));
q.AddJob<MailmanJob>(jobKey)
.AddTrigger(trigger =>
trigger
.ForJob(jobKey)
.WithSimpleSchedule(schedule =>
schedule.WithIntervalInSeconds(1).RepeatForever()
)
);
}
}

View file

@ -0,0 +1,8 @@
namespace Femto.Modules.Files.Domain.Files;
public class File
{
Guid Id { get; set; }
}

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,16 @@
using Femto.Modules.Media.Infrastructure.Integration;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Media.Data;
internal class MediaContext(DbContextOptions<MediaContext> options) : DbContext(options)
{
public virtual DbSet<OutboxEntry> Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.HasDefaultSchema("blog");
builder.ApplyConfigurationsFromAssembly(typeof(MediaContext).Assembly);
}
}

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Folder Include="Data\Configurations\" />
</ItemGroup>
<ItemGroup>
<Reference Include="MediatR">
<HintPath>..\..\..\..\.nuget\packages\mediatr\12.5.0\lib\net6.0\MediatR.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Extensions.Hosting.Abstractions">
<HintPath>..\..\..\..\.nuget\packages\microsoft.aspnetcore.app.ref\9.0.4\ref\net9.0\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,78 @@
using System.Text.Json;
using Femto.Modules.Media.Data;
using MediatR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Media.Infrastructure.Integration;
internal class Mailman(Outbox outbox, MediaContext context, ILogger<Mailman> logger, IMediator mediator)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
var timeToWait = TimeSpan.FromSeconds(1);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await this.DeliverMail(cancellationToken);
}
catch (Exception e)
{
logger.LogError(e, "Error while processing outbox");
}
try
{
await Task.Delay(timeToWait, cancellationToken);
}
catch (TaskCanceledException)
{
break;
}
}
}
private async Task DeliverMail(CancellationToken cancellationToken)
{
var messages = await outbox.GetPendingMessages(cancellationToken);
foreach (var message in messages)
{
try
{
var notificationType = OutboxMessageTypeRegistry.GetType(message.EventType);
if (notificationType is null)
{
logger.LogWarning("unmapped event type {Type}. skipping.", message.EventType);
continue;
}
var notification =
JsonSerializer.Deserialize(message.Payload, notificationType) as INotification;
if (notification is null)
throw new Exception("notification is null");
await mediator.Publish(notification, cancellationToken);
message.Succeed();
}
catch (Exception e)
{
logger.LogError(
e,
"Error processing event {EventId} for aggregate {AggregateId}",
message.Id,
message.AggregateId
);
message.Fail(e.ToString());
}
await context.SaveChangesAsync(cancellationToken);
}
}
}

View file

@ -0,0 +1,34 @@
using System.Text.Json;
using Femto.Common.Integration;
using Femto.Modules.Media.Data;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Media.Infrastructure.Integration;
internal class Outbox(MediaContext context)
{
public async Task AddMessage<TMessage>(Guid aggregateId, TMessage message, CancellationToken cancellationToken)
where TMessage : IIntegrationEvent
{
await context.Outbox.AddAsync(
new(
message.EventId,
aggregateId,
typeof(TMessage).Name,
JsonSerializer.Serialize(message)
),
cancellationToken
);
}
public async Task<IEnumerable<OutboxEntry>> GetPendingMessages(CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
return await context
.Outbox.Where(message => message.Status == OutboxEntryStatus.Pending)
.Where(message => message.NextRetryAt == null || message.NextRetryAt <= now)
.OrderBy(message => message.CreatedAt)
.ToListAsync(cancellationToken);
}
}

View file

@ -0,0 +1,59 @@
namespace Femto.Modules.Media.Infrastructure.Integration;
internal class OutboxEntry
{
private const int MaxRetries = 5;
public Guid Id { get; private set; }
public string EventType { get; private set; } = null!;
public Guid AggregateId { get; private set; }
public string Payload { get; private set; } = null!;
public DateTime CreatedAt { get; private set; }
public DateTime? ProcessedAt { get; private set; }
public DateTime? NextRetryAt { get; private set; }
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)
{
this.Id = eventId;
this.EventType = eventType;
this.AggregateId = aggregateId;
this.Payload = payload;
this.CreatedAt = DateTime.UtcNow;
}
public void Succeed()
{
this.ProcessedAt = DateTime.UtcNow;
this.Status = OutboxEntryStatus.Completed;
}
public void Fail(string error)
{
if (this.RetryCount >= MaxRetries)
{
this.Status = OutboxEntryStatus.Failed;
}
else
{
this.LastError = error;
this.NextRetryAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, this.RetryCount));
this.RetryCount++;
}
}
}
public enum OutboxEntryStatus
{
Pending,
Completed,
Failed
}

View file

@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using System.Reflection;
using Femto.Common.Attributes;
using MediatR;
namespace Femto.Modules.Media.Infrastructure.Integration;
internal static class OutboxMessageTypeRegistry
{
private static readonly ConcurrentDictionary<string, Type> Mapping = new();
public static void RegisterOutboxMessageTypesInAssembly(Assembly assembly)
{
var types = assembly.GetTypes();
foreach (var type in types)
{
if (!typeof(INotification).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface)
continue;
var attribute = type.GetCustomAttribute<EventTypeAttribute>();
if (attribute == null)
continue;
var eventName = attribute.Name;
if (!string.IsNullOrWhiteSpace(eventName))
{
Mapping.TryAdd(eventName, type);
}
}
}
public static Type? GetType(string eventName) => Mapping.GetValueOrDefault(eventName);
}

View file

@ -0,0 +1,36 @@
using Femto.Common.Domain;
using Femto.Modules.Media.Data;
using MediatR;
namespace Femto.Modules.Media.Infrastructure.PipelineBehaviours;
internal class DomainEventsPipelineBehaviour<TRequest, TResponse>(
MediaContext context,
IPublisher publisher) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var response = await next(cancellationToken);
var domainEvents = context.ChangeTracker
.Entries<Entity>()
.SelectMany(e =>
{
var events = e.Entity.DomainEvents;
e.Entity.ClearDomainEvents();
return events;
})
.ToList();
foreach (var domainEvent in domainEvents)
{
await publisher.Publish(domainEvent, cancellationToken);
}
return response;
}
}

View file

@ -0,0 +1,23 @@
using Femto.Modules.Media.Data;
using MediatR;
namespace Femto.Modules.Media.Infrastructure.PipelineBehaviours;
/// <summary>
/// automatically call unit of work after all requuests
/// </summary>
internal class SaveChangesPipelineBehaviour<TRequest, TResponse>(MediaContext context)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
var response = await next(cancellationToken);
if (context.ChangeTracker.HasChanges())
await context.SaveChangesAsync(cancellationToken);
return response;
}
}

View file

@ -0,0 +1,66 @@
using Femto.Modules.Media.Data;
using Femto.Modules.Media.Infrastructure.Integration;
using Femto.Modules.Media.Infrastructure.PipelineBehaviours;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Media;
public static class MediaModule
{
public static void UseBlogModule(this IServiceCollection services, string connectionString)
{
OutboxMessageTypeRegistry.RegisterOutboxMessageTypesInAssembly(typeof(MediaModule).Assembly);
services.AddDbContext<MediaContext>(builder =>
{
builder.UseNpgsql(
connectionString,
o =>
{
o.MapEnum<OutboxEntryStatus>("outbox_status");
}
);
;
builder.UseSnakeCaseNamingConvention();
var loggerFactory = LoggerFactory.Create(b =>
{
b.AddConsole();
// .AddFilter(
// (category, level) =>
// category == DbLoggerCategory.Database.Command.Name
// && level == LogLevel.Debug
// );
});
builder.UseLoggerFactory(loggerFactory);
builder.EnableSensitiveDataLogging();
});
services.AddMediatR(c =>
{
c.RegisterServicesFromAssembly(typeof(MediaModule).Assembly);
});
services.SetupMediatrPipeline();
services.AddTransient<Outbox, Outbox>();
services.AddHostedService<Mailman>();
}
private static void SetupMediatrPipeline(this IServiceCollection services)
{
services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(DomainEventsPipelineBehaviour<,>)
);
services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(SaveChangesPipelineBehaviour<,>)
);
}
}

48
FemtoBackend.sln Normal file
View file

@ -0,0 +1,48 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Api", "Femto.Api\Femto.Api.csproj", "{925D568D-6B82-463B-8E6B-44E392E05970}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Blog", "Femto.Modules.Blog\Femto.Modules.Blog.csproj", "{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Database", "Femto.Database\Femto.Database.csproj", "{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Docs", "Femto.Docs\Femto.Docs.csproj", "{B5444421-873F-4E3F-AE5B-0CB86005E23A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Common", "Femto.Common\Femto.Common.csproj", "{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Blog.Contracts", "Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj", "{35C42036-D53B-42EB-9A1C-B540E55F4FD0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{925D568D-6B82-463B-8E6B-44E392E05970}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{925D568D-6B82-463B-8E6B-44E392E05970}.Debug|Any CPU.Build.0 = Debug|Any CPU
{925D568D-6B82-463B-8E6B-44E392E05970}.Release|Any CPU.ActiveCfg = Release|Any CPU
{925D568D-6B82-463B-8E6B-44E392E05970}.Release|Any CPU.Build.0 = Release|Any CPU
{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}.Debug|Any CPU.Build.0 = Debug|Any CPU
{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}.Release|Any CPU.ActiveCfg = Release|Any CPU
{095295C8-4C8C-4691-ABFA-56CD2FE3CD21}.Release|Any CPU.Build.0 = Release|Any CPU
{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2CE798CB-DEF2-4672-B115-FDB55DD05ADA}.Release|Any CPU.Build.0 = Release|Any CPU
{B5444421-873F-4E3F-AE5B-0CB86005E23A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5444421-873F-4E3F-AE5B-0CB86005E23A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5444421-873F-4E3F-AE5B-0CB86005E23A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5444421-873F-4E3F-AE5B-0CB86005E23A}.Release|Any CPU.Build.0 = Release|Any CPU
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Debug|Any CPU.Build.0 = Debug|Any CPU
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Release|Any CPU.ActiveCfg = Release|Any CPU
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Release|Any CPU.Build.0 = Release|Any CPU
{35C42036-D53B-42EB-9A1C-B540E55F4FD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{35C42036-D53B-42EB-9A1C-B540E55F4FD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{35C42036-D53B-42EB-9A1C-B540E55F4FD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{35C42036-D53B-42EB-9A1C-B540E55F4FD0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
EndGlobalSection
EndGlobal

6
compose.yaml Normal file
View file

@ -0,0 +1,6 @@
services:
backend:
image: backend
build:
context: .
dockerfile: backend/Dockerfile