hopefully not a horribly foolish refactoring
This commit is contained in:
parent
59d660165f
commit
1ecaf64dea
82 changed files with 782 additions and 398 deletions
27
Femto.Api/Controllers/Auth/AuthController.cs
Normal file
27
Femto.Api/Controllers/Auth/AuthController.cs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("auth")]
|
||||||
|
public class AuthController : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request)
|
||||||
|
{
|
||||||
|
return new LoginResponse(Guid.Parse("0196960c-6296-7532-ba66-8fabb38c6ae0"), "johnbotris", "token");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("signup")]
|
||||||
|
public async Task<ActionResult<SignupResponse>> Signup([FromBody] SignupRequest request)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delete-session")]
|
||||||
|
public async Task<ActionResult> DeleteSession([FromBody] DeleteSessionRequest request)
|
||||||
|
{
|
||||||
|
// TODO
|
||||||
|
return Ok(new {});
|
||||||
|
}
|
||||||
|
}
|
3
Femto.Api/Controllers/Auth/DeleteSessionRequest.cs
Normal file
3
Femto.Api/Controllers/Auth/DeleteSessionRequest.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
public record DeleteSessionRequest(string SessionToken);
|
3
Femto.Api/Controllers/Auth/LoginRequest.cs
Normal file
3
Femto.Api/Controllers/Auth/LoginRequest.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
public record LoginRequest(string Username, string Password);
|
3
Femto.Api/Controllers/Auth/LoginResponse.cs
Normal file
3
Femto.Api/Controllers/Auth/LoginResponse.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
public record LoginResponse(Guid UserId, string Username, string SessionToken);
|
3
Femto.Api/Controllers/Auth/SignupRequest.cs
Normal file
3
Femto.Api/Controllers/Auth/SignupRequest.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
public record SignupRequest(string Username, string Password, string SignupCode, string? Email);
|
3
Femto.Api/Controllers/Auth/SignupResponse.cs
Normal file
3
Femto.Api/Controllers/Auth/SignupResponse.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
public record SignupResponse(Guid UserId, string Username, string SessionToken);
|
|
@ -1,40 +0,0 @@
|
||||||
using Femto.Api.Controllers.Authors.Dto;
|
|
||||||
using Femto.Modules.Blog.Domain.Posts.Commands.GetPosts;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Femto.Api.Controllers.Authors;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("authors")]
|
|
||||||
public class AuthorsController(IMediator mediator) : ControllerBase
|
|
||||||
{
|
|
||||||
[HttpGet("{username}/posts")]
|
|
||||||
public async Task<ActionResult<GetAuthorPostsResponse>> GetAuthorPosts(
|
|
||||||
string username,
|
|
||||||
[FromQuery] GetAuthorPostsSearchParams searchParams,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var res = await mediator.Send(
|
|
||||||
new GetPostsQuery
|
|
||||||
{
|
|
||||||
Username = username,
|
|
||||||
Amount = searchParams.Amount ?? 20,
|
|
||||||
From = searchParams.From,
|
|
||||||
},
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
return new GetAuthorPostsResponse(
|
|
||||||
res.Posts.Select(p => new AuthorPostDto(
|
|
||||||
p.PostId,
|
|
||||||
p.Text,
|
|
||||||
p.Media.Select(m => m.Url),
|
|
||||||
p.CreatedAt,
|
|
||||||
new AuthoPostAuthorDto(p.Author.AuthorId, p.Author.Username)
|
|
||||||
)),
|
|
||||||
res.Next
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
using JetBrains.Annotations;
|
|
||||||
|
|
||||||
namespace Femto.Api.Controllers.Authors.Dto;
|
|
||||||
|
|
||||||
[PublicAPI]
|
|
||||||
public record AuthoPostAuthorDto(Guid AuthorId, string Username);
|
|
|
@ -1,6 +0,0 @@
|
||||||
using JetBrains.Annotations;
|
|
||||||
|
|
||||||
namespace Femto.Api.Controllers.Authors.Dto;
|
|
||||||
|
|
||||||
[PublicAPI]
|
|
||||||
public record AuthorPostDto(Guid PostId, string Content, IEnumerable<Uri> Media, DateTimeOffset CreatedAt, AuthoPostAuthorDto Author );
|
|
|
@ -1,6 +0,0 @@
|
||||||
using JetBrains.Annotations;
|
|
||||||
|
|
||||||
namespace Femto.Api.Controllers.Authors.Dto;
|
|
||||||
|
|
||||||
[PublicAPI]
|
|
||||||
public record GetAuthorPostsResponse(IEnumerable<AuthorPostDto> Posts, Guid? Next);
|
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Authors.Dto;
|
|
||||||
|
|
||||||
public record GetAuthorPostsSearchParams(Guid? From, int? Amount);
|
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
public record CreatePostRequest(Guid AuthorId, string Content, IEnumerable<Uri> Media);
|
public record CreatePostRequest(Guid AuthorId, string Content, IEnumerable<CreatePostRequestMedia> Media);
|
|
@ -0,0 +1,6 @@
|
||||||
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record CreatePostRequestMedia(Guid MediaId, Uri Url, string? Type, int? Width, int? Height);
|
|
@ -3,4 +3,4 @@ using JetBrains.Annotations;
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public record GetPublicPostsSearchParams(Guid? From, int? Amount);
|
public record GetPublicPostsSearchParams(Guid? From, int? Amount, Guid? AuthorId, string? Author);
|
|
@ -7,6 +7,6 @@ public record PublicPostDto(
|
||||||
PublicPostAuthorDto Author,
|
PublicPostAuthorDto Author,
|
||||||
Guid PostId,
|
Guid PostId,
|
||||||
string Content,
|
string Content,
|
||||||
IEnumerable<Uri> Media,
|
IEnumerable<PublicPostMediaDto> Media,
|
||||||
DateTimeOffset CreatedAt
|
DateTimeOffset CreatedAt
|
||||||
);
|
);
|
6
Femto.Api/Controllers/Posts/Dto/PublicPostMediaDto.cs
Normal file
6
Femto.Api/Controllers/Posts/Dto/PublicPostMediaDto.cs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record PublicPostMediaDto(Uri Url, int? Width, int? Height);
|
|
@ -1,6 +1,8 @@
|
||||||
using Femto.Api.Controllers.Posts.Dto;
|
using Femto.Api.Controllers.Posts.Dto;
|
||||||
using Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
|
using Femto.Modules.Blog;
|
||||||
using Femto.Modules.Blog.Domain.Posts.Commands.GetPosts;
|
using Femto.Modules.Blog.Application;
|
||||||
|
using Femto.Modules.Blog.Application.Commands.CreatePost;
|
||||||
|
using Femto.Modules.Blog.Application.Queries.GetPosts;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
@ -8,7 +10,7 @@ namespace Femto.Api.Controllers.Posts;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("posts")]
|
[Route("posts")]
|
||||||
public class PostsController(IMediator mediator) : ControllerBase
|
public class PostsController(IBlogModule blogModule) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<GetAllPublicPostsResponse>> GetAllPublicPosts(
|
public async Task<ActionResult<GetAllPublicPostsResponse>> GetAllPublicPosts(
|
||||||
|
@ -16,11 +18,13 @@ public class PostsController(IMediator mediator) : ControllerBase
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var res = await mediator.Send(
|
var res = await blogModule.PostQuery(
|
||||||
new GetPostsQuery
|
new GetPostsQuery
|
||||||
{
|
{
|
||||||
From = searchParams.From,
|
From = searchParams.From,
|
||||||
Amount = searchParams.Amount ?? 20
|
Amount = searchParams.Amount ?? 20,
|
||||||
|
AuthorId = searchParams.AuthorId,
|
||||||
|
Author = searchParams.Author,
|
||||||
},
|
},
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
@ -30,7 +34,7 @@ public class PostsController(IMediator mediator) : ControllerBase
|
||||||
new PublicPostAuthorDto(p.Author.AuthorId, p.Author.Username),
|
new PublicPostAuthorDto(p.Author.AuthorId, p.Author.Username),
|
||||||
p.PostId,
|
p.PostId,
|
||||||
p.Text,
|
p.Text,
|
||||||
p.Media.Select(m => m.Url),
|
p.Media.Select(m => new PublicPostMediaDto(m.Url, m.Width, m.Height)),
|
||||||
p.CreatedAt
|
p.CreatedAt
|
||||||
)),
|
)),
|
||||||
res.Next
|
res.Next
|
||||||
|
@ -43,8 +47,22 @@ public class PostsController(IMediator mediator) : ControllerBase
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var guid = await mediator.Send(
|
var guid = await blogModule.PostCommand(
|
||||||
new CreatePostCommand(req.AuthorId, req.Content, req.Media),
|
new CreatePostCommand(
|
||||||
|
req.AuthorId,
|
||||||
|
req.Content,
|
||||||
|
req.Media.Select(
|
||||||
|
(media, idx) =>
|
||||||
|
new CreatePostMedia(
|
||||||
|
media.MediaId,
|
||||||
|
media.Url,
|
||||||
|
media.Type,
|
||||||
|
idx,
|
||||||
|
media.Width,
|
||||||
|
media.Height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Autofac" Version="8.3.0" />
|
||||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3"/>
|
||||||
|
@ -17,6 +18,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Femto.Modules.Blog\Femto.Modules.Blog.csproj" />
|
<ProjectReference Include="..\Femto.Modules.Blog\Femto.Modules.Blog.csproj" />
|
||||||
|
<ProjectReference Include="..\Femto.Modules.Authentication\Femto.Modules.Authentication.csproj" />
|
||||||
<ProjectReference Include="..\Femto.Modules.Media\Femto.Modules.Media.csproj" />
|
<ProjectReference Include="..\Femto.Modules.Media\Femto.Modules.Media.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Femto.Modules.Blog;
|
using Femto.Modules.Blog.Application;
|
||||||
using Femto.Modules.Media;
|
using Femto.Modules.Media;
|
||||||
using Quartz;
|
using Femto.Modules.Media.Application;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
@ -16,13 +16,9 @@ var blobStorageRoot = builder.Configuration.GetValue<string>("BlobStorageRoot");
|
||||||
if (blobStorageRoot is null)
|
if (blobStorageRoot is null)
|
||||||
throw new Exception("no blob storage root found");
|
throw new Exception("no blob storage root found");
|
||||||
|
|
||||||
builder.Services.AddQuartzHostedService(options =>
|
builder.Services.InitializeBlogModule(databaseConnectionString);
|
||||||
{
|
builder.Services.InitializeMediaModule(databaseConnectionString, blobStorageRoot);
|
||||||
options.WaitForJobsToComplete = true;
|
// builder.Services.UseIdentityModule(databaseConnectionString);
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.UseBlogModule(databaseConnectionString);
|
|
||||||
builder.Services.UseMediaModule(databaseConnectionString, blobStorageRoot);
|
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
|
@ -36,6 +32,7 @@ builder.Services.AddCors(options =>
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.Services.AddControllers()
|
.Services.AddControllers()
|
||||||
.AddJsonOptions(options =>
|
.AddJsonOptions(options =>
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
namespace Femto.Common.Domain;
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
public class DomainException(string message) : Exception(message);
|
public class DomainException : Exception
|
||||||
|
{
|
||||||
|
public DomainException(string message, Exception innerException) : base(message, innerException) {}
|
||||||
|
public DomainException(string message) : base(message) {}
|
||||||
|
}
|
7
Femto.Common/Domain/ICommand.cs
Normal file
7
Femto.Common/Domain/ICommand.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
|
public interface ICommand : IRequest;
|
||||||
|
|
||||||
|
public interface ICommand<out T> : IRequest<T>;
|
5
Femto.Common/Domain/IQuery.cs
Normal file
5
Femto.Common/Domain/IQuery.cs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
|
public interface IQuery<out T> : IRequest<T>;
|
|
@ -8,9 +8,10 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.4" />
|
||||||
<ItemGroup>
|
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||||
<Folder Include="Infrastructure\" />
|
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Infrastructure.DbConnection;
|
namespace Femto.Common.Infrastructure.DbConnection;
|
||||||
|
|
||||||
public class DbConnectionFactory(string connectionString) : IDbConnectionFactory
|
public class DbConnectionFactory(string connectionString) : IDbConnectionFactory
|
||||||
{
|
{
|
|
@ -1,7 +1,6 @@
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Infrastructure.DbConnection;
|
namespace Femto.Common.Infrastructure.DbConnection;
|
||||||
|
|
||||||
public interface IDbConnectionFactory
|
public interface IDbConnectionFactory
|
||||||
{
|
{
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace Femto.Common.Infrastructure.Outbox;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A mapping based on the CLR type name
|
||||||
|
/// Brittle in the case of types being moved as they will then not be able to be handled by the outbox. but simple in implementation
|
||||||
|
/// </summary>
|
||||||
|
public class ClrTypenameMessageMapping : IOutboxMessageMapping
|
||||||
|
{
|
||||||
|
public Type? GetTypeOfEvent(string eventName)
|
||||||
|
{
|
||||||
|
return Type.GetType(eventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetEventName(Type eventType)
|
||||||
|
{
|
||||||
|
return eventType.AssemblyQualifiedName;
|
||||||
|
}
|
||||||
|
}
|
9
Femto.Common/Infrastructure/Outbox/IOutboxContext.cs
Normal file
9
Femto.Common/Infrastructure/Outbox/IOutboxContext.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Common.Infrastructure.Outbox;
|
||||||
|
|
||||||
|
public interface IOutboxContext
|
||||||
|
{
|
||||||
|
DbSet<OutboxEntry> Outbox { get; }
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Femto.Common.Infrastructure.Outbox;
|
||||||
|
|
||||||
|
public interface IOutboxMessageHandler
|
||||||
|
{
|
||||||
|
Task Publish<TNotification>(TNotification notification, CancellationToken executionContextCancellationToken);
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Femto.Common.Infrastructure.Outbox;
|
||||||
|
|
||||||
|
public interface IOutboxMessageMapping
|
||||||
|
{
|
||||||
|
Type? GetTypeOfEvent(string eventName);
|
||||||
|
string? GetEventName(Type eventType);
|
||||||
|
}
|
29
Femto.Common/Infrastructure/Outbox/Outbox.cs
Normal file
29
Femto.Common/Infrastructure/Outbox/Outbox.cs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Femto.Common.Attributes;
|
||||||
|
using Femto.Common.Integration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Femto.Common.Infrastructure.Outbox;
|
||||||
|
|
||||||
|
public class Outbox<TContext>(TContext context, IOutboxMessageMapping mapping) where TContext : IOutboxContext
|
||||||
|
{
|
||||||
|
public async Task AddMessage<TMessage>(
|
||||||
|
Guid aggregateId,
|
||||||
|
TMessage message,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
where TMessage : IIntegrationEvent
|
||||||
|
{
|
||||||
|
var eventName = mapping.GetEventName(typeof(TMessage));
|
||||||
|
if (eventName is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{typeof(TMessage).Name} does not have EventType attribute"
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.Outbox.AddAsync(
|
||||||
|
new(message.EventId, aggregateId, eventName, JsonSerializer.Serialize(message)),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
namespace Femto.Modules.Blog.Infrastructure.Integration.Outbox;
|
namespace Femto.Common.Infrastructure.Outbox;
|
||||||
|
|
||||||
internal class OutboxEntry
|
public class OutboxEntry
|
||||||
{
|
{
|
||||||
private const int MaxRetries = 5;
|
private const int MaxRetries = 5;
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace Femto.Common.Infrastructure.Outbox;
|
||||||
|
|
||||||
|
public static class OutboxEventNameRegistry
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
|
@ -1,31 +1,37 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Femto.Modules.Blog.Data;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Infrastructure.Integration.Outbox;
|
namespace Femto.Common.Infrastructure.Outbox;
|
||||||
|
|
||||||
[DisallowConcurrentExecution]
|
[DisallowConcurrentExecution]
|
||||||
internal class MailmanJob(
|
public class OutboxProcessor<TContext>(
|
||||||
Outbox outbox,
|
TContext context,
|
||||||
BlogContext context,
|
ILogger<OutboxProcessor<TContext>> logger,
|
||||||
ILogger<MailmanJob> logger,
|
IOutboxMessageMapping mapping,
|
||||||
IMediator mediator
|
IOutboxMessageHandler handler
|
||||||
) : IJob
|
) : IJob
|
||||||
|
where TContext : DbContext, IOutboxContext
|
||||||
{
|
{
|
||||||
public async Task Execute(IJobExecutionContext executionContext)
|
public async Task Execute(IJobExecutionContext executionContext)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var messages = await outbox.GetPendingMessages(executionContext.CancellationToken);
|
var now = DateTime.UtcNow;
|
||||||
|
var messages = await context
|
||||||
|
.Outbox.Where(message => message.Status == OutboxEntryStatus.Pending)
|
||||||
|
.Where(message => message.NextRetryAt == null || message.NextRetryAt <= now)
|
||||||
|
.OrderBy(message => message.CreatedAt)
|
||||||
|
.ToListAsync(executionContext.CancellationToken);
|
||||||
|
|
||||||
logger.LogTrace("loaded {Count} outbox messages to process", messages.Count);
|
logger.LogTrace("loaded {Count} outbox messages to process", messages.Count);
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var notificationType = OutboxMessageTypeRegistry.GetType(message.EventType);
|
var notificationType = mapping.GetTypeOfEvent(message.EventType);
|
||||||
|
|
||||||
if (notificationType is null)
|
if (notificationType is null)
|
||||||
{
|
{
|
||||||
|
@ -50,7 +56,7 @@ internal class MailmanJob(
|
||||||
message.AggregateId
|
message.AggregateId
|
||||||
);
|
);
|
||||||
|
|
||||||
await mediator.Publish(notification, executionContext.CancellationToken);
|
await handler.Publish(notification, executionContext.CancellationToken);
|
||||||
|
|
||||||
message.Succeed();
|
message.Succeed();
|
||||||
}
|
}
|
41
Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs
Normal file
41
Femto.Common/Infrastructure/Outbox/OutboxServiceExtension.cs
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using Femto.Common.Attributes;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace Femto.Common.Infrastructure.Outbox;
|
||||||
|
|
||||||
|
public static class OutboxServiceExtension
|
||||||
|
{
|
||||||
|
public static void AddOutbox<TContext>(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Func<IServiceProvider, TContext>? contextFactory = null
|
||||||
|
)
|
||||||
|
where TContext : DbContext, IOutboxContext
|
||||||
|
{
|
||||||
|
|
||||||
|
services.AddSingleton<IOutboxMessageMapping, ClrTypenameMessageMapping>();
|
||||||
|
|
||||||
|
services.AddScoped<IOutboxContext>(c =>
|
||||||
|
contextFactory?.Invoke(c) ?? c.GetRequiredService<TContext>()
|
||||||
|
);
|
||||||
|
|
||||||
|
services.AddScoped<Outbox<TContext>>();
|
||||||
|
|
||||||
|
services.AddQuartz(q =>
|
||||||
|
{
|
||||||
|
var jobKey = JobKey.Create(nameof(OutboxProcessor<TContext>));
|
||||||
|
|
||||||
|
q.AddJob<OutboxProcessor<TContext>>(jobKey)
|
||||||
|
.AddTrigger(trigger =>
|
||||||
|
trigger
|
||||||
|
.ForJob(jobKey)
|
||||||
|
.WithSimpleSchedule(schedule =>
|
||||||
|
schedule.WithIntervalInSeconds(1).RepeatForever()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,12 @@
|
||||||
using Femto.Common.Domain;
|
using Femto.Common.Domain;
|
||||||
using Femto.Modules.Blog.Data;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Infrastructure.PipelineBehaviours;
|
namespace Femto.Common.Infrastructure;
|
||||||
|
|
||||||
internal class SaveChangesPipelineBehaviour<TRequest, TResponse>(
|
public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
|
||||||
BlogContext context,
|
DbContext context,
|
||||||
IPublisher publisher,
|
IPublisher publisher,
|
||||||
ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger
|
ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger
|
||||||
) : IPipelineBehavior<TRequest, TResponse>
|
) : IPipelineBehavior<TRequest, TResponse>
|
||||||
|
@ -22,8 +22,7 @@ internal class SaveChangesPipelineBehaviour<TRequest, TResponse>(
|
||||||
|
|
||||||
if (context.ChangeTracker.HasChanges())
|
if (context.ChangeTracker.HasChanges())
|
||||||
{
|
{
|
||||||
|
await this.EmitDomainEvents(cancellationToken);
|
||||||
await EmitDomainEvents(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogDebug("saving changes");
|
logger.LogDebug("saving changes");
|
||||||
await context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
|
@ -1,21 +1,24 @@
|
||||||
using System.Collections.Immutable;
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Femto.Common.Attributes;
|
using Femto.Common.Attributes;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Contracts;
|
namespace Femto.Common.Util;
|
||||||
|
|
||||||
public static class Module
|
public static class EventTypeMapping
|
||||||
{
|
{
|
||||||
public static IDictionary<string, Type> GetIntegrationEventTypes()
|
public static IDictionary<string, Type> GetEventTypeMapping(Assembly assembly)
|
||||||
{
|
{
|
||||||
var mapping = new Dictionary<string, Type>();
|
var mapping = new Dictionary<string, Type>();
|
||||||
|
|
||||||
var types = typeof(Module).Assembly.GetTypes();
|
var types = assembly.GetTypes();
|
||||||
|
|
||||||
foreach (var type in types)
|
foreach (var type in types)
|
||||||
{
|
{
|
||||||
if (!typeof(INotification).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface)
|
if (
|
||||||
|
!typeof(INotification).IsAssignableFrom(type)
|
||||||
|
|| type.IsAbstract
|
||||||
|
|| type.IsInterface
|
||||||
|
)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var attribute = type.GetCustomAttribute<EventTypeAttribute>();
|
var attribute = type.GetCustomAttribute<EventTypeAttribute>();
|
|
@ -22,6 +22,9 @@ CREATE TABLE blog.post_media
|
||||||
id uuid PRIMARY KEY,
|
id uuid PRIMARY KEY,
|
||||||
post_id uuid NOT NULL REFERENCES blog.post (id) ON DELETE CASCADE,
|
post_id uuid NOT NULL REFERENCES blog.post (id) ON DELETE CASCADE,
|
||||||
url text NOT NULL,
|
url text NOT NULL,
|
||||||
|
type varchar(64),
|
||||||
|
width int,
|
||||||
|
height int,
|
||||||
ordering int NOT NULL
|
ordering int NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
using Femto.Modules.Authentication.Data;
|
||||||
|
using Femto.Modules.Authentication.Models;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Authentication.Contracts;
|
||||||
|
|
||||||
|
internal class AuthenticationService(AuthenticationContext context) : IAuthenticationService
|
||||||
|
{
|
||||||
|
public async Task<UserInfo> 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<UserInfo> Authenticate(string username, string password)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuthenticationError(string message, Exception inner) : Exception(message, inner);
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Femto.Modules.Authentication.Contracts;
|
||||||
|
|
||||||
|
public interface IAuthenticationService
|
||||||
|
{
|
||||||
|
public Task<UserInfo?> Register(string username, string password);
|
||||||
|
public Task<UserInfo?> Authenticate(string username, string password);
|
||||||
|
}
|
3
Femto.Modules.Authentication/Contracts/UserInfo.cs
Normal file
3
Femto.Modules.Authentication/Contracts/UserInfo.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Modules.Authentication.Contracts;
|
||||||
|
|
||||||
|
public record UserInfo(Guid UserId, string Username);
|
16
Femto.Modules.Authentication/Data/AuthenticationContext.cs
Normal file
16
Femto.Modules.Authentication/Data/AuthenticationContext.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
using Femto.Common.Infrastructure.Outbox;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Authentication.Data;
|
||||||
|
|
||||||
|
internal class AuthenticationContext : DbContext, IOutboxContext
|
||||||
|
{
|
||||||
|
public virtual DbSet<OutboxEntry> Outbox { get; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(builder);
|
||||||
|
builder.HasDefaultSchema("authn");
|
||||||
|
builder.ApplyConfigurationsFromAssembly(typeof(AuthenticationContext).Assembly);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
using Femto.Modules.Authentication.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Authentication.Data.Configurations;
|
||||||
|
|
||||||
|
internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration<UserIdentity>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<UserIdentity> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("user_identity");
|
||||||
|
builder.OwnsOne(u => u.Password).WithOwner().HasForeignKey("user_id");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Autofac" Version="8.3.0" />
|
||||||
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Geralt" Version="3.3.0" />
|
||||||
|
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
|
@ -0,0 +1,5 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Authentication.Models.Events;
|
||||||
|
|
||||||
|
internal record UserWasCreatedEvent(UserIdentity User) : DomainEvent;
|
52
Femto.Modules.Authentication/Models/UserIdentity.cs
Normal file
52
Femto.Modules.Authentication/Models/UserIdentity.cs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
using System.Text;
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Authentication.Contracts;
|
||||||
|
using Femto.Modules.Authentication.Models.Events;
|
||||||
|
using Geralt;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Authentication.Models;
|
||||||
|
|
||||||
|
internal class UserIdentity : Entity
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
|
public string Username { get; private set; }
|
||||||
|
|
||||||
|
public UserPassword Password { get; private set; }
|
||||||
|
|
||||||
|
private UserIdentity()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserIdentity(string username)
|
||||||
|
{
|
||||||
|
this.Id = Guid.CreateVersion7();
|
||||||
|
this.Username = username;
|
||||||
|
|
||||||
|
this.AddDomainEvent(new UserWasCreatedEvent(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserIdentity WithPassword(string password)
|
||||||
|
{
|
||||||
|
this.SetPassword(password);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPassword(string password)
|
||||||
|
{
|
||||||
|
var hash = new byte[128];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Argon2id.ComputeHash(hash, Encoding.UTF8.GetBytes(password), 3, 67108864);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
throw new SetPasswordError("Failed to hash password", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.Password = new UserPassword(this.Id, hash, new byte[128]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SetPasswordError(string message, Exception inner) : DomainException(message, inner);
|
19
Femto.Modules.Authentication/Models/UserPassword.cs
Normal file
19
Femto.Modules.Authentication/Models/UserPassword.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
namespace Femto.Modules.Authentication.Models;
|
||||||
|
|
||||||
|
internal class UserPassword
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
|
public byte[] Hash { get; private set; }
|
||||||
|
|
||||||
|
public byte[] Salt { get; private set; }
|
||||||
|
|
||||||
|
private UserPassword() {}
|
||||||
|
|
||||||
|
public UserPassword(Guid id, byte[] hash, byte[] salt)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Hash = hash;
|
||||||
|
Salt = salt;
|
||||||
|
}
|
||||||
|
}
|
57
Femto.Modules.Authentication/Module.cs
Normal file
57
Femto.Modules.Authentication/Module.cs
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
using Femto.Common.Infrastructure.Outbox;
|
||||||
|
using Femto.Modules.Authentication.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Authentication;
|
||||||
|
|
||||||
|
public static class Module
|
||||||
|
{
|
||||||
|
public static void UseIdentityModule(this IServiceCollection services, string connectionString)
|
||||||
|
{
|
||||||
|
services.AddDbContext<AuthenticationContext>(
|
||||||
|
builder =>
|
||||||
|
{
|
||||||
|
builder.UseNpgsql(connectionString);
|
||||||
|
builder.UseSnakeCaseNamingConvention();
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(Module).Assembly));
|
||||||
|
|
||||||
|
services.AddDbContext<AuthenticationContext>(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.AddOutbox<AuthenticationContext>();
|
||||||
|
|
||||||
|
services.AddMediatR(c =>
|
||||||
|
{
|
||||||
|
c.RegisterServicesFromAssembly(typeof(Module).Assembly);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddTransient<Outbox<AuthenticationContext>, Outbox<AuthenticationContext>>();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +0,0 @@
|
||||||
<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>
|
|
11
Femto.Modules.Blog/Application/BlogApplication.cs
Normal file
11
Femto.Modules.Blog/Application/BlogApplication.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Application;
|
||||||
|
|
||||||
|
public class BlogApplication(IHost host) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await host.RunAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,10 @@
|
||||||
|
using Femto.Common.Infrastructure.Outbox;
|
||||||
using Femto.Modules.Blog.Domain.Posts;
|
using Femto.Modules.Blog.Domain.Posts;
|
||||||
using Femto.Modules.Blog.Infrastructure.Integration;
|
|
||||||
using Femto.Modules.Blog.Infrastructure.Integration.Outbox;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Data;
|
namespace Femto.Modules.Blog.Application;
|
||||||
|
|
||||||
internal class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options)
|
internal class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options), IOutboxContext
|
||||||
{
|
{
|
||||||
public virtual DbSet<Post> Posts { get; set; }
|
public virtual DbSet<Post> Posts { get; set; }
|
||||||
public virtual DbSet<OutboxEntry> Outbox { get; set; }
|
public virtual DbSet<OutboxEntry> Outbox { get; set; }
|
38
Femto.Modules.Blog/Application/BlogModule.cs
Normal file
38
Femto.Modules.Blog/Application/BlogModule.cs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Application;
|
||||||
|
|
||||||
|
internal class BlogModule(IHost host) : IBlogModule
|
||||||
|
{
|
||||||
|
public async Task PostCommand(ICommand command, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var scope = host.Services.CreateScope();
|
||||||
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
|
await mediator.Send(command, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TResponse> PostCommand<TResponse>(
|
||||||
|
ICommand<TResponse> command,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
using var scope = host.Services.CreateScope();
|
||||||
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
|
var response = await mediator.Send(command, cancellationToken);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TResponse> PostQuery<TResponse>(
|
||||||
|
IQuery<TResponse> query,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
using var scope = host.Services.CreateScope();
|
||||||
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
|
var response = await mediator.Send(query, cancellationToken);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
62
Femto.Modules.Blog/Application/BlogStartup.cs
Normal file
62
Femto.Modules.Blog/Application/BlogStartup.cs
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
using Femto.Common.Infrastructure;
|
||||||
|
using Femto.Common.Infrastructure.DbConnection;
|
||||||
|
using Femto.Common.Infrastructure.Outbox;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Application;
|
||||||
|
|
||||||
|
public static class BlogStartup
|
||||||
|
{
|
||||||
|
public static void InitializeBlogModule(
|
||||||
|
this IServiceCollection rootContainer,
|
||||||
|
string connectionString
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var hostBuilder = Host.CreateDefaultBuilder();
|
||||||
|
|
||||||
|
hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString));
|
||||||
|
|
||||||
|
var host = hostBuilder.Build();
|
||||||
|
|
||||||
|
rootContainer.AddHostedService(services => new BlogApplication(host));
|
||||||
|
|
||||||
|
rootContainer.AddScoped<IBlogModule>(_ => new BlogModule(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureServices(this IServiceCollection services, string connectionString)
|
||||||
|
{
|
||||||
|
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
|
||||||
|
|
||||||
|
services.AddDbContext<BlogContext>(builder =>
|
||||||
|
{
|
||||||
|
builder.UseNpgsql(
|
||||||
|
connectionString,
|
||||||
|
o =>
|
||||||
|
{
|
||||||
|
o.MapEnum<OutboxEntryStatus>("outbox_status");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
builder.UseSnakeCaseNamingConvention();
|
||||||
|
var loggerFactory = LoggerFactory.Create(b => { });
|
||||||
|
builder.UseLoggerFactory(loggerFactory);
|
||||||
|
builder.EnableSensitiveDataLogging();
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddMediatR(c =>
|
||||||
|
{
|
||||||
|
c.RegisterServicesFromAssembly(typeof(BlogStartup).Assembly);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddScoped<DbContext>(s => s.GetRequiredService<BlogContext>());
|
||||||
|
|
||||||
|
services.AddTransient(
|
||||||
|
typeof(IPipelineBehavior<,>),
|
||||||
|
typeof(SaveChangesPipelineBehaviour<,>)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Application.Commands.CreatePost;
|
||||||
|
|
||||||
|
public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable<CreatePostMedia> Media)
|
||||||
|
: ICommand<Guid>;
|
||||||
|
|
||||||
|
public record CreatePostMedia(Guid MediaId, Uri Url, string? Type, int Order, int? Width, int? Height);
|
|
@ -1,7 +1,7 @@
|
||||||
using Femto.Modules.Blog.Data;
|
using Femto.Modules.Blog.Domain.Posts;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
|
namespace Femto.Modules.Blog.Application.Commands.CreatePost;
|
||||||
|
|
||||||
internal class CreatePostCommandHandler(BlogContext context)
|
internal class CreatePostCommandHandler(BlogContext context)
|
||||||
: IRequestHandler<CreatePostCommand, Guid>
|
: IRequestHandler<CreatePostCommand, Guid>
|
||||||
|
@ -11,7 +11,16 @@ internal class CreatePostCommandHandler(BlogContext context)
|
||||||
var post = new Post(
|
var post = new Post(
|
||||||
request.AuthorId,
|
request.AuthorId,
|
||||||
request.Content,
|
request.Content,
|
||||||
request.Media.Select((url, idx) => new PostMedia(Guid.CreateVersion7(), url, idx)).ToList()
|
request
|
||||||
|
.Media.Select(media => new PostMedia(
|
||||||
|
media.MediaId,
|
||||||
|
media.Url,
|
||||||
|
media.Type,
|
||||||
|
media.Order,
|
||||||
|
media.Width,
|
||||||
|
media.Height
|
||||||
|
))
|
||||||
|
.ToList()
|
||||||
);
|
);
|
||||||
|
|
||||||
await context.AddAsync(post, cancellationToken);
|
await context.AddAsync(post, cancellationToken);
|
|
@ -1,9 +1,8 @@
|
||||||
using Femto.Modules.Blog.Infrastructure.Integration;
|
using Femto.Common.Infrastructure.Outbox;
|
||||||
using Femto.Modules.Blog.Infrastructure.Integration.Outbox;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Data.Configurations;
|
namespace Femto.Modules.Blog.Application.Configurations;
|
||||||
|
|
||||||
internal class OutboxEntryConfiguration : IEntityTypeConfiguration<OutboxEntry>
|
internal class OutboxEntryConfiguration : IEntityTypeConfiguration<OutboxEntry>
|
||||||
{
|
{
|
||||||
|
@ -11,7 +10,6 @@ internal class OutboxEntryConfiguration : IEntityTypeConfiguration<OutboxEntry>
|
||||||
{
|
{
|
||||||
builder.ToTable("outbox");
|
builder.ToTable("outbox");
|
||||||
|
|
||||||
builder.Property(x => x.Payload)
|
builder.Property(x => x.Payload).HasColumnType("jsonb");
|
||||||
.HasColumnType("jsonb");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,7 +2,7 @@ using Femto.Modules.Blog.Domain.Posts;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Data.Configurations;
|
namespace Femto.Modules.Blog.Application.Configurations;
|
||||||
|
|
||||||
internal class PostConfiguration : IEntityTypeConfiguration<Post>
|
internal class PostConfiguration : IEntityTypeConfiguration<Post>
|
||||||
{
|
{
|
18
Femto.Modules.Blog/Application/IBlogModule.cs
Normal file
18
Femto.Modules.Blog/Application/IBlogModule.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Application;
|
||||||
|
|
||||||
|
public interface IBlogModule
|
||||||
|
{
|
||||||
|
Task PostCommand(ICommand command, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<TResponse> PostCommand<TResponse>(
|
||||||
|
ICommand<TResponse> command,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
);
|
||||||
|
|
||||||
|
Task<TResponse> PostQuery<TResponse>(
|
||||||
|
IQuery<TResponse> query,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto;
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
public record GetPostsQueryResult(IList<PostDto> Posts, Guid? Next);
|
public record GetPostsQueryResult(IList<PostDto> Posts, Guid? Next);
|
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
|
public record PostAuthorDto(Guid AuthorId, string Username);
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto;
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
public record PostDto(Guid PostId, string Text, IList<PostMediaDto> Media, DateTimeOffset CreatedAt, PostAuthorDto Author);
|
public record PostDto(Guid PostId, string Text, IList<PostMediaDto> Media, DateTimeOffset CreatedAt, PostAuthorDto Author);
|
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
|
public record PostMediaDto(Uri Url, int? Width, int? Height);
|
|
@ -1,21 +1,21 @@
|
||||||
using Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto;
|
using Femto.Common.Domain;
|
||||||
using MediatR;
|
using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts;
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts;
|
||||||
|
|
||||||
public class GetPostsQuery : IRequest<GetPostsQueryResult>
|
public class GetPostsQuery : IQuery<GetPostsQueryResult>
|
||||||
{
|
{
|
||||||
|
|
||||||
public string? Username { get; init; }
|
|
||||||
public Guid? From { get; init; }
|
public Guid? From { get; init; }
|
||||||
public int Amount { get; init; } = 20;
|
public int Amount { get; init; } = 20;
|
||||||
public Guid? AuthorGuid { get; init; }
|
public Guid? AuthorId { get; init; }
|
||||||
|
public string? Author { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default is to load in reverse chronological order
|
/// Default is to load in reverse chronological order
|
||||||
/// TODO this is not exposed on the client as it probably wouldn't work that well
|
/// TODO this is not exposed on the client as it probably wouldn't work that well
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
|
public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum GetPostsDirection
|
public enum GetPostsDirection
|
|
@ -1,9 +1,9 @@
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto;
|
using Femto.Common.Infrastructure.DbConnection;
|
||||||
using Femto.Modules.Blog.Infrastructure.DbConnection;
|
using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts;
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts;
|
||||||
|
|
||||||
public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
: IRequestHandler<GetPostsQuery, GetPostsQueryResult>
|
: IRequestHandler<GetPostsQuery, GetPostsQueryResult>
|
||||||
|
@ -15,12 +15,6 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
{
|
{
|
||||||
using var conn = connectionFactory.GetConnection();
|
using var conn = connectionFactory.GetConnection();
|
||||||
|
|
||||||
if (query.Username is not null && query.AuthorGuid is not null)
|
|
||||||
throw new ArgumentException(
|
|
||||||
"Cannot specify both username and authorGuid",
|
|
||||||
nameof(query)
|
|
||||||
);
|
|
||||||
|
|
||||||
var orderBy = query.Direction is GetPostsDirection.Backward ? "desc" : "asc";
|
var orderBy = query.Direction is GetPostsDirection.Backward ? "desc" : "asc";
|
||||||
var pageFilter = query.Direction is GetPostsDirection.Backward ? "<=" : ">=";
|
var pageFilter = query.Direction is GetPostsDirection.Backward ? "<=" : ">=";
|
||||||
|
|
||||||
|
@ -40,6 +34,8 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
page.id as PostId,
|
page.id as PostId,
|
||||||
page.content as Content,
|
page.content as Content,
|
||||||
blog.post_media.url as MediaUrl,
|
blog.post_media.url as MediaUrl,
|
||||||
|
blog.post_media.width as MediaWidth,
|
||||||
|
blog.post_media.height as MediaHeight,
|
||||||
page.posted_on as PostedOn,
|
page.posted_on as PostedOn,
|
||||||
page.Username,
|
page.Username,
|
||||||
page.AuthorId
|
page.AuthorId
|
||||||
|
@ -52,8 +48,8 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
sql,
|
sql,
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
username = query.Username,
|
username = query.Author,
|
||||||
authorGuid = query.AuthorGuid,
|
authorGuid = query.AuthorId,
|
||||||
cursor = query.From,
|
cursor = query.From,
|
||||||
// load an extra one to take for the curst
|
// load an extra one to take for the curst
|
||||||
amount = query.Amount + 1,
|
amount = query.Amount + 1,
|
||||||
|
@ -68,9 +64,20 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
var postId = group.Key;
|
var postId = group.Key;
|
||||||
var post = group.First();
|
var post = group.First();
|
||||||
var media = group
|
var media = group
|
||||||
.Select(row => row.MediaUrl)
|
.Select(row =>
|
||||||
.OfType<string>()
|
{
|
||||||
.Select(url => new PostMediaDto(new Uri(url)))
|
if (row.MediaUrl is not null)
|
||||||
|
{
|
||||||
|
return new PostMediaDto(
|
||||||
|
new Uri(row.MediaUrl),
|
||||||
|
row.MediaHeight,
|
||||||
|
row.MediaHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.OfType<PostMediaDto>()
|
||||||
.ToList();
|
.ToList();
|
||||||
return new PostDto(
|
return new PostDto(
|
||||||
postId,
|
postId,
|
||||||
|
@ -92,6 +99,9 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
public Guid PostId { get; set; }
|
public Guid PostId { get; set; }
|
||||||
public string Content { get; set; }
|
public string Content { get; set; }
|
||||||
public string? MediaUrl { get; set; }
|
public string? MediaUrl { get; set; }
|
||||||
|
public string? MediaType { get; set; }
|
||||||
|
public int? MediaWidth { get; set; }
|
||||||
|
public int? MediaHeight { get; set; }
|
||||||
public DateTimeOffset PostedOn { get; set; }
|
public DateTimeOffset PostedOn { get; set; }
|
||||||
public Guid AuthorId { get; set; }
|
public Guid AuthorId { get; set; }
|
||||||
public string Username { get; set; }
|
public string Username { get; set; }
|
|
@ -1,7 +0,0 @@
|
||||||
namespace Femto.Modules.Blog.Domain.Authors;
|
|
||||||
|
|
||||||
internal class Author
|
|
||||||
{
|
|
||||||
public string Id { get; private set; } = null!;
|
|
||||||
public string Name { get; private set; } = null!;
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Domain.Posts.Commands.CreatePost;
|
|
||||||
|
|
||||||
public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable<Uri> Media)
|
|
||||||
: IRequest<Guid>;
|
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto;
|
|
||||||
|
|
||||||
public record PostAuthorDto(Guid AuthorId, string Username);
|
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Modules.Blog.Domain.Posts.Commands.GetPosts.Dto;
|
|
||||||
|
|
||||||
public record PostMediaDto(Uri Url);
|
|
|
@ -1,20 +0,0 @@
|
||||||
using Femto.Modules.Blog.Contracts.Events;
|
|
||||||
using Femto.Modules.Blog.Domain.Posts.Events;
|
|
||||||
using Femto.Modules.Blog.Infrastructure.Integration;
|
|
||||||
using Femto.Modules.Blog.Infrastructure.Integration.Outbox;
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,14 +4,20 @@ internal class PostMedia
|
||||||
{
|
{
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Uri Url { get; private set; }
|
public Uri Url { get; private set; }
|
||||||
|
public string? Type { get; private set; }
|
||||||
public int Ordering { get; private set; }
|
public int Ordering { get; private set; }
|
||||||
|
public int? Width { get; private set; }
|
||||||
|
public int? Height { get; private set; }
|
||||||
|
|
||||||
private PostMedia() {}
|
private PostMedia() {}
|
||||||
|
|
||||||
public PostMedia(Guid id, Uri url, int ordering)
|
public PostMedia(Guid id, Uri url, string type, int ordering, int? width, int? height)
|
||||||
{
|
{
|
||||||
this.Id = id;
|
this.Id = id;
|
||||||
this.Url = url;
|
this.Url = url;
|
||||||
|
this.Type = type;
|
||||||
this.Ordering = ordering;
|
this.Ordering = ordering;
|
||||||
|
this.Width = width;
|
||||||
|
this.Height = height;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
using Femto.Common.Attributes;
|
using Femto.Common.Attributes;
|
||||||
using Femto.Common.Integration;
|
using Femto.Common.Integration;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Contracts.Events;
|
namespace Femto.Modules.Blog.Events;
|
||||||
|
|
||||||
[EventType("post.created")]
|
[EventType("post.created")]
|
||||||
public record PostCreatedIntegrationEvent(Guid EventId, Guid PostId, IEnumerable<Guid> MediaIds)
|
public record PostCreatedIntegrationEvent(Guid EventId, Guid PostId, IEnumerable<Guid> MediaIds)
|
|
@ -9,12 +9,15 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Autofac" Version="8.3.0" />
|
||||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
|
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
||||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||||
|
@ -41,7 +44,6 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
|
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
|
||||||
<ProjectReference Include="..\Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
using Femto.Modules.Blog.Contracts.Events;
|
using Femto.Modules.Blog.Events;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Handlers;
|
namespace Femto.Modules.Blog.Handlers;
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
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.Outbox;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Immutable;
|
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Infrastructure.Integration.Outbox;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,92 +0,0 @@
|
||||||
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.Integration.Outbox;
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
9
Femto.Modules.Media/Application/IMediaModule.cs
Normal file
9
Femto.Modules.Media/Application/IMediaModule.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Application;
|
||||||
|
|
||||||
|
public interface IMediaModule
|
||||||
|
{
|
||||||
|
Task<TResult> PostCommand<TResult>(ICommand<TResult> command);
|
||||||
|
Task<TResult> PostQuery<TResult>(IQuery<TResult> query);
|
||||||
|
}
|
11
Femto.Modules.Media/Application/MediaApplication.cs
Normal file
11
Femto.Modules.Media/Application/MediaApplication.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Application;
|
||||||
|
|
||||||
|
public class MediaApplication(IHost host) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await host.RunAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
25
Femto.Modules.Media/Application/MediaModule.cs
Normal file
25
Femto.Modules.Media/Application/MediaModule.cs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Application;
|
||||||
|
|
||||||
|
public class MediaModule(IHost host) : IMediaModule
|
||||||
|
{
|
||||||
|
public async Task<TResult> PostCommand<TResult>(ICommand<TResult> command)
|
||||||
|
{
|
||||||
|
using var scope = host.Services.CreateScope();
|
||||||
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
|
var response = await mediator.Send(command);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TResult> PostQuery<TResult>(IQuery<TResult> query)
|
||||||
|
{
|
||||||
|
using var scope = host.Services.CreateScope();
|
||||||
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
|
var response = await mediator.Send(query);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
30
Femto.Modules.Media/Application/Startup.cs
Normal file
30
Femto.Modules.Media/Application/Startup.cs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
using Femto.Modules.Media.Data;
|
||||||
|
using Femto.Modules.Media.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Media.Application;
|
||||||
|
|
||||||
|
public static class Startup
|
||||||
|
{
|
||||||
|
public static void InitializeMediaModule(this IServiceCollection rootContainer, string connectionString, string storageRoot)
|
||||||
|
{
|
||||||
|
var hostBuilder = Host.CreateDefaultBuilder();
|
||||||
|
|
||||||
|
hostBuilder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.AddDbContext<MediaContext>(builder =>
|
||||||
|
{
|
||||||
|
builder.UseNpgsql(connectionString);
|
||||||
|
builder.UseSnakeCaseNamingConvention();
|
||||||
|
});
|
||||||
|
services.AddTransient<IStorageProvider>(s => new FilesystemStorageProvider(storageRoot));
|
||||||
|
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(Startup).Assembly));
|
||||||
|
});
|
||||||
|
|
||||||
|
var host = hostBuilder.Build();
|
||||||
|
|
||||||
|
rootContainer.AddTransient<IMediaModule>(_ => new MediaModule(host));
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,9 +20,11 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Autofac" Version="8.3.0" />
|
||||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
using Femto.Modules.Media.Data;
|
|
||||||
using Femto.Modules.Media.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Femto.Modules.Media;
|
|
||||||
|
|
||||||
public static class Module
|
|
||||||
{
|
|
||||||
public static void UseMediaModule(this IServiceCollection services, string connectionString, string storageRoot)
|
|
||||||
{
|
|
||||||
services.AddDbContext<MediaContext>(builder =>
|
|
||||||
{
|
|
||||||
builder.UseNpgsql(connectionString);
|
|
||||||
builder.UseSnakeCaseNamingConvention();
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddTransient<IStorageProvider>(s => new FilesystemStorageProvider(storageRoot));
|
|
||||||
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(Module).Assembly));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -10,10 +10,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Docs", "Femto.Docs\Fe
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Common", "Femto.Common\Femto.Common.csproj", "{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Common", "Femto.Common\Femto.Common.csproj", "{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}"
|
||||||
EndProject
|
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
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Media", "Femto.Modules.Media\Femto.Modules.Media.csproj", "{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Media", "Femto.Modules.Media\Femto.Modules.Media.csproj", "{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Femto.Modules.Authentication", "Femto.Modules.Authentication\Femto.Modules.Authentication.csproj", "{7E138EF6-E075-4896-93C0-923024F0CA78}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
@ -40,14 +40,14 @@ Global
|
||||||
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{52A086BD-AF2F-463F-A6A9-5FC1343C0E28}.Release|Any CPU.Build.0 = 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
|
|
||||||
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Release|Any CPU.Build.0 = Release|Any CPU
|
{AC9FBF11-FF29-4A80-B9EA-AFDF1E3DCA80}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7E138EF6-E075-4896-93C0-923024F0CA78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{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
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue