Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -490,3 +490,4 @@ $RECYCLE.BIN/
*.db
*.db-shm
*.db-wal
TestProject/
4 changes: 4 additions & 0 deletions README-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/Application/Common/Interfaces/ICacheService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace CleanArchitecture.Application.Common.Interfaces;

public interface ICacheService
{
Task<TItem> GetOrCreateAsync<TItem>(string key, Func<Task<TItem>> createItem, TimeSpan? absoluteExpirationRelativeToNow = null);
void Remove(string key);
}
15 changes: 15 additions & 0 deletions src/Application/Common/Interfaces/ISpecification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Linq.Expressions;

namespace CleanArchitecture.Application.Common.Interfaces;

public interface ISpecification<T> where T : class
{
Expression<Func<T, bool>>? Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
List<string> IncludeStrings { get; }
Expression<Func<T, object>>? OrderBy { get; }
Expression<Func<T, object>>? OrderByDescending { get; }
int? Take { get; }
int? Skip { get; }
bool IsPagingEnabled { get; }
}
40 changes: 40 additions & 0 deletions src/Application/Common/Models/PaginatedList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;

namespace CleanArchitecture.Application.Common.Models;

public sealed class PaginatedList<T>
{
public PaginatedList(IReadOnlyCollection<T> items, int totalCount, int pageIndex, int pageSize)
{
Items = items;
TotalCount = totalCount;
PageIndex = pageIndex;
PageSize = pageSize;
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
}

public IReadOnlyCollection<T> 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<PaginatedList<T>> ToPaginatedListAsync<T>(this IQueryable<T> 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<T>(items, totalCount, pageIndex, pageSize);
}
}
58 changes: 58 additions & 0 deletions src/Application/Common/Specifications/Specification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Linq.Expressions;

namespace CleanArchitecture.Application.Common.Specifications;

public abstract class Specification<T> : ISpecification<T> where T : class
{
protected Specification()
{
}

protected Specification(Expression<Func<T, bool>> criteria)
{
Criteria = criteria;
}

public Expression<Func<T, bool>>? Criteria { get; protected set; }

public List<Expression<Func<T, object>>> Includes { get; } = [];

public List<string> IncludeStrings { get; } = [];

public Expression<Func<T, object>>? OrderBy { get; protected set; }

public Expression<Func<T, object>>? 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<Func<T, object>> 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<Func<T, object>> orderByExpression)
{
OrderBy = orderByExpression;
}

protected virtual void ApplyOrderByDescending(Expression<Func<T, object>> orderByDescendingExpression)
{
OrderByDescending = orderByDescendingExpression;
}
}
47 changes: 47 additions & 0 deletions src/Application/Common/Specifications/SpecificationEvaluator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace CleanArchitecture.Application.Common.Specifications;

public class SpecificationEvaluator<T> where T : class
{
public static IQueryable<T> GetQuery(IQueryable<T> inputQuery, ISpecification<T> 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;
}
}
50 changes: 50 additions & 0 deletions src/Application/Common/Utilities/PredicateBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Linq.Expressions;

namespace CleanArchitecture.Application.Common.Utilities;

public static class PredicateBuilder
{
public static Expression<Func<T, bool>> True<T>() => _ => true;

public static Expression<Func<T, bool>> False<T>() => _ => false;

public static Expression<Func<T, bool>> And<T>(
this Expression<Func<T, bool>> expr1,
Expression<Func<T, bool>> 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<Func<T, bool>>(
Expression.AndAlso(left ?? throw new InvalidOperationException(), right ?? throw new InvalidOperationException()), parameter);
}

public static Expression<Func<T, bool>> Or<T>(
this Expression<Func<T, bool>> expr1,
Expression<Func<T, bool>> 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<Func<T, bool>>(
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);
}
}
}
8 changes: 8 additions & 0 deletions src/Application/TodoLists/Queries/GetTodos/ColourDto.cs
Original file line number Diff line number Diff line change
@@ -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;
}
91 changes: 67 additions & 24 deletions src/Application/TodoLists/Queries/GetTodos/GetTodos.cs
Original file line number Diff line number Diff line change
@@ -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<TodosVm>;
public sealed class GetTodosQuery : IRequest<TodosVm>
{
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<GetTodosQuery, TodosVm>
{
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<TodosVm> 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<PriorityLevel>()
.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<Domain.Entities.TodoList>.GetQuery(_context.TodoLists.AsNoTracking(), countSpecification);
var totalCount = await totalQuery.CountAsync(cancellationToken);

var skip = (pageNumber - 1) * pageSize;
specification.ApplyPaging(skip, pageSize);

var query = SpecificationEvaluator<Domain.Entities.TodoList>.GetQuery(_context.TodoLists.AsNoTracking(), specification);
var pagedLists = await query
.ProjectTo<TodoListDto>(_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<PriorityLevel>()
.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));
}
}
11 changes: 11 additions & 0 deletions src/Application/TodoLists/Queries/GetTodos/PaginationDto.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
13 changes: 9 additions & 4 deletions src/Application/TodoLists/Queries/GetTodos/TodosVm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ public class TodosVm
public IReadOnlyCollection<ColourDto> Colours { get; init; } = [];

public IReadOnlyCollection<TodoListDto> 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; }
}
Loading