diff --git a/.gitignore b/.gitignore index 26a90679d..9a57e89b3 100644 --- a/.gitignore +++ b/.gitignore @@ -490,3 +490,4 @@ $RECYCLE.BIN/ *.db *.db-shm *.db-wal +TestProject/ diff --git a/README-template.md b/README-template.md index 0a4c72c80..083d6acf0 100644 --- a/README-template.md +++ b/README-template.md @@ -16,6 +16,10 @@ dotnet run --project .\src\AppHost The Aspire dashboard will open automatically, showing the application URLs and logs. +## Auth & API features + +The template includes built-in authentication endpoints for user registration, login, logout, and profile retrieval. It also supports paged Todo list queries with optional search, colour, and priority filtering. + ## Code Styles & Formatting The template includes [EditorConfig](https://editorconfig.org/) support to help maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The **.editorconfig** file defines the coding styles applicable to this solution. diff --git a/src/Application/Common/Interfaces/ICacheService.cs b/src/Application/Common/Interfaces/ICacheService.cs new file mode 100644 index 000000000..51cdfe0d2 --- /dev/null +++ b/src/Application/Common/Interfaces/ICacheService.cs @@ -0,0 +1,7 @@ +namespace CleanArchitecture.Application.Common.Interfaces; + +public interface ICacheService +{ + Task GetOrCreateAsync(string key, Func> createItem, TimeSpan? absoluteExpirationRelativeToNow = null); + void Remove(string key); +} diff --git a/src/Application/Common/Interfaces/ISpecification.cs b/src/Application/Common/Interfaces/ISpecification.cs new file mode 100644 index 000000000..843b69431 --- /dev/null +++ b/src/Application/Common/Interfaces/ISpecification.cs @@ -0,0 +1,15 @@ +using System.Linq.Expressions; + +namespace CleanArchitecture.Application.Common.Interfaces; + +public interface ISpecification where T : class +{ + Expression>? Criteria { get; } + List>> Includes { get; } + List IncludeStrings { get; } + Expression>? OrderBy { get; } + Expression>? OrderByDescending { get; } + int? Take { get; } + int? Skip { get; } + bool IsPagingEnabled { get; } +} diff --git a/src/Application/Common/Models/PaginatedList.cs b/src/Application/Common/Models/PaginatedList.cs new file mode 100644 index 000000000..1c4b8c21f --- /dev/null +++ b/src/Application/Common/Models/PaginatedList.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; + +namespace CleanArchitecture.Application.Common.Models; + +public sealed class PaginatedList +{ + public PaginatedList(IReadOnlyCollection items, int totalCount, int pageIndex, int pageSize) + { + Items = items; + TotalCount = totalCount; + PageIndex = pageIndex; + PageSize = pageSize; + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + } + + public IReadOnlyCollection Items { get; } + + public int PageIndex { get; } + + public int PageSize { get; } + + public int TotalCount { get; } + + public int TotalPages { get; } + + public bool HasPreviousPage => PageIndex > 1; + + public bool HasNextPage => PageIndex < TotalPages; +} + +public static class PaginatedListExtensions +{ + public static async Task> ToPaginatedListAsync(this IQueryable source, int pageIndex, int pageSize, CancellationToken cancellationToken = default) + { + var totalCount = await source.CountAsync(cancellationToken); + var items = await source.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken); + + return new PaginatedList(items, totalCount, pageIndex, pageSize); + } +} diff --git a/src/Application/Common/Specifications/Specification.cs b/src/Application/Common/Specifications/Specification.cs new file mode 100644 index 000000000..4302223f3 --- /dev/null +++ b/src/Application/Common/Specifications/Specification.cs @@ -0,0 +1,58 @@ +using System.Linq.Expressions; + +namespace CleanArchitecture.Application.Common.Specifications; + +public abstract class Specification : ISpecification where T : class +{ + protected Specification() + { + } + + protected Specification(Expression> criteria) + { + Criteria = criteria; + } + + public Expression>? Criteria { get; protected set; } + + public List>> Includes { get; } = []; + + public List IncludeStrings { get; } = []; + + public Expression>? OrderBy { get; protected set; } + + public Expression>? OrderByDescending { get; protected set; } + + public int? Take { get; protected set; } + + public int? Skip { get; protected set; } + + public bool IsPagingEnabled { get; protected set; } + + protected virtual void AddInclude(Expression> includeExpression) + { + Includes.Add(includeExpression); + } + + protected virtual void AddInclude(string includeString) + { + IncludeStrings.Add(includeString); + } + + public virtual void ApplyPaging(int skip, int take) + { + Skip = skip; + Take = take; + IsPagingEnabled = true; + } + + protected virtual void ApplyOrderBy(Expression> orderByExpression) + { + OrderBy = orderByExpression; + } + + protected virtual void ApplyOrderByDescending(Expression> orderByDescendingExpression) + { + OrderByDescending = orderByDescendingExpression; + } +} diff --git a/src/Application/Common/Specifications/SpecificationEvaluator.cs b/src/Application/Common/Specifications/SpecificationEvaluator.cs new file mode 100644 index 000000000..de133804c --- /dev/null +++ b/src/Application/Common/Specifications/SpecificationEvaluator.cs @@ -0,0 +1,47 @@ +namespace CleanArchitecture.Application.Common.Specifications; + +public class SpecificationEvaluator where T : class +{ + public static IQueryable GetQuery(IQueryable inputQuery, ISpecification specification) + { + var query = inputQuery; + + // Apply criteria + if (specification.Criteria != null) + { + query = query.Where(specification.Criteria); + } + + // Apply includes + query = specification.Includes.Aggregate(query, (current, include) => current.Include(include)); + + // Apply string-based includes + query = specification.IncludeStrings.Aggregate(query, (current, include) => current.Include(include)); + + // Apply ordering + if (specification.OrderBy != null) + { + query = query.OrderBy(specification.OrderBy); + } + else if (specification.OrderByDescending != null) + { + query = query.OrderByDescending(specification.OrderByDescending); + } + + // Apply paging + if (specification.IsPagingEnabled) + { + if (specification.Skip.HasValue) + { + query = query.Skip(specification.Skip.Value); + } + + if (specification.Take.HasValue) + { + query = query.Take(specification.Take.Value); + } + } + + return query; + } +} diff --git a/src/Application/Common/Utilities/PredicateBuilder.cs b/src/Application/Common/Utilities/PredicateBuilder.cs new file mode 100644 index 000000000..f1826f163 --- /dev/null +++ b/src/Application/Common/Utilities/PredicateBuilder.cs @@ -0,0 +1,50 @@ +using System.Linq.Expressions; + +namespace CleanArchitecture.Application.Common.Utilities; + +public static class PredicateBuilder +{ + public static Expression> True() => _ => true; + + public static Expression> False() => _ => false; + + public static Expression> And( + this Expression> expr1, + Expression> expr2) + { + var parameter = Expression.Parameter(typeof(T)); + + var leftVisitor = new ReplaceExpressionVisitor(expr1.Parameters[0], parameter); + var left = leftVisitor.Visit(expr1.Body); + + var rightVisitor = new ReplaceExpressionVisitor(expr2.Parameters[0], parameter); + var right = rightVisitor.Visit(expr2.Body); + + return Expression.Lambda>( + Expression.AndAlso(left ?? throw new InvalidOperationException(), right ?? throw new InvalidOperationException()), parameter); + } + + public static Expression> Or( + this Expression> expr1, + Expression> expr2) + { + var parameter = Expression.Parameter(typeof(T)); + + var leftVisitor = new ReplaceExpressionVisitor(expr1.Parameters[0], parameter); + var left = leftVisitor.Visit(expr1.Body); + + var rightVisitor = new ReplaceExpressionVisitor(expr2.Parameters[0], parameter); + var right = rightVisitor.Visit(expr2.Body); + + return Expression.Lambda>( + Expression.OrElse(left ?? throw new InvalidOperationException(), right ?? throw new InvalidOperationException()), parameter); + } + + private class ReplaceExpressionVisitor(Expression oldValue, Expression newValue) : ExpressionVisitor + { + public override Expression? Visit(Expression? node) + { + return node == oldValue ? newValue : base.Visit(node); + } + } +} diff --git a/src/Application/TodoLists/Queries/GetTodos/ColourDto.cs b/src/Application/TodoLists/Queries/GetTodos/ColourDto.cs new file mode 100644 index 000000000..36fbe2675 --- /dev/null +++ b/src/Application/TodoLists/Queries/GetTodos/ColourDto.cs @@ -0,0 +1,8 @@ +namespace CleanArchitecture.Application.TodoLists.Queries.GetTodos; + +public class ColourDto +{ + public string Code { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; +} diff --git a/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs b/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs index 73e0d38f7..b3228d240 100644 --- a/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs +++ b/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs @@ -1,50 +1,93 @@ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.Common.Models; using CleanArchitecture.Application.Common.Security; +using CleanArchitecture.Application.Common.Specifications; +using CleanArchitecture.Application.TodoLists.Specifications; using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.ValueObjects; namespace CleanArchitecture.Application.TodoLists.Queries.GetTodos; [Authorize] -public record GetTodosQuery : IRequest; +public sealed class GetTodosQuery : IRequest +{ + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; + public string? Search { get; init; } + public string? Colour { get; init; } + public int? Priority { get; init; } +} public class GetTodosQueryHandler : IRequestHandler { + private const int MaxPageSize = 50; private readonly IApplicationDbContext _context; private readonly IMapper _mapper; + private readonly ICacheService _cacheService; - public GetTodosQueryHandler(IApplicationDbContext context, IMapper mapper) + public GetTodosQueryHandler(IApplicationDbContext context, IMapper mapper, ICacheService cacheService) { _context = context; _mapper = mapper; + _cacheService = cacheService; } public async Task Handle(GetTodosQuery request, CancellationToken cancellationToken) { - return new TodosVm + var pageNumber = Math.Max(request.PageNumber, 1); + var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize); + var search = request.Search?.Trim() ?? string.Empty; + var colour = request.Colour?.Trim() ?? string.Empty; + + var cacheKey = $"GetTodos:{pageNumber}:{pageSize}:{search}:{colour}:{request.Priority}"; + + return await _cacheService.GetOrCreateAsync(cacheKey, async () => { - PriorityLevels = Enum.GetValues(typeof(PriorityLevel)) - .Cast() - .Select(p => new LookupDto { Id = (int)p, Title = p.ToString() }) - .ToList(), - - Colours = - [ - new ColourDto { Code = Colour.Grey, Name = nameof(Colour.Grey) }, - new ColourDto { Code = Colour.Purple, Name = nameof(Colour.Purple) }, - new ColourDto { Code = Colour.Blue, Name = nameof(Colour.Blue) }, - new ColourDto { Code = Colour.Teal, Name = nameof(Colour.Teal) }, - new ColourDto { Code = Colour.Green, Name = nameof(Colour.Green) }, - new ColourDto { Code = Colour.Orange, Name = nameof(Colour.Orange) }, - new ColourDto { Code = Colour.Red, Name = nameof(Colour.Red) }, - ], - - Lists = await _context.TodoLists - .AsNoTracking() + var specification = new TodoListFilterSpecification(search, colour, request.Priority); + + var countSpecification = new TodoListFilterSpecification(search, colour, request.Priority); + var totalQuery = SpecificationEvaluator.GetQuery(_context.TodoLists.AsNoTracking(), countSpecification); + var totalCount = await totalQuery.CountAsync(cancellationToken); + + var skip = (pageNumber - 1) * pageSize; + specification.ApplyPaging(skip, pageSize); + + var query = SpecificationEvaluator.GetQuery(_context.TodoLists.AsNoTracking(), specification); + var pagedLists = await query .ProjectTo(_mapper.ConfigurationProvider) - .OrderBy(t => t.Title) - .ToListAsync(cancellationToken) - }; + .ToListAsync(cancellationToken); + + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + + return new TodosVm + { + PriorityLevels = Enum.GetValues(typeof(PriorityLevel)) + .Cast() + .Select(p => new LookupDto { Id = (int)p, Title = p.ToString() }) + .ToList(), + + Colours = + [ + new ColourDto { Code = Colour.Grey, Name = nameof(Colour.Grey) }, + new ColourDto { Code = Colour.Purple, Name = nameof(Colour.Purple) }, + new ColourDto { Code = Colour.Blue, Name = nameof(Colour.Blue) }, + new ColourDto { Code = Colour.Teal, Name = nameof(Colour.Teal) }, + new ColourDto { Code = Colour.Green, Name = nameof(Colour.Green) }, + new ColourDto { Code = Colour.Orange, Name = nameof(Colour.Orange) }, + new ColourDto { Code = Colour.Red, Name = nameof(Colour.Red) }, + ], + + Lists = pagedLists, + Pagination = new PaginationDto + { + PageNumber = pageNumber, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages, + HasPreviousPage = pageNumber > 1, + HasNextPage = pageNumber < totalPages + } + }; + }, TimeSpan.FromMinutes(1)); } } diff --git a/src/Application/TodoLists/Queries/GetTodos/PaginationDto.cs b/src/Application/TodoLists/Queries/GetTodos/PaginationDto.cs new file mode 100644 index 000000000..71b31d145 --- /dev/null +++ b/src/Application/TodoLists/Queries/GetTodos/PaginationDto.cs @@ -0,0 +1,11 @@ +namespace CleanArchitecture.Application.TodoLists.Queries.GetTodos; + +public class PaginationDto +{ + public int PageNumber { get; init; } + public int PageSize { get; init; } + public int TotalCount { get; init; } + public int TotalPages { get; init; } + public bool HasPreviousPage { get; init; } + public bool HasNextPage { get; init; } +} diff --git a/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs b/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs index 4b29d4aa4..05911d08b 100644 --- a/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs +++ b/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs @@ -9,11 +9,16 @@ public class TodosVm public IReadOnlyCollection Colours { get; init; } = []; public IReadOnlyCollection Lists { get; init; } = []; + + public PaginationDto Pagination { get; init; } = new PaginationDto(); } -public class ColourDto +public class PaginationDto { - public string Code { get; init; } = string.Empty; - - public string Name { get; init; } = string.Empty; + public int PageNumber { get; init; } + public int PageSize { get; init; } + public int TotalCount { get; init; } + public int TotalPages { get; init; } + public bool HasPreviousPage { get; init; } + public bool HasNextPage { get; init; } } diff --git a/src/Application/TodoLists/Specifications/TodoListFilterSpecification.cs b/src/Application/TodoLists/Specifications/TodoListFilterSpecification.cs new file mode 100644 index 000000000..f0e4e51d0 --- /dev/null +++ b/src/Application/TodoLists/Specifications/TodoListFilterSpecification.cs @@ -0,0 +1,29 @@ +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Application.TodoLists.Specifications; + +public class TodoListFilterSpecification : Specification +{ + public TodoListFilterSpecification(string? search = null, string? colour = null, int? priority = null) + { + var criteria = PredicateBuilder.True(); + + if (!string.IsNullOrWhiteSpace(search)) + { + criteria = criteria.And(list => list.Title != null && list.Title.Contains(search)); + } + + if (!string.IsNullOrWhiteSpace(colour)) + { + criteria = criteria.And(list => list.Colour.Code == colour); + } + + if (priority.HasValue) + { + criteria = criteria.And(list => list.Items.Any(item => (int)item.Priority == priority.Value)); + } + + Criteria = criteria; + ApplyOrderBy(list => list.Title!); + } +} diff --git a/src/Infrastructure/Cache/MemoryCacheService.cs b/src/Infrastructure/Cache/MemoryCacheService.cs new file mode 100644 index 000000000..1060efb98 --- /dev/null +++ b/src/Infrastructure/Cache/MemoryCacheService.cs @@ -0,0 +1,43 @@ +using CleanArchitecture.Application.Common.Interfaces; +using Microsoft.Extensions.Caching.Memory; + +namespace CleanArchitecture.Infrastructure.Cache; + +public sealed class MemoryCacheService : ICacheService +{ + private readonly IMemoryCache _memoryCache; + + public MemoryCacheService(IMemoryCache memoryCache) + { + _memoryCache = memoryCache; + } + + public Task GetOrCreateAsync(string key, Func> createItem, TimeSpan? absoluteExpirationRelativeToNow = null) + { + if (_memoryCache.TryGetValue(key, out TItem cachedItem)) + { + return Task.FromResult(cachedItem); + } + + return CreateAndCacheAsync(key, createItem, absoluteExpirationRelativeToNow); + } + + public void Remove(string key) + { + _memoryCache.Remove(key); + } + + private async Task CreateAndCacheAsync(string key, Func> createItem, TimeSpan? absoluteExpirationRelativeToNow) + { + var item = await createItem(); + + var cacheEntryOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow ?? TimeSpan.FromMinutes(1) + }; + + _memoryCache.Set(key, item, cacheEntryOptions); + + return item; + } +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index f361e4541..055b3b75e 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -1,4 +1,5 @@ using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Infrastructure.Cache; using CleanArchitecture.Infrastructure.Data; using CleanArchitecture.Infrastructure.Data.Interceptors; using CleanArchitecture.Infrastructure.Identity; @@ -41,6 +42,9 @@ public static void AddInfrastructureServices(this IHostApplicationBuilder builde builder.Services.AddScoped(provider => provider.GetRequiredService()); + builder.Services.AddMemoryCache(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); #if (UseApiOnly) diff --git a/src/Web/Endpoints/TodoLists.cs b/src/Web/Endpoints/TodoLists.cs index 7de82fb4d..6dd4fab0a 100644 --- a/src/Web/Endpoints/TodoLists.cs +++ b/src/Web/Endpoints/TodoLists.cs @@ -18,11 +18,11 @@ public static void Map(RouteGroupBuilder groupBuilder) groupBuilder.MapDelete(DeleteTodoList, "{id}"); } - [EndpointSummary("Get all Todo Lists")] - [EndpointDescription("Retrieves all todo lists along with their items.")] - public static async Task> GetTodoLists(ISender sender) + [EndpointSummary("Get paged Todo Lists")] + [EndpointDescription("Retrieves todo lists with optional filtering and pagination.")] + public static async Task> GetTodoLists(ISender sender, [AsParameters] GetTodosQuery query) { - var vm = await sender.Send(new GetTodosQuery()); + var vm = await sender.Send(query); return TypedResults.Ok(vm); } diff --git a/src/Web/Endpoints/UserProfileDto.cs b/src/Web/Endpoints/UserProfileDto.cs new file mode 100644 index 000000000..894c2a08b --- /dev/null +++ b/src/Web/Endpoints/UserProfileDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace CleanArchitecture.Web.Endpoints; + +public class UserProfileDto +{ + public string? Id { get; init; } + public string? Email { get; init; } + public List Roles { get; init; } = []; +} diff --git a/src/Web/Endpoints/Users.cs b/src/Web/Endpoints/Users.cs index 5c6abd33e..5a7ff19cf 100644 --- a/src/Web/Endpoints/Users.cs +++ b/src/Web/Endpoints/Users.cs @@ -1,4 +1,5 @@ -using CleanArchitecture.Infrastructure.Identity; +using System.Security.Claims; +using CleanArchitecture.Infrastructure.Identity; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -10,10 +11,22 @@ public class Users : IEndpointGroup public static void Map(RouteGroupBuilder groupBuilder) { groupBuilder.MapIdentityApi(); - + groupBuilder.MapGet("profile", GetProfile).RequireAuthorization(); groupBuilder.MapPost(Logout, "logout").RequireAuthorization(); } + [EndpointSummary("Get current user profile")] + [EndpointDescription("Returns the authenticated user's identifier, email claim, and roles.")] + public static Task> GetProfile(ClaimsPrincipal user) + { + return Task.FromResult(TypedResults.Ok(new UserProfileDto + { + Id = user.FindFirstValue(ClaimTypes.NameIdentifier), + Email = user.FindFirstValue(ClaimTypes.Email), + Roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList() + })); + } + [EndpointSummary("Log out")] [EndpointDescription("Logs out the current user by clearing the authentication cookie.")] public static async Task> Logout(SignInManager signInManager, [FromBody] object empty) @@ -27,3 +40,10 @@ public static async Task> Logout(SignInManag return TypedResults.Unauthorized(); } } + +public class UserProfileDto +{ + public string? Id { get; init; } + public string? Email { get; init; } + public List Roles { get; init; } = []; +}