diff --git a/src/Wax.Api/Controllers/CustomerController.cs b/src/Wax.Api/Controllers/CustomerController.cs index 15b4238..5dd53d7 100644 --- a/src/Wax.Api/Controllers/CustomerController.cs +++ b/src/Wax.Api/Controllers/CustomerController.cs @@ -1,6 +1,8 @@ using Mediator.Net; using Microsoft.AspNetCore.Mvc; using Wax.Messages.Commands.Customers; +using Wax.Messages.Dtos.Customers; +using Wax.Messages.Requests; using Wax.Messages.Requests.Customers; namespace Wax.Api.Controllers @@ -16,12 +18,11 @@ public CustomerController(IMediator mediator) _mediator = mediator; } - [HttpGet("{id:guid}")] - [ProducesResponseType(typeof(GetCustomerResponse), 200)] - public async Task GetAsync(Guid id) + [HttpGet] + [ProducesResponseType(typeof(PaginatedResponse), 200)] + public async Task GetListAsync([FromQuery] GetCustomersRequest request) { - var response = await _mediator.RequestAsync( - new GetCustomerRequest { CustomerId = id }); + var response = await _mediator.RequestAsync>(request); return Ok(response); } diff --git a/src/Wax.Api/Filters/GlobalExceptionFilter.cs b/src/Wax.Api/Filters/GlobalExceptionFilter.cs index d0d704d..857a351 100644 --- a/src/Wax.Api/Filters/GlobalExceptionFilter.cs +++ b/src/Wax.Api/Filters/GlobalExceptionFilter.cs @@ -42,6 +42,7 @@ private void HandleBusinessException(ExceptionContext context) var problemDetails = new ProblemDetails { + Type = context.Exception.GetType().Name, Status = StatusCodes.Status409Conflict, Title = "A business error occur.", Detail = context.Exception.Message, @@ -59,6 +60,7 @@ private void HandleEntityNotFoundException(ExceptionContext context) var details = new ProblemDetails { + Type = nameof(EntityNotFoundException), Status = StatusCodes.Status404NotFound, Title = "The specified resource was not found.", Detail = exception.Message, @@ -72,7 +74,10 @@ private void HandleValidationException(ExceptionContext context) var exception = context.Exception as FluentValidation.ValidationException; var details = new ValidationProblemDetails(exception.Errors.GroupBy(e => e.PropertyName, e => e.ErrorMessage) - .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray())); + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray())) + { + Type = nameof(FluentValidation.ValidationException) + }; context.Result = new BadRequestObjectResult(details); @@ -85,6 +90,7 @@ private void HandleInternalServerError(ExceptionContext context) var problemDetails = new ProblemDetails { + Type = context.Exception.GetType().Name, Status = StatusCodes.Status500InternalServerError, Title = "Internal error.", Detail = _env.IsDevelopment() ? context.Exception.Message : "An error occur. Try it again later." diff --git a/src/Wax.Core/Data/ApplicationDbContext.cs b/src/Wax.Core/Data/ApplicationDbContext.cs index c73afab..9eabfd9 100644 --- a/src/Wax.Core/Data/ApplicationDbContext.cs +++ b/src/Wax.Core/Data/ApplicationDbContext.cs @@ -9,8 +9,6 @@ public ApplicationDbContext(DbContextOptions options) : ba { } - public bool HasEntitiesChanged { get; private set; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseInMemoryDatabase("__wax_database"); @@ -20,24 +18,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(CustomerEntityTypeConfiguration).Assembly); } - - - public override Task SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken()) - { - var result = base.SaveChangesAsync(cancellationToken); - - HasEntitiesChanged = false; - - return result; - } - - public async Task ChangeEntitiesAsync(bool saveNow = false, CancellationToken cancellationToken = default) - { - HasEntitiesChanged = true; - - if (saveNow) - { - await SaveChangesAsync(cancellationToken); - } - } } \ No newline at end of file diff --git a/src/Wax.Core/Data/IUnitOfWork.cs b/src/Wax.Core/Data/IUnitOfWork.cs index 2814ddc..58b07a4 100644 --- a/src/Wax.Core/Data/IUnitOfWork.cs +++ b/src/Wax.Core/Data/IUnitOfWork.cs @@ -16,6 +16,15 @@ public UnitOfWork(ApplicationDbContext context) public Task CommitAsync(CancellationToken cancellationToken = default) { - return _context.HasEntitiesChanged ? _context.SaveChangesAsync(cancellationToken) : Task.CompletedTask; + return _context.SaveChangesAsync(cancellationToken); + } +} + +public static class UnitOfWorkExtensions +{ + public static async Task WithUnitOfWork(this IUnitOfWork uow, Func func) + { + await func(); + await uow.CommitAsync(); } } \ No newline at end of file diff --git a/src/Wax.Core/Handlers/CommandHandlers/Customers/CreateCustomerCommandHandler.cs b/src/Wax.Core/Handlers/CommandHandlers/Customers/CreateCustomerCommandHandler.cs index 30246bf..2340dd5 100644 --- a/src/Wax.Core/Handlers/CommandHandlers/Customers/CreateCustomerCommandHandler.cs +++ b/src/Wax.Core/Handlers/CommandHandlers/Customers/CreateCustomerCommandHandler.cs @@ -1,40 +1,50 @@ using AutoMapper; +using FluentValidation; using Mediator.Net.Context; using Mediator.Net.Contracts; -using Wax.Core.Data; using Wax.Core.Domain.Customers; using Wax.Core.Domain.Customers.Exceptions; +using Wax.Core.Middlewares.FluentMessageValidator; using Wax.Core.Repositories; using Wax.Messages.Commands.Customers; -namespace Wax.Core.Handlers.CommandHandlers.Customers +namespace Wax.Core.Handlers.CommandHandlers.Customers; + +public class CreateCustomerCommandHandler : ICommandHandler { - public class CreateCustomerCommandHandler : ICommandHandler + private readonly IMapper _mapper; + private readonly ICustomerRepository _customerRepository; + + public CreateCustomerCommandHandler(IMapper mapper, ICustomerRepository customerRepository) { - private readonly IMapper _mapper; - private readonly ICustomerRepository _customerRepository; + _mapper = mapper; + _customerRepository = customerRepository; + } - public CreateCustomerCommandHandler(IMapper mapper,ICustomerRepository customerRepository) - { - _mapper = mapper; - _customerRepository = customerRepository; - } + public async Task Handle(IReceiveContext context, + CancellationToken cancellationToken) + { + var isUnique = await _customerRepository.IsUniqueAsync(context.Message.Name); - public async Task Handle(IReceiveContext context, - CancellationToken cancellationToken) + if (!isUnique) { - var existing = await _customerRepository.FindByNameAsync(context.Message.Name); + throw new CustomerNameAlreadyExistsException(); + } - if (existing != null) - { - throw new CustomerNameAlreadyExistsException(); - } + var customer = _mapper.Map(context.Message); - var customer = _mapper.Map(context.Message); + await _customerRepository.InsertAsync(customer, cancellationToken); - await _customerRepository.InsertAsync(customer); + return new CreateCustomerResponse { CustomerId = customer.Id }; + } +} - return new CreateCustomerResponse { CustomerId = customer.Id }; - } +public class CreateCustomerCommandValidator : FluentMessageValidator +{ + public CreateCustomerCommandValidator() + { + RuleFor(v => v.Name).NotEmpty().MaximumLength(64); + RuleFor(v => v.Address).MaximumLength(512); + RuleFor(v => v.Contact).MaximumLength(128); } } \ No newline at end of file diff --git a/src/Wax.Core/Handlers/CommandHandlers/Customers/DeleteCustomerCommandHandler.cs b/src/Wax.Core/Handlers/CommandHandlers/Customers/DeleteCustomerCommandHandler.cs index e711f94..25cc872 100644 --- a/src/Wax.Core/Handlers/CommandHandlers/Customers/DeleteCustomerCommandHandler.cs +++ b/src/Wax.Core/Handlers/CommandHandlers/Customers/DeleteCustomerCommandHandler.cs @@ -1,6 +1,7 @@ +using FluentValidation; using Mediator.Net.Context; using Mediator.Net.Contracts; -using Wax.Core.Data; +using Wax.Core.Middlewares.FluentMessageValidator; using Wax.Core.Repositories; using Wax.Messages.Commands.Customers; @@ -17,8 +18,16 @@ public DeleteCustomerCommandHandler(ICustomerRepository customerRepository) public async Task Handle(IReceiveContext context, CancellationToken cancellationToken) { - var customer = await _customerRepository.GetByIdAsync(context.Message.CustomerId); + var customer = await _customerRepository.GetByIdAsync(context.Message.CustomerId, cancellationToken); - await _customerRepository.DeleteAsync(customer); + await _customerRepository.DeleteAsync(customer, cancellationToken); + } +} + +public class DeleteCustomerCommandValidator : FluentMessageValidator +{ + public DeleteCustomerCommandValidator() + { + RuleFor(v => v.CustomerId).NotEmpty(); } } \ No newline at end of file diff --git a/src/Wax.Core/Handlers/CommandHandlers/Customers/UpdateCustomerCommandHandler.cs b/src/Wax.Core/Handlers/CommandHandlers/Customers/UpdateCustomerCommandHandler.cs index f14df87..70c4658 100644 --- a/src/Wax.Core/Handlers/CommandHandlers/Customers/UpdateCustomerCommandHandler.cs +++ b/src/Wax.Core/Handlers/CommandHandlers/Customers/UpdateCustomerCommandHandler.cs @@ -1,7 +1,9 @@ using AutoMapper; +using FluentValidation; using Mediator.Net.Context; using Mediator.Net.Contracts; using Wax.Core.Domain.Customers.Exceptions; +using Wax.Core.Middlewares.FluentMessageValidator; using Wax.Core.Repositories; using Wax.Messages.Commands.Customers; @@ -20,13 +22,13 @@ public UpdateCustomerCommandHandler(IMapper mapper, ICustomerRepository customer public async Task Handle(IReceiveContext context, CancellationToken cancellationToken) { - var customer = await _customerRepository.GetByIdAsync(context.Message.CustomerId); + var customer = await _customerRepository.GetByIdAsync(context.Message.CustomerId, cancellationToken); if (customer.Name != context.Message.Name) { - var existing = await _customerRepository.FindByNameAsync(context.Message.Name); + var isUnique = await _customerRepository.IsUniqueAsync(context.Message.Name); - if (existing != null) + if (!isUnique) { throw new CustomerNameAlreadyExistsException(); } @@ -34,6 +36,17 @@ public async Task Handle(IReceiveContext context, Cancell _mapper.Map(context.Message, customer); - await _customerRepository.UpdateAsync(customer); + await _customerRepository.UpdateAsync(customer, cancellationToken); + } +} + +public class UpdateCustomerCommandValidator : FluentMessageValidator +{ + public UpdateCustomerCommandValidator() + { + RuleFor(v => v.CustomerId).NotEmpty(); + RuleFor(v => v.Name).NotEmpty().MaximumLength(64); + RuleFor(v => v.Address).MaximumLength(512); + RuleFor(v => v.Contact).MaximumLength(128); } } \ No newline at end of file diff --git a/src/Wax.Core/Handlers/RequestHandlers/Customers/GetCustomerRequestHandler.cs b/src/Wax.Core/Handlers/RequestHandlers/Customers/GetCustomerRequestHandler.cs deleted file mode 100644 index 939ed71..0000000 --- a/src/Wax.Core/Handlers/RequestHandlers/Customers/GetCustomerRequestHandler.cs +++ /dev/null @@ -1,32 +0,0 @@ -using AutoMapper; -using Mediator.Net.Context; -using Mediator.Net.Contracts; -using Wax.Core.Data; -using Wax.Core.Repositories; -using Wax.Messages.Dtos.Customers; -using Wax.Messages.Requests.Customers; - -namespace Wax.Core.Handlers.RequestHandlers.Customers; - -public class GetCustomerRequestHandler : IRequestHandler -{ - private readonly IMapper _mapper; - private readonly ICustomerRepository _customerRepository; - - public GetCustomerRequestHandler(IMapper mapper, ICustomerRepository customerRepository) - { - _mapper = mapper; - _customerRepository = customerRepository; - } - - public async Task Handle(IReceiveContext context, - CancellationToken cancellationToken) - { - var customer = await _customerRepository.GetByIdAsync(context.Message.CustomerId); - - return new GetCustomerResponse - { - Customer = _mapper.Map(customer) - }; - } -} \ No newline at end of file diff --git a/src/Wax.Core/Handlers/RequestHandlers/Customers/GetCustomersRequestHandler.cs b/src/Wax.Core/Handlers/RequestHandlers/Customers/GetCustomersRequestHandler.cs new file mode 100644 index 0000000..0167435 --- /dev/null +++ b/src/Wax.Core/Handlers/RequestHandlers/Customers/GetCustomersRequestHandler.cs @@ -0,0 +1,47 @@ +using FluentValidation; +using Mediator.Net.Context; +using Mediator.Net.Contracts; +using Wax.Core.Middlewares.FluentMessageValidator; +using Wax.Core.Repositories; +using Wax.Messages.Dtos.Customers; +using Wax.Messages.Requests; +using Wax.Messages.Requests.Customers; + +namespace Wax.Core.Handlers.RequestHandlers.Customers; + +public class GetCustomersRequestHandler : IRequestHandler> +{ + private readonly ICustomerRepository _customerRepository; + + public GetCustomersRequestHandler(ICustomerRepository customerRepository) + { + _customerRepository = customerRepository; + } + + public async Task> Handle(IReceiveContext context, + CancellationToken cancellationToken) + { + var data = await _customerRepository.GetPaginatedListByProjectionAsync( + c => new CustomerShortInfo + { + Id = c.Id, + Address = c.Address, + Name = c.Name + }, + orderBy: o => o.Name, + pageIndex: context.Message.PageIndex, + pageSize: context.Message.PageSize, + cancellationToken: cancellationToken); + + return new PaginatedResponse(data); + } +} + +public class GetCustomersRequestValidator : FluentMessageValidator +{ + public GetCustomersRequestValidator() + { + RuleFor(v => v.PageIndex).GreaterThan(0); + RuleFor(v => v.PageSize).GreaterThan(0); + } +} \ No newline at end of file diff --git a/src/Wax.Core/Repositories/BasicRepository.cs b/src/Wax.Core/Repositories/BasicRepository.cs index eddd31a..9eb61f8 100644 --- a/src/Wax.Core/Repositories/BasicRepository.cs +++ b/src/Wax.Core/Repositories/BasicRepository.cs @@ -3,6 +3,7 @@ using Wax.Core.Data; using Wax.Core.Domain; using Wax.Core.Exceptions; +using Wax.Messages; namespace Wax.Core.Repositories; @@ -15,10 +16,11 @@ public BasicRepository(ApplicationDbContext dbContext) _dbContext = dbContext; } - public async Task GetByIdAsync(TKey id) + public async Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default) where TKey : notnull { - var entity = await _dbContext.Set().FindAsync(new object[] { id }).ConfigureAwait(false); + var entity = await _dbContext.Set().FindAsync(new object[] { id }, cancellationToken) + .ConfigureAwait(false); if (entity == null) { @@ -28,53 +30,168 @@ public async Task GetByIdAsync(TKey id) return entity; } - public async Task InsertAsync(TEntity entity, bool saveNow = false) + public Task FirstOrDefaultAsync( + Expression> filter = null, + Expression> orderBy = null, + bool descending = true, + CancellationToken cancellationToken = default) { - var entry = await _dbContext.Set().AddAsync(entity).ConfigureAwait(false); + var query = _dbContext.Set().AsQueryable(); - await _dbContext.ChangeEntitiesAsync(saveNow); + if (filter != null) + { + query = query.Where(filter); + } - return entry.Entity; + if (orderBy != null) + { + query = descending + ? query.OrderByDescending(orderBy) + : query.OrderBy(orderBy); + } + + return query.FirstOrDefaultAsync(cancellationToken); } - public async Task InsertRangeAsync(IEnumerable entity, bool saveNow = false) + public Task SingleOrDefaultAsync( + Expression> filter = null, + CancellationToken cancellationToken = default) { - await _dbContext.Set().AddRangeAsync(entity).ConfigureAwait(false); + var query = _dbContext.Set().AsQueryable(); - await _dbContext.ChangeEntitiesAsync(saveNow); + if (filter != null) + { + query = query.Where(filter); + } + + return query.SingleOrDefaultAsync(cancellationToken); } - public Task UpdateAsync(TEntity entity, bool saveNow = false) + public Task> ListAsync( + Expression> filter = null, + Expression> orderBy = null, + bool descending = true, + CancellationToken cancellationToken = default) { - _dbContext.Entry(entity).State = EntityState.Modified; - return _dbContext.ChangeEntitiesAsync(saveNow); + var query = _dbContext.Set().AsNoTracking().AsQueryable(); + + if (filter != null) + { + query = query.Where(filter); + } + + if (orderBy != null) + { + query = descending + ? query.OrderByDescending(orderBy) + : query.OrderBy(orderBy); + } + + return query.ToListAsync(cancellationToken); } - public Task UpdateRangeAsync(IEnumerable entities, bool saveNow = false) + public Task GetByProjectionAsync( + Expression> selector, + Expression> filter = null, + CancellationToken cancellationToken = default) { - _dbContext.Set().UpdateRange(entities); - return _dbContext.ChangeEntitiesAsync(saveNow); + var query = _dbContext.Set().AsNoTracking().AsQueryable(); + + if (filter != null) + { + query = query.Where(filter); + } + + return query.Select(selector).SingleOrDefaultAsync(cancellationToken); } - public Task DeleteAsync(TEntity entity, bool saveNow = false) + public Task> GetListByProjectionAsync( + Expression> selector, + Expression> filter = null, + Expression> orderBy = null, + bool descending = true, + CancellationToken cancellationToken = default) { - _dbContext.Set().Remove(entity); - return _dbContext.ChangeEntitiesAsync(saveNow); + var query = _dbContext.Set().AsNoTracking().AsQueryable(); + + if (filter != null) + { + query = query.Where(filter); + } + + if (orderBy != null) + { + query = descending + ? query.OrderByDescending(orderBy) + : query.OrderBy(orderBy); + } + + return query.Select(selector).ToListAsync(cancellationToken); } - public Task DeleteRangeAsync(IEnumerable entity, bool saveNow = false) + public Task> GetPaginatedListByProjectionAsync( + Expression> selector, + Expression> filter = null, + Expression> orderBy = null, + bool descending = true, + int pageIndex = 1, + int pageSize = 15, + CancellationToken cancellationToken = default) { - _dbContext.Set().RemoveRange(entity); - return _dbContext.ChangeEntitiesAsync(saveNow); + var query = _dbContext.Set().AsNoTracking().AsQueryable(); + + if (filter != null) + { + query = query.Where(filter); + } + + if (orderBy != null) + { + query = descending + ? query.OrderByDescending(orderBy) + : query.OrderBy(orderBy); + } + + return query.Select(selector).ToPaginatedListAsync(pageIndex, pageSize, cancellationToken); + } + + public async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default) + { + var entry = await _dbContext.Set().AddAsync(entity, cancellationToken).ConfigureAwait(false); + + return entry.Entity; + } + + public async Task InsertRangeAsync(IEnumerable entity, CancellationToken cancellationToken = default) + { + await _dbContext.Set().AddRangeAsync(entity, cancellationToken).ConfigureAwait(false); } - public Task> ListAsync(Expression> predicate = null) + public Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) { - return predicate == null - ? _dbContext.Set().ToListAsync() - : _dbContext.Set().Where(predicate).ToListAsync(); + return Task.CompletedTask; } + public Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + _dbContext.Set().UpdateRange(entities); + + return Task.CompletedTask; + } + + public Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) + { + _dbContext.Set().Remove(entity); + + return Task.CompletedTask; + } + + public Task DeleteRangeAsync(IEnumerable entity, CancellationToken cancellationToken = default) + { + _dbContext.Set().RemoveRange(entity); + + return Task.CompletedTask; + } public IQueryable Table => _dbContext.Set(); } \ No newline at end of file diff --git a/src/Wax.Core/Repositories/IBasicRepository.cs b/src/Wax.Core/Repositories/IBasicRepository.cs index e6a1b82..0b4d532 100644 --- a/src/Wax.Core/Repositories/IBasicRepository.cs +++ b/src/Wax.Core/Repositories/IBasicRepository.cs @@ -1,25 +1,61 @@ using System.Linq.Expressions; using Wax.Core.Domain; +using Wax.Messages; namespace Wax.Core.Repositories; public interface IBasicRepository where TEntity : class, IEntity { - Task GetByIdAsync(TKey id) where TKey : notnull; + Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default) where TKey : notnull; - Task InsertAsync(TEntity entity, bool saveNow = false); + Task FirstOrDefaultAsync( + Expression> filter = null, + Expression> orderBy = null, + bool descending = true, + CancellationToken cancellationToken = default); - Task InsertRangeAsync(IEnumerable entity, bool saveNow = false); + Task SingleOrDefaultAsync( + Expression> filter = null, + CancellationToken cancellationToken = default); - Task UpdateAsync(TEntity entity, bool saveNow = false); + Task> ListAsync( + Expression> filter = null, + Expression> orderBy = null, + bool descending = true, + CancellationToken cancellationToken = default); - Task UpdateRangeAsync(IEnumerable entities, bool saveNow = false); + Task GetByProjectionAsync( + Expression> selector, + Expression> filter = null, + CancellationToken cancellationToken = default); - Task DeleteAsync(TEntity entity, bool saveNow = false); + Task> GetListByProjectionAsync( + Expression> selector, + Expression> filter = null, + Expression> orderBy = null, + bool descending = true, + CancellationToken cancellationToken = default); - Task DeleteRangeAsync(IEnumerable entity, bool saveNow = false); + Task> GetPaginatedListByProjectionAsync( + Expression> selector, + Expression> filter = null, + Expression> orderBy = null, + bool descending = true, + int pageIndex = 1, + int pageSize = 15, + CancellationToken cancellationToken = default); - Task> ListAsync(Expression> predicate = null); + Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default); + + Task InsertRangeAsync(IEnumerable entity, CancellationToken cancellationToken = default); + + Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); + + Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default); + + Task DeleteRangeAsync(IEnumerable entity, CancellationToken cancellationToken = default); IQueryable Table { get; } } \ No newline at end of file diff --git a/src/Wax.Core/Repositories/ICustomerRepository.cs b/src/Wax.Core/Repositories/ICustomerRepository.cs index bb2481f..cd80d76 100644 --- a/src/Wax.Core/Repositories/ICustomerRepository.cs +++ b/src/Wax.Core/Repositories/ICustomerRepository.cs @@ -5,19 +5,19 @@ namespace Wax.Core.Repositories; -public interface ICustomerRepository : IBasicRepository +public interface ICustomerRepository : IBasicRepository, IScopedDependency { - Task FindByNameAsync(string name); + Task IsUniqueAsync(string name); } -public class CustomerRepository : BasicRepository, ICustomerRepository, IScopedDependency +public class CustomerRepository : BasicRepository, ICustomerRepository { public CustomerRepository(ApplicationDbContext dbContext) : base(dbContext) { } - public Task FindByNameAsync(string name) + public async Task IsUniqueAsync(string name) { - return Table.FirstOrDefaultAsync(c => c.Name == name); + return !await Table.AnyAsync(c => c.Name == name); } } \ No newline at end of file diff --git a/src/Wax.Core/Repositories/PaginatedListQueryExtensions.cs b/src/Wax.Core/Repositories/PaginatedListQueryExtensions.cs new file mode 100644 index 0000000..bf89901 --- /dev/null +++ b/src/Wax.Core/Repositories/PaginatedListQueryExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Wax.Messages; + +namespace Wax.Core.Repositories; + +public static class PaginatedListQueryExtensions +{ + public static async Task> ToPaginatedListAsync(this IQueryable source, int pageIndex, int pageSize, + CancellationToken cancellationToken = default) + { + var count = await source.CountAsync(cancellationToken); + + if (count == 0) + { + return PaginatedList.Empty(); + } + + var items = await source + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return new PaginatedList(items, count, pageIndex, pageSize); + } +} \ No newline at end of file diff --git a/src/Wax.Core/Validators/CreateCustomerCommandValidator.cs b/src/Wax.Core/Validators/CreateCustomerCommandValidator.cs deleted file mode 100644 index 7307de0..0000000 --- a/src/Wax.Core/Validators/CreateCustomerCommandValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentValidation; -using Wax.Core.Middlewares.FluentMessageValidator; -using Wax.Messages.Commands.Customers; - -namespace Wax.Core.Validators; - -public class CreateCustomerCommandValidator : FluentMessageValidator -{ - public CreateCustomerCommandValidator() - { - RuleFor(v => v.Name).NotEmpty().MaximumLength(64); - RuleFor(v => v.Address).MaximumLength(512); - RuleFor(v => v.Contact).MaximumLength(128); - } -} \ No newline at end of file diff --git a/src/Wax.Core/Validators/DeleteCustomerCommandValidator.cs b/src/Wax.Core/Validators/DeleteCustomerCommandValidator.cs deleted file mode 100644 index d1b78e8..0000000 --- a/src/Wax.Core/Validators/DeleteCustomerCommandValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentValidation; -using Wax.Core.Middlewares.FluentMessageValidator; -using Wax.Messages.Commands.Customers; - -namespace Wax.Core.Validators; - - -public class DeleteCustomerCommandValidator : FluentMessageValidator -{ - public DeleteCustomerCommandValidator() - { - RuleFor(v => v.CustomerId).NotEmpty(); - } -} \ No newline at end of file diff --git a/src/Wax.Core/Validators/GetCustomerRequestValidator.cs b/src/Wax.Core/Validators/GetCustomerRequestValidator.cs deleted file mode 100644 index 18abf24..0000000 --- a/src/Wax.Core/Validators/GetCustomerRequestValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FluentValidation; -using Wax.Core.Middlewares.FluentMessageValidator; -using Wax.Messages.Requests.Customers; - -namespace Wax.Core.Validators; - -public class GetCustomerRequestValidator : FluentMessageValidator -{ - public GetCustomerRequestValidator() - { - RuleFor(v => v.CustomerId).NotEmpty(); - } -} \ No newline at end of file diff --git a/src/Wax.Core/Validators/UpdateCustomerCommandValidator.cs b/src/Wax.Core/Validators/UpdateCustomerCommandValidator.cs deleted file mode 100644 index cce8d67..0000000 --- a/src/Wax.Core/Validators/UpdateCustomerCommandValidator.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentValidation; -using Wax.Core.Middlewares.FluentMessageValidator; -using Wax.Messages.Commands.Customers; - -namespace Wax.Core.Validators; - -public class UpdateCustomerCommandValidator : FluentMessageValidator -{ - public UpdateCustomerCommandValidator() - { - RuleFor(v => v.CustomerId).NotEmpty(); - RuleFor(v => v.Name).NotEmpty().MaximumLength(64); - RuleFor(v => v.Address).MaximumLength(512); - RuleFor(v => v.Contact).MaximumLength(128); - } -} \ No newline at end of file diff --git a/src/Wax.Messages/IPaginatedList.cs b/src/Wax.Messages/IPaginatedList.cs new file mode 100644 index 0000000..2527afb --- /dev/null +++ b/src/Wax.Messages/IPaginatedList.cs @@ -0,0 +1,43 @@ +using System.Collections; + +namespace Wax.Messages; + +public interface IPaginatedList : IReadOnlyList +{ + int PageIndex { get; } + int TotalPages { get; } + bool HasPreviousPage { get; } + bool HasNextPage { get; } +} + +public sealed class PaginatedList : IPaginatedList +{ + private readonly IReadOnlyList _subset; + public int PageIndex { get; } + public int TotalPages { get; } + + public PaginatedList(IEnumerable items, int count, int pageIndex, int pageSize) + { + PageIndex = pageIndex; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + + _subset = items as IReadOnlyList ?? items.ToArray(); + } + + public bool HasPreviousPage => PageIndex > 1; + + public bool HasNextPage => PageIndex < TotalPages; + + public IEnumerator GetEnumerator() => _subset.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _subset.GetEnumerator(); + + public int Count => _subset.Count; + + public T this[int index] => _subset[index]; + + public static PaginatedList Empty() + { + return new PaginatedList(Enumerable.Empty(), 0, 0, 0); + } +} \ No newline at end of file diff --git a/src/Wax.Messages/Requests/Customers/GetCustomersRequest.cs b/src/Wax.Messages/Requests/Customers/GetCustomersRequest.cs index 23c0741..6c56213 100644 --- a/src/Wax.Messages/Requests/Customers/GetCustomersRequest.cs +++ b/src/Wax.Messages/Requests/Customers/GetCustomersRequest.cs @@ -1,14 +1,9 @@ -using Mediator.Net.Contracts; -using Wax.Messages.Dtos.Customers; +using Mediator.Net.Contracts; namespace Wax.Messages.Requests.Customers; -public class GetCustomerRequest : IRequest +public class GetCustomersRequest : IRequest { - public Guid CustomerId { get; set; } -} - -public class GetCustomerResponse : IResponse -{ - public CustomerShortInfo Customer { get; set; } + public int PageIndex { get; set; } + public int PageSize { get; set; } } \ No newline at end of file diff --git a/src/Wax.Messages/Requests/PaginatedResponse.cs b/src/Wax.Messages/Requests/PaginatedResponse.cs new file mode 100644 index 0000000..a5b89ac --- /dev/null +++ b/src/Wax.Messages/Requests/PaginatedResponse.cs @@ -0,0 +1,21 @@ +using Mediator.Net.Contracts; + +namespace Wax.Messages.Requests; + +public class PaginatedResponse : IResponse +{ + public PaginatedResponse(IPaginatedList list) + { + PageIndex = list.PageIndex; + TotalPages = list.TotalPages; + HasPreviousPage = list.HasPreviousPage; + HasNextPage = list.HasNextPage; + Data = list; + } + + public int PageIndex { get; } + public int TotalPages { get; } + public bool HasPreviousPage { get; } + public bool HasNextPage { get; } + public IPaginatedList Data { get; } +} \ No newline at end of file diff --git a/tests/Wax.E2ETests/GlobalExceptionTests.cs b/tests/Wax.E2ETests/GlobalExceptionTests.cs index 0cde9da..2fcb2db 100644 --- a/tests/Wax.E2ETests/GlobalExceptionTests.cs +++ b/tests/Wax.E2ETests/GlobalExceptionTests.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json; using Shouldly; using Wax.Api; +using Wax.Core.Domain.Customers.Exceptions; using Wax.Messages.Commands.Customers; using Xunit; @@ -37,23 +38,6 @@ public async Task ShouldReturn400StatusCodeWhenInvalidCommand() problemDetails.Errors.Count.ShouldBeGreaterThanOrEqualTo(1); } - [Fact] - public async Task ShouldReturn404StatusCodeWhenEntityNotFound() - { - var client = _factory.CreateClient(); - - var response = await client.GetAsync($"/customers/{Guid.NewGuid()}"); - - response.StatusCode.ShouldBe(HttpStatusCode.NotFound); - - var content = await response.Content.ReadAsStringAsync(); - - var problemDetails = JsonConvert.DeserializeObject(content); - - problemDetails.Title.ShouldBe("The specified resource was not found."); - problemDetails.Detail.ShouldStartWith("Entity not found"); - } - [Fact] public async Task ShouldReturn409StatusCodeWhenBusinessError() { @@ -76,6 +60,7 @@ public async Task ShouldReturn409StatusCodeWhenBusinessError() var problemDetails = JsonConvert.DeserializeObject(content); + problemDetails.Type.ShouldBe(nameof(CustomerNameAlreadyExistsException)); problemDetails.Title.ShouldBe("A business error occur."); problemDetails.Detail.ShouldBe("Customer with this name already exists."); } diff --git a/tests/Wax.IntegrationTests/Customers/CustomerTests.cs b/tests/Wax.IntegrationTests/Customers/CustomerTests.cs index 4d5d7ca..4501062 100644 --- a/tests/Wax.IntegrationTests/Customers/CustomerTests.cs +++ b/tests/Wax.IntegrationTests/Customers/CustomerTests.cs @@ -3,8 +3,8 @@ using Mediator.Net; using Shouldly; using Wax.Core.Exceptions; +using Wax.Core.Repositories; using Wax.Messages.Commands.Customers; -using Wax.Messages.Requests.Customers; using Xunit; namespace Wax.IntegrationTests.Customers; @@ -18,7 +18,7 @@ public CustomerTests(IntegrationFixture fixture) : base(fixture) [Fact] public async Task ShouldCreateNewCustomer() { - await Run(async mediator => + await Run(async (mediator, repo) => { var createCustomerCommand = new CreateCustomerCommand { @@ -30,14 +30,10 @@ await Run(async mediator => var createCustomerResponse = await mediator.SendAsync( createCustomerCommand); - var getCustomerResponse = await mediator.RequestAsync( - new GetCustomerRequest - { - CustomerId = createCustomerResponse.CustomerId - }); + var customer = await repo.GetByIdAsync(createCustomerResponse.CustomerId); - getCustomerResponse.Customer.Name.ShouldBe(createCustomerCommand.Name); - getCustomerResponse.Customer.Address.ShouldBe(createCustomerCommand.Address); + customer.Name.ShouldBe(createCustomerCommand.Name); + customer.Address.ShouldBe(createCustomerCommand.Address); }); } @@ -46,7 +42,7 @@ public async Task ShouldUpdateCustomer() { var customerId = await CreateDefaultCustomer(); - await Run(async mediator => + await Run(async (mediator, repo) => { var updateCustomerCommand = new UpdateCustomerCommand { @@ -58,14 +54,10 @@ await Run(async mediator => await mediator.SendAsync(updateCustomerCommand); - var getCustomerResponse = await mediator.RequestAsync( - new GetCustomerRequest - { - CustomerId = updateCustomerCommand.CustomerId - }); + var customer = await repo.GetByIdAsync(customerId); - getCustomerResponse.Customer.Name.ShouldBe(updateCustomerCommand.Name); - getCustomerResponse.Customer.Address.ShouldBe(updateCustomerCommand.Address); + customer.Name.ShouldBe(updateCustomerCommand.Name); + customer.Address.ShouldBe(updateCustomerCommand.Address); }); } @@ -74,7 +66,7 @@ public async Task ShouldDeleteCustomer() { var customerId = await CreateDefaultCustomer(); - await Run(async mediator => + await Run(async (mediator, repo) => { await mediator.SendAsync(new DeleteCustomerCommand { @@ -82,11 +74,7 @@ await mediator.SendAsync(new DeleteCustomerCommand }); await Should.ThrowAsync(async () => - await mediator.RequestAsync( - new GetCustomerRequest - { - CustomerId = customerId - })); + await repo.GetByIdAsync(customerId)); }); } diff --git a/tests/Wax.IntegrationTests/IntegrationCollection.cs b/tests/Wax.IntegrationTests/IntegrationCollection.cs new file mode 100644 index 0000000..307e969 --- /dev/null +++ b/tests/Wax.IntegrationTests/IntegrationCollection.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace Wax.IntegrationTests; + +[CollectionDefinition("Sequential")] +public class IntegrationCollection : ICollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} \ No newline at end of file diff --git a/tests/Wax.IntegrationTests/IntegrationFixture.cs b/tests/Wax.IntegrationTests/IntegrationFixture.cs index d6bf12e..0753573 100644 --- a/tests/Wax.IntegrationTests/IntegrationFixture.cs +++ b/tests/Wax.IntegrationTests/IntegrationFixture.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Threading.Tasks; using Autofac; using Microsoft.Extensions.Configuration; using NSubstitute; @@ -10,7 +11,7 @@ namespace Wax.IntegrationTests; -public class IntegrationFixture : IDisposable, ICollectionFixture +public class IntegrationFixture : IAsyncLifetime { public readonly ILifetimeScope LifetimeScope; @@ -29,23 +30,14 @@ public IntegrationFixture() LifetimeScope = containerBuilder.Build(); } - public void Cleanup() + public Task InitializeAsync() { - var dbContext = LifetimeScope.Resolve(); - dbContext.Database.EnsureDeleted(); + return Task.CompletedTask; } - public void Dispose() + public Task DisposeAsync() { - var dbContext = LifetimeScope.Resolve(); - dbContext.Database.EnsureDeleted(); + var context = LifetimeScope.Resolve(); + return context.Database.EnsureDeletedAsync(); } -} - -[CollectionDefinition("Sequential")] -public class DatabaseCollection : ICollectionFixture -{ - // This class has no code, and is never created. Its purpose is simply - // to be the place to apply [CollectionDefinition] and all the - // ICollectionFixture<> interfaces. } \ No newline at end of file diff --git a/tests/Wax.IntegrationTests/IntegrationTestBase.cs b/tests/Wax.IntegrationTests/IntegrationTestBase.cs index 0d0a2eb..b49c88a 100644 --- a/tests/Wax.IntegrationTests/IntegrationTestBase.cs +++ b/tests/Wax.IntegrationTests/IntegrationTestBase.cs @@ -2,40 +2,56 @@ using System.Threading.Tasks; using Autofac; using Wax.Core.Data; -using Wax.Core.Repositories; using Xunit; namespace Wax.IntegrationTests; [Collection("Sequential")] -public class IntegrationTestBase : IDisposable +public class IntegrationTestBase : IAsyncLifetime { - private readonly IntegrationFixture _fixture; + private readonly ILifetimeScope _lifetimeScope; protected IntegrationTestBase(IntegrationFixture fixture) { - _fixture = fixture; + _lifetimeScope = fixture.LifetimeScope; } protected async Task Run(Func action, Action extraRegistration = null) { var dependency = extraRegistration != null - ? _fixture.LifetimeScope.BeginLifetimeScope(extraRegistration).Resolve() - : _fixture.LifetimeScope.BeginLifetimeScope().Resolve(); + ? _lifetimeScope.BeginLifetimeScope(extraRegistration).Resolve() + : _lifetimeScope.BeginLifetimeScope().Resolve(); await action(dependency); } + protected Task Run(Func action, Action extraRegistration = null) + { + var lifetime = extraRegistration != null + ? _lifetimeScope.BeginLifetimeScope(extraRegistration) + : _lifetimeScope.BeginLifetimeScope(); + var dependency = lifetime.Resolve(); + var dependency2 = lifetime.Resolve(); + return action(dependency, dependency2); + } + protected async Task Run(Func> action, Action extraRegistration = null) { var dependency = extraRegistration != null - ? _fixture.LifetimeScope.BeginLifetimeScope(extraRegistration).Resolve() - : _fixture.LifetimeScope.BeginLifetimeScope().Resolve(); + ? _lifetimeScope.BeginLifetimeScope(extraRegistration).Resolve() + : _lifetimeScope.BeginLifetimeScope().Resolve(); return await action(dependency); } - public void Dispose() + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() { - _fixture.Cleanup(); + //If have a real database, only need to clean the table after each test. + var context = _lifetimeScope.Resolve(); + return context.Database.EnsureDeletedAsync(); } } \ No newline at end of file diff --git a/tests/Wax.UnitTests/Customers/CreateCustomerTests.cs b/tests/Wax.UnitTests/Customers/CreateCustomerTests.cs index 1d66bfb..be5c8a8 100644 --- a/tests/Wax.UnitTests/Customers/CreateCustomerTests.cs +++ b/tests/Wax.UnitTests/Customers/CreateCustomerTests.cs @@ -18,7 +18,7 @@ public class CreateCustomerTests : CustomerTestFixture public CreateCustomerTests() { - _handler = new CreateCustomerCommandHandler(Mapper, Customers); + _handler = new CreateCustomerCommandHandler(Mapper, CustomerRepository); } [Fact] @@ -29,7 +29,7 @@ public async Task ShouldNotCreateCustomerWhenNameAlreadyExists() Name = "microsoft" }; - Customers.FindByNameAsync(command.Name).Returns(new Customer { Name = "google" }); + CustomerRepository.IsUniqueAsync(command.Name).Returns(false); await Should.ThrowAsync(async () => await _handler.Handle(new ReceiveContext(command), CancellationToken.None)); @@ -44,10 +44,10 @@ public async Task ShouldCallInsert() Contact = "+861306888888" }; - Customers.FindByNameAsync(command.Name).ReturnsNull(); + CustomerRepository.IsUniqueAsync(command.Name).Returns(true); await _handler.Handle(new ReceiveContext(command), CancellationToken.None); - await Customers.Received().InsertAsync(Arg.Any()); + await CustomerRepository.Received().InsertAsync(Arg.Any()); } } \ No newline at end of file diff --git a/tests/Wax.UnitTests/Customers/CustomerTestFixture.cs b/tests/Wax.UnitTests/Customers/CustomerTestFixture.cs index 7b547ab..43a5448 100644 --- a/tests/Wax.UnitTests/Customers/CustomerTestFixture.cs +++ b/tests/Wax.UnitTests/Customers/CustomerTestFixture.cs @@ -9,11 +9,11 @@ namespace Wax.UnitTests.Customers; public class CustomerTestFixture { protected readonly IMapper Mapper; - protected readonly ICustomerRepository Customers; + protected readonly ICustomerRepository CustomerRepository; protected CustomerTestFixture() { Mapper = new MapperConfiguration(x => x.AddProfile(new CustomerProfile())).CreateMapper(); - Customers = Substitute.For(); + CustomerRepository = Substitute.For(); } } \ No newline at end of file diff --git a/tests/Wax.UnitTests/Customers/GetCustomerTests.cs b/tests/Wax.UnitTests/Customers/GetCustomerTests.cs deleted file mode 100644 index 98220ea..0000000 --- a/tests/Wax.UnitTests/Customers/GetCustomerTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Mediator.Net.Context; -using NSubstitute; -using Shouldly; -using Wax.Core.Domain.Customers; -using Wax.Core.Handlers.RequestHandlers.Customers; -using Wax.Messages.Requests.Customers; -using Xunit; - -namespace Wax.UnitTests.Customers; - -public class GetCustomerTests : CustomerTestFixture -{ - private readonly GetCustomerRequestHandler _handler; - - public GetCustomerTests() - { - _handler = new GetCustomerRequestHandler(Mapper, Customers); - } - - [Fact] - public async Task ShouldGetCustomer() - { - var customer = new Customer - { - Id = Guid.NewGuid(), - Name = "microsoft", - Address = "HuiZhou, Guangdong Province, China", - Contact = "+861306888888" - }; - - Customers.GetByIdAsync(customer.Id).Returns(Task.FromResult(customer)); - - var response = await _handler.Handle(new ReceiveContext( - new GetCustomerRequest - { - CustomerId = customer.Id - }), CancellationToken.None); - - response.Customer.ShouldNotBeNull(); - response.Customer.Id.ShouldBe(customer.Id); - response.Customer.Name.ShouldBe(customer.Name); - response.Customer.Address.ShouldBe(customer.Address); - } -} \ No newline at end of file diff --git a/tests/Wax.UnitTests/Customers/UpdateCustomerTests.cs b/tests/Wax.UnitTests/Customers/UpdateCustomerTests.cs index 1c77cb0..5bc2823 100644 --- a/tests/Wax.UnitTests/Customers/UpdateCustomerTests.cs +++ b/tests/Wax.UnitTests/Customers/UpdateCustomerTests.cs @@ -19,7 +19,7 @@ public class UpdateCustomerTests : CustomerTestFixture public UpdateCustomerTests() { - _handler = new UpdateCustomerCommandHandler(Mapper, Customers); + _handler = new UpdateCustomerCommandHandler(Mapper, CustomerRepository); } [Fact] @@ -31,10 +31,10 @@ public async Task ShouldNotUpdateCustomerWhenNameAlreadyExists() Name = "microsoft" }; - Customers.GetByIdAsync(command.CustomerId) + CustomerRepository.GetByIdAsync(command.CustomerId) .Returns(new Customer { Id = command.CustomerId, Name = "google" }); - Customers.FindByNameAsync(command.Name).Returns(new Customer { Name = "meta" }); + CustomerRepository.IsUniqueAsync(command.Name).Returns(false); await Should.ThrowAsync(async () => await _handler.Handle(new ReceiveContext(command), CancellationToken.None)); @@ -52,14 +52,14 @@ public async Task ShouldUpdateCustomer() Contact = "+861306888888" }; - Customers.GetByIdAsync(command.CustomerId).Returns(customer); - Customers.FindByNameAsync(command.Name).ReturnsNull(); + CustomerRepository.GetByIdAsync(command.CustomerId).Returns(customer); + CustomerRepository.IsUniqueAsync(command.Name).Returns(true); await _handler.Handle(new ReceiveContext(command), CancellationToken.None); customer.Name.ShouldBe(command.Name); customer.Contact.ShouldBe(command.Contact); - await Customers.Received().UpdateAsync(Arg.Any()); + await CustomerRepository.Received().UpdateAsync(Arg.Any()); } } \ No newline at end of file