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.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Todo.Core.Interfaces.Persistence;
@ -17,8 +18,14 @@ public class TodosController : ControllerBase
}
[HttpPost]
public async Task<ActionResult<Core.Entities.Todo>> CreateTodo([FromBody] CreateTodoRequest request) =>
Ok(await _todoRepository.CreateTodoAsync(request.Title, String.Empty));
[Authorize]
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]
[Authorize]

View File

@ -1,11 +1,15 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using System.Text.Json;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
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.User;
using Todo.Infrastructure;
namespace Todo.Api.Hubs;
@ -13,85 +17,59 @@ namespace Todo.Api.Hubs;
public class TodoHub : Hub
{
private readonly ITodoRepository _todoRepository;
private static readonly ConcurrentDictionary<string, List<string>> ConnectedUsers = new();
private readonly IUserConnectionStore _userConnectionStore;
private readonly ICurrentUserService _currentUserService;
private readonly IMediator _mediator;
public override Task OnConnectedAsync()
{
var userId = Context.User.FindFirstValue("sub");
// 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(Context.ConnectionId);
// Add to the global dictionary of connected users
ConnectedUsers.TryAdd(userId, existingUserConnectionIds);
var userId = _currentUserService.GetUserId();
if (userId is not null)
_userConnectionStore.AddAsync(userId, Context.ConnectionId);
return base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.User.FindFirstValue("sub");
ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds);
// 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 _);
}
var userId = _currentUserService.GetUserId();
if (userId is not null)
_userConnectionStore.RemoveAsync(userId, Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
public TodoHub(ITodoRepository todoRepository)
public TodoHub(
ITodoRepository todoRepository,
IUserConnectionStore userConnectionStore,
ICurrentUserService currentUserService,
IMediator mediator)
{
_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)
throw new ArgumentException("title cannot be null");
var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName);
var todos = await _todoRepository.GetNotDoneTodos();
var serializedTodos =
JsonSerializer.Serialize(todos
.Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Project = t.Project })
.ToList());
//var userId = GetUserId();
await RunOnUserConnections(async (connections) =>
await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos));
//var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName, userId);
await _mediator.Send(new CreateTodoCommand(todoTitle, projectName));
await GetInboxTodos();
}
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();
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));
await GetInboxTodos();
}
public async Task GetTodos()
@ -157,17 +135,15 @@ public class TodoHub : Hub
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");
if (userId is null)
throw new InvalidOperationException("User id was invalid. Something has gone terribly wrong");
var userId = GetUserId();
var connections = await _userConnectionStore.GetConnectionsAsync(userId);
ConnectedUsers.TryGetValue(userId, out var connections);
if (connections is not null)
action(connections);
return Task.CompletedTask;
await action(connections);
}
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.OpenApi.Models;
using Todo.Api.Hubs;
using Todo.Api.Publishers;
using Todo.Api.Services;
using Todo.Core.Interfaces.User;
using Todo.Infrastructure;
using Todo.Persistence;
using Todo.Persistence.Mongo;
using Todo.Core;
using Todo.Core.Interfaces.Publisher;
namespace Todo.Api
{
@ -37,6 +42,10 @@ namespace Todo.Api
.AllowAnyMethod());
});
services.AddCore();
services.AddScoped<ICurrentUserService, HttpContextCurrentUserService>();
services.AddScoped<ITodoPublisher, TodoPublisher>();
services.AddSwaggerGen(c =>
{
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.Threading.Tasks;
global using System.Collections.Generic;
using System.Reflection;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace Todo.Core;
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
{
Task<Entities.Todo> CreateTodoAsync(string title, string projectName);
Task<Entities.Todo> CreateTodoAsync(string title, string projectName, string userId);
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<Entities.Todo> GetTodoByIdAsync(string todoId);
Task<Entities.Todo> UpdateTodoAsync(Entities.Todo todo);

View File

@ -4,5 +4,5 @@ namespace Todo.Core.Interfaces.Persistence;
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>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</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>

View File

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

View File

@ -13,5 +13,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Todo.Core\Todo.Core.csproj" />
</ItemGroup>
</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 bool Status { get; set; }
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");
}
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);
return new Core.Entities.Todo()
{ 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 });
}
public async Task UpdateTodoStatus(string todoId, bool todoStatus)
public async Task UpdateTodoStatus(string todoId, bool todoStatus, string userId)
{
await _todosCollection
.UpdateOneAsync(t => t.Id == todoId,
.UpdateOneAsync(t => t.Id == todoId && t.AuthorId == userId,
Builders<MongoTodo>.Update.Set(t => t.Status, todoStatus));
}

View File

@ -11,7 +11,7 @@ export const TodoCheckmark: FC<TodoCheckmarkProps> = (props) => (
onClick={() =>
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
? "bg-gray-300 dark:bg-gray-500"
: "hover:bg-gray-200 hover:dark:bg-gray-600"