Add mediator
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing

This commit is contained in:
Kasper Juul Hermansen 2021-11-17 22:11:55 +01:00
parent 3b446fa885
commit 9c5eaf2644
Signed by: kjuulh
GPG Key ID: DCD9397082D97069
20 changed files with 287 additions and 74 deletions

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Todo.Core.Interfaces.Persistence; using Todo.Core.Interfaces.Persistence;
@ -17,8 +18,14 @@ public class TodosController : ControllerBase
} }
[HttpPost] [HttpPost]
public async Task<ActionResult<Core.Entities.Todo>> CreateTodo([FromBody] CreateTodoRequest request) => [Authorize]
Ok(await _todoRepository.CreateTodoAsync(request.Title, String.Empty)); public async Task<ActionResult<Core.Entities.Todo>> CreateTodo([FromBody] CreateTodoRequest request)
{
var userId = User.FindFirstValue("sub") ??
throw new InvalidOperationException("Could not get user, something has gone terribly wrong");
return Ok(await _todoRepository.CreateTodoAsync(request.Title, String.Empty, userId));
}
[HttpGet] [HttpGet]
[Authorize] [Authorize]

View File

@ -1,11 +1,15 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Todo.Api.Hubs.Models; using Todo.Api.Hubs.Models;
using Todo.Core.Application.Commands.Todo;
using Todo.Core.Application.Services.UserConnectionStore;
using Todo.Core.Interfaces.Persistence; using Todo.Core.Interfaces.Persistence;
using Todo.Core.Interfaces.User;
using Todo.Infrastructure;
namespace Todo.Api.Hubs; namespace Todo.Api.Hubs;
@ -13,85 +17,59 @@ namespace Todo.Api.Hubs;
public class TodoHub : Hub public class TodoHub : Hub
{ {
private readonly ITodoRepository _todoRepository; private readonly ITodoRepository _todoRepository;
private readonly IUserConnectionStore _userConnectionStore;
private static readonly ConcurrentDictionary<string, List<string>> ConnectedUsers = new(); private readonly ICurrentUserService _currentUserService;
private readonly IMediator _mediator;
public override Task OnConnectedAsync() public override Task OnConnectedAsync()
{ {
var userId = Context.User.FindFirstValue("sub"); var userId = _currentUserService.GetUserId();
if (userId is not null)
// Try to get a List of existing user connections from the cache _userConnectionStore.AddAsync(userId, Context.ConnectionId);
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds);
// happens on the very first connection from the user
existingUserConnectionIds ??= new List<string>();
// First add to a List of existing user connections (i.e. multiple web browser tabs)
existingUserConnectionIds.Add(Context.ConnectionId);
// Add to the global dictionary of connected users
ConnectedUsers.TryAdd(userId, existingUserConnectionIds);
return base.OnConnectedAsync(); return base.OnConnectedAsync();
} }
public override Task OnDisconnectedAsync(Exception? exception) public override Task OnDisconnectedAsync(Exception? exception)
{ {
var userId = Context.User.FindFirstValue("sub"); var userId = _currentUserService.GetUserId();
if (userId is not null)
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds); _userConnectionStore.RemoveAsync(userId, Context.ConnectionId);
// remove the connection id from the List
existingUserConnectionIds?.Remove(Context.ConnectionId);
// If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers).
if (existingUserConnectionIds?.Count == 0)
{
// if there are no connections for the user,
// just delete the userName key from the ConnectedUsers concurent dictionary
ConnectedUsers.TryRemove(userId, out _);
}
return base.OnDisconnectedAsync(exception); return base.OnDisconnectedAsync(exception);
} }
public TodoHub(
public TodoHub(ITodoRepository todoRepository) ITodoRepository todoRepository,
IUserConnectionStore userConnectionStore,
ICurrentUserService currentUserService,
IMediator mediator)
{ {
_todoRepository = todoRepository; _todoRepository = todoRepository;
_userConnectionStore = userConnectionStore;
_currentUserService = currentUserService;
_mediator = mediator;
} }
public async Task CreateTodo(string todoTitle, string projectName) public async Task CreateTodo(string todoTitle, string? projectName)
{ {
if (todoTitle is null) if (todoTitle is null)
throw new ArgumentException("title cannot be null"); throw new ArgumentException("title cannot be null");
var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName);
var todos = await _todoRepository.GetNotDoneTodos(); //var userId = GetUserId();
var serializedTodos =
JsonSerializer.Serialize(todos
.Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Project = t.Project })
.ToList());
await RunOnUserConnections(async (connections) => //var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName, userId);
await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos)); await _mediator.Send(new CreateTodoCommand(todoTitle, projectName));
await GetInboxTodos();
} }
public async Task UpdateTodo(string todoId, bool todoStatus) public async Task UpdateTodo(string todoId, bool todoStatus)
{ {
await _todoRepository.UpdateTodoStatus(todoId, todoStatus); var userId = GetUserId();
await _todoRepository.UpdateTodoStatus(todoId, todoStatus, userId);
var todos = await _todoRepository.GetNotDoneTodos(); await GetInboxTodos();
var serializedTodos =
JsonSerializer.Serialize(todos
.Select(t => new TodoResponse
{ Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project })
.ToList());
await RunOnUserConnections(async (connections) =>
await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos));
} }
public async Task GetTodos() public async Task GetTodos()
@ -157,17 +135,15 @@ public class TodoHub : Hub
await Clients.Clients(connections).SendAsync("getTodo", serializedTodo)); await Clients.Clients(connections).SendAsync("getTodo", serializedTodo));
} }
private Task RunOnUserConnections(Func<IEnumerable<string>, Task> action) private async Task RunOnUserConnections(Func<IEnumerable<string>, Task> action)
{ {
var userId = Context.User.FindFirstValue("sub"); var userId = GetUserId();
if (userId is null) var connections = await _userConnectionStore.GetConnectionsAsync(userId);
throw new InvalidOperationException("User id was invalid. Something has gone terribly wrong");
ConnectedUsers.TryGetValue(userId, out var connections); await action(connections);
if (connections is not null)
action(connections);
return Task.CompletedTask;
} }
private string GetUserId() =>
_currentUserService.GetUserId() ??
throw new InvalidOperationException("User id was invalid. Something has gone terribly wrong");
} }

View File

@ -0,0 +1,42 @@
using System.Threading;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Todo.Api.Hubs;
using Todo.Core.Application.Services.UserConnectionStore;
using Todo.Core.Interfaces.Publisher;
using Todo.Core.Interfaces.User;
namespace Todo.Api.Publishers;
public class TodoPublisher : ITodoPublisher
{
private readonly IHubContext<TodoHub> _hubContext;
private readonly ICurrentUserService _currentUserService;
private readonly IUserConnectionStore _userConnectionStore;
private readonly ILogger<TodoPublisher> _logger;
public TodoPublisher(
IHubContext<TodoHub> hubContext,
ICurrentUserService currentUserService,
IUserConnectionStore userConnectionStore,
ILogger<TodoPublisher> logger)
{
_hubContext = hubContext;
_currentUserService = currentUserService;
_userConnectionStore = userConnectionStore;
_logger = logger;
}
public async Task Publish(string todoId, CancellationToken cancellationToken)
{
var userId = _currentUserService.GetUserId() ?? throw new InvalidOperationException("Cannot proceed without user");
var connections = await _userConnectionStore.GetConnectionsAsync(userId);
await _hubContext
.Clients
.Clients(connections)
.SendAsync("todoCreated", todoId , cancellationToken);
_logger.LogInformation("todo created {TodoId}", todoId);
}
}

View File

@ -0,0 +1,17 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Todo.Core.Interfaces.User;
namespace Todo.Api.Services;
public class HttpContextCurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpContextCurrentUserService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string? GetUserId() => _httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
}

View File

@ -8,9 +8,14 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Todo.Api.Hubs; using Todo.Api.Hubs;
using Todo.Api.Publishers;
using Todo.Api.Services;
using Todo.Core.Interfaces.User;
using Todo.Infrastructure; using Todo.Infrastructure;
using Todo.Persistence; using Todo.Persistence;
using Todo.Persistence.Mongo; using Todo.Persistence.Mongo;
using Todo.Core;
using Todo.Core.Interfaces.Publisher;
namespace Todo.Api namespace Todo.Api
{ {
@ -37,6 +42,10 @@ namespace Todo.Api
.AllowAnyMethod()); .AllowAnyMethod());
}); });
services.AddCore();
services.AddScoped<ICurrentUserService, HttpContextCurrentUserService>();
services.AddScoped<ITodoPublisher, TodoPublisher>();
services.AddSwaggerGen(c => services.AddSwaggerGen(c =>
{ {
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Todo.Api", Version = "v1" }); c.SwaggerDoc("v1", new OpenApiInfo { Title = "Todo.Api", Version = "v1" });

View File

@ -0,0 +1,37 @@
using System.Threading;
using MediatR;
using Todo.Core.Application.Notifications.Todo;
using Todo.Core.Interfaces.Persistence;
using Todo.Core.Interfaces.User;
namespace Todo.Core.Application.Commands.Todo;
public record CreateTodoCommand(string TodoTitle, string? TodoProject) : IRequest<string>
{
internal class Handler : IRequestHandler<CreateTodoCommand, string>
{
private readonly ICurrentUserService _currentUserService;
private readonly ITodoRepository _todoRepository;
private readonly IMediator _mediator;
public Handler(ICurrentUserService currentUserService, ITodoRepository todoRepository, IMediator mediator)
{
_currentUserService = currentUserService;
_todoRepository = todoRepository;
_mediator = mediator;
}
public async Task<string> Handle(CreateTodoCommand request, CancellationToken cancellationToken)
{
var userId = _currentUserService.GetUserId();
if (userId is null)
throw new InvalidOperationException("User was not found");
var todo = await _todoRepository.CreateTodoAsync(request.TodoTitle, request.TodoProject, userId);
await _mediator.Publish(new TodoCreated(todo.Id), cancellationToken);
return todo.Id;
}
}
}

View File

@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Todo.Core.Application.Services.UserConnectionStore;
using Todo.Core.Interfaces.Publisher;
using Todo.Core.Interfaces.User;
namespace Todo.Core.Application.Notifications.Todo;
public record TodoCreated([property: JsonPropertyName("todoId")] string TodoId) : INotification
{
internal class Handler : INotificationHandler<TodoCreated>
{
private readonly ITodoPublisher _todoPublisher;
public Handler(ITodoPublisher todoPublisher)
{
_todoPublisher = todoPublisher;
}
public async Task Handle(TodoCreated notification, CancellationToken cancellationToken)
{
await _todoPublisher.Publish(JsonSerializer.Serialize(notification), cancellationToken);
}
}
}

View File

@ -0,0 +1,8 @@
using System.Threading;
namespace Todo.Core.Interfaces.Publisher;
public interface ITodoPublisher
{
Task Publish(string todoId, CancellationToken cancellationToken = new ());
}

View File

@ -0,0 +1,8 @@
namespace Todo.Core.Application.Services.UserConnectionStore;
public interface IUserConnectionStore
{
Task AddAsync(string userId, string connectionId);
Task RemoveAsync(string userId, string connectionId);
Task<IEnumerable<string>> GetConnectionsAsync(string userId);
}

View File

@ -2,9 +2,13 @@ global using System;
global using System.Linq; global using System.Linq;
global using System.Threading.Tasks; global using System.Threading.Tasks;
global using System.Collections.Generic; global using System.Collections.Generic;
using System.Reflection;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace Todo.Core; namespace Todo.Core;
public static class DependencyInjection public static class DependencyInjection
{ {
public static IServiceCollection AddCore(this IServiceCollection services) => services.AddMediatR(Assembly.GetExecutingAssembly());
} }

View File

@ -3,9 +3,9 @@ namespace Todo.Core.Interfaces.Persistence;
public interface ITodoRepository public interface ITodoRepository
{ {
Task<Entities.Todo> CreateTodoAsync(string title, string projectName); Task<Entities.Todo> CreateTodoAsync(string title, string projectName, string userId);
Task<IEnumerable<Entities.Todo>> GetTodosAsync(); Task<IEnumerable<Entities.Todo>> GetTodosAsync();
Task UpdateTodoStatus(string todoId, bool todoStatus); Task UpdateTodoStatus(string todoId, bool todoStatus, string userId);
Task<IEnumerable<Entities.Todo>> GetNotDoneTodos(); Task<IEnumerable<Entities.Todo>> GetNotDoneTodos();
Task<Entities.Todo> GetTodoByIdAsync(string todoId); Task<Entities.Todo> GetTodoByIdAsync(string todoId);
Task<Entities.Todo> UpdateTodoAsync(Entities.Todo todo); Task<Entities.Todo> UpdateTodoAsync(Entities.Todo todo);

View File

@ -4,5 +4,5 @@ namespace Todo.Core.Interfaces.Persistence;
public interface IUserRepository public interface IUserRepository
{ {
Task<User> Register(string email, string password); Task<Entities.User> Register(string email, string password);
} }

View File

@ -0,0 +1,6 @@
namespace Todo.Core.Interfaces.User;
public interface ICurrentUserService
{
string? GetUserId();
}

View File

@ -2,6 +2,20 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<Folder Include="Application\Services" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Mediatr" Version="9.0.0" />
<PackageReference Include="Mediatr.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
</ItemGroup>
</Project> </Project>

View File

@ -3,7 +3,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Todo.Core.Application.Services.UserConnectionStore;
using Todo.Infrastructure.UserConnectionStore;
namespace Todo.Infrastructure; namespace Todo.Infrastructure;
@ -20,6 +21,7 @@ public static class DependencyInjection
.Bind(giteaOptions) .Bind(giteaOptions)
.ValidateDataAnnotations(); .ValidateDataAnnotations();
services.AddSingleton<IUserConnectionStore, InMemoryUserConnectionStore>();
return services.AddAuthentication(options => return services.AddAuthentication(options =>
{ {

View File

@ -13,5 +13,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Todo.Core\Todo.Core.csproj" />
</ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,50 @@
using System.Collections.Concurrent;
using Todo.Core.Application.Services.UserConnectionStore;
namespace Todo.Infrastructure.UserConnectionStore;
class InMemoryUserConnectionStore : IUserConnectionStore
{
private static readonly ConcurrentDictionary<string, List<string>> ConnectedUsers = new();
public Task AddAsync(string userId, string connectionId)
{
// Try to get a List of existing user connections from the cache
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds);
// happens on the very first connection from the user
existingUserConnectionIds ??= new List<string>();
// First add to a List of existing user connections (i.e. multiple web browser tabs)
existingUserConnectionIds.Add(connectionId);
// Add to the global dictionary of connected users
ConnectedUsers.TryAdd(userId, existingUserConnectionIds);
return Task.CompletedTask;
}
public Task<IEnumerable<string>> GetConnectionsAsync(string userId)
{
ConnectedUsers.TryGetValue(userId, out var connections);
return Task.FromResult(connections is null ? new List<string>().AsEnumerable() : connections.AsEnumerable());
}
public Task RemoveAsync(string userId, string connectionId)
{
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds);
// remove the connection id from the List
existingUserConnectionIds?.Remove(connectionId);
// If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers).
if (existingUserConnectionIds?.Count == 0)
{
// if there are no connections for the user,
// just delete the userName key from the ConnectedUsers concurent dictionary
ConnectedUsers.TryRemove(userId, out _);
}
return Task.CompletedTask;
}
}

View File

@ -12,4 +12,5 @@ public record MongoTodo
[BsonRequired] public string Title { get; init; } [BsonRequired] public string Title { get; init; }
[BsonRequired] public bool Status { get; set; } [BsonRequired] public bool Status { get; set; }
public string ProjectName { get; set; } = String.Empty; public string ProjectName { get; set; } = String.Empty;
public string AuthorId { get; set; }
} }

View File

@ -15,9 +15,9 @@ public class TodoRepository : ITodoRepository
_todosCollection = database.GetCollection<MongoTodo>("todos"); _todosCollection = database.GetCollection<MongoTodo>("todos");
} }
public async Task<Core.Entities.Todo> CreateTodoAsync(string title, string projectName) public async Task<Core.Entities.Todo> CreateTodoAsync(string title, string projectName, string userId)
{ {
var todo = new MongoTodo() { Title = title, ProjectName = projectName }; var todo = new MongoTodo() { Title = title, ProjectName = projectName, AuthorId = userId };
await _todosCollection.InsertOneAsync(todo); await _todosCollection.InsertOneAsync(todo);
return new Core.Entities.Todo() return new Core.Entities.Todo()
{ Id = todo.Id, Title = todo.Title, Status = false, Project = todo.ProjectName }; { Id = todo.Id, Title = todo.Title, Status = false, Project = todo.ProjectName };
@ -32,10 +32,10 @@ public class TodoRepository : ITodoRepository
new Core.Entities.Todo() { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.ProjectName }); new Core.Entities.Todo() { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.ProjectName });
} }
public async Task UpdateTodoStatus(string todoId, bool todoStatus) public async Task UpdateTodoStatus(string todoId, bool todoStatus, string userId)
{ {
await _todosCollection await _todosCollection
.UpdateOneAsync(t => t.Id == todoId, .UpdateOneAsync(t => t.Id == todoId && t.AuthorId == userId,
Builders<MongoTodo>.Update.Set(t => t.Status, todoStatus)); Builders<MongoTodo>.Update.Set(t => t.Status, todoStatus));
} }

View File

@ -11,7 +11,7 @@ export const TodoCheckmark: FC<TodoCheckmarkProps> = (props) => (
onClick={() => onClick={() =>
props.updateTodo({ ...props.todo, status: !props.todo.status }) props.updateTodo({ ...props.todo, status: !props.todo.status })
} }
className={`todo-checkmark h-5 w-5 rounded-full border dark:border-gray-500 ${ className={`todo-checkmark h-5 w-5 rounded-full border dark:border-gray-500 cursor-pointer ${
props.todo.status === StatusState.done props.todo.status === StatusState.done
? "bg-gray-300 dark:bg-gray-500" ? "bg-gray-300 dark:bg-gray-500"
: "hover:bg-gray-200 hover:dark:bg-gray-600" : "hover:bg-gray-200 hover:dark:bg-gray-600"