CoffeShop cz. 3 – Kontrolery i CQRS
Rozbudujemy nasze kontrolery o następujące opcje:
- dodawania nowych zamówień,
- aktualizacji stanu zamówienia.
- poszczególnego zamówienia,
- wyświetlania listy zamówień,
0. Na początek stworzymy kontroler:
namespace Presentation.Controllers { public class OrdersController : BaseController { [HttpGet] public async Task<OrdersListVm> GetAll() { return await Mediator.Send(new GetAllOrdersQuery()); } [HttpGet("{id}")] public async Task<OrderDetailVm> GetById(int id) { return await Mediator.Send(new GetOrderDetailQuery() { Id = id }); } [HttpPost] public async Task<int> Create([FromBody] CreateOrderCommand command) { return await Mediator.Send(command); } /// <summary> /// Update Order Status by one step /// </summary> [HttpPut("{id}")] public async Task<Unit> UpdateStatus(int id) { return await Mediator.Send(new UpdateOrderStatusCommand() { OrderId = id }); } } }
Dodałem nad ostatnią metodą komentarz którzy będzie wyświetlony w Swagger po wcześniejszej konfiguracji.
W Startup.cs w metodzie ConfigureServices dodajemy podświetlone linijki. Dodatkowo możemy dodać opcje żeby dla naszego enum OrderStatus pola były nie pokazywane w Swaggerze jako inty tylko jako stringi.
#region Swagger services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Version = "v1", Title = "CoffeShop API", Description = "*description*", }); var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); c.IncludeXmlComments(xmlPath); }); #endregion services.AddControllers() .AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
1. Dodamy klase obsługującą składanie nowych zamówień.
public class CreateOrderCommand : IRequest<int> { public IEnumerable<OrderDetailOtd> Details { get; set; } public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int> { public readonly IContext _context; public readonly IMapper _mapper; public CreateOrderCommandHandler(IContext context, IMapper mapper) => (_context, _mapper) = (context, mapper); public async Task<int> Handle(CreateOrderCommand request, CancellationToken cancellationToken) { var details = request.Details.Select(o => new OrderDetail { Quantity = o.Quantity, Product = _context.Products.First(p => p.Id == o.ProductId), AdditionalInfo = o.AdditionalInfo }).ToList(); foreach (var detail in details) { detail.UnitPrice = detail.Product.Price * detail.Quantity; detail.UnitTimeToPrepare = detail.Product.TimeToPrepare * detail.Quantity; } var order = new Order { Status = OrderStatus.NotStarted, OrderPlaced = DateTime.UtcNow, OrderCompleted = null, }.AddOrderDetails(details); var entry = _context.Orders.Add(order); await _context.SaveChangesAsync(); return entry.Entity.Id; } } } public class OrderDetailOtd { public int ProductId { get; set; } public int Quantity { get; set; } public string AdditionalInfo { get; set; } } }
Nie miałem pomysłu jak nazwać klase która ma być naszym typem generycznym dla IEnumerable więc postawiłem na końcówke Otd, bo tak jak mamy klasy Dto czyli Data-To-Object które używamy do przekazania użytkownikowi tylko części informacji z elementu bazy tak pomyślałem że coś co ma działać odwrotnie będzie właśnie Otd Object-To-Data. 🤔
Sprawdzimy sobie w Swagger jak to działa.
Widzimy że wszystko działa, możemy nawet sprawdzić w bazie jak to wygląda.
2. Następnie dodamy opcje aktualizacji statusu zamówienia.
namespace Application.Orders.Command.UpdateOrderStatusCommand { public class UpdateOrderStatusCommand : IRequest { public int OrderId { get; set; } public class UpdateOrderStatusCommandHandler : IRequestHandler<UpdateOrderStatusCommand> { public readonly IContext _context; public UpdateOrderStatusCommandHandler(IContext context) => (_context) = (context); public async Task<Unit> Handle(UpdateOrderStatusCommand request, CancellationToken cancellationToken) { var order = _context.Orders.Find(request.OrderId); if (order == null) { return Unit.Value; } if (order.Status != OrderStatus.Completed) { order.Status += 1; if (order.Status == OrderStatus.Completed) { order.OrderCompleted = DateTime.UtcNow; } _context.Orders.Update(order); } await _context.SaveChangesAsync(); return Unit.Value; } } } }
Program sprawdza najpierw czy zamówienie istnieje, jeśli tak sprawdza czy nie ma już statusu ukończonego i dopiero wtedy modyfikuje go o jeden krok. Jeśli zamówienie miało stan jeden przed Completed i ma zostać zakualizowane zostaje do niego dodana godzina skompletowania.
Testujemy przez swagger podając Id zamówienia którego stan chcemy zaktualizować:
I sprawdzamy w bazie:
3. Nastepnie dodamy opcje sprawdzenia szczegółów pojedynczego zamówienia.
namespace Application.Orders.Query.GetOrderQuery { public class GetOrderDetailQuery : IRequest<OrderDetailVm> { public int Id { get; set; } public class GetOrderDetailQueryHandler : IRequestHandler<GetOrderDetailQuery, OrderDetailVm> { public readonly IContext _context; public readonly IMapper _mapper; private const int TIP_PERCENTAGE = 10; private const int TIP_THRESHOLD = 5; public GetOrderDetailQueryHandler(IContext context, IMapper mapper) => (_context, _mapper) = (context, mapper); public async Task<OrderDetailVm> Handle(GetOrderDetailQuery request, CancellationToken cancellationToken) { var order = _context.Orders.AsNoTracking() .Include(order => order.OrderDetails) .First(order => order.Id == request.Id); var dto = order.OrderDetails .Select(d => new OrderDetailDto { ProductName = _context.Products.First(p => p.Id == d.ProductId).Name, UnitPrice = d.UnitPrice, UnitTimeToPrepare = d.UnitTimeToPrepare, Quantity = d.Quantity }); var vm = new OrderDetailVm { OrderId = request.Id, Status = order.Status, Details = dto, TotalPrice = order.OrderDetails.Select(d => d.UnitPrice).Sum(), TotalTimeToPrepare = order.OrderDetails.Select(d => d.UnitTimeToPrepare).Sum(), TipPercentage = 0, }; var quantity = dto.Select(o => o.Quantity).Sum(); if (quantity > TIP_THRESHOLD) { vm.TipPercentage = TIP_PERCENTAGE; vm.TotalPrice = vm.TotalPrice * (1 + (vm.TipPercentage / 100m)); } return vm; } } } public class OrderDetailDto : IMapFrom<OrderDetail> { public string ProductName { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } public int UnitTimeToPrepare { get; set; } public void Mapping(Profile profile) { profile.CreateMap<OrderDetail, OrderDetailDto>() .ForMember(vm => vm.ProductName, opt => opt.MapFrom(s => s.Product.Name)) .ForMember(vm => vm.Quantity, opt => opt.MapFrom(s => s.Quantity)) .ForMember(vm => vm.UnitPrice, opt => opt.MapFrom(s => s.UnitPrice)) .ForMember(vm => vm.UnitTimeToPrepare, opt => opt.MapFrom(s => s.UnitTimeToPrepare)); } } public class OrderDetailVm { public int OrderId { get; set; } public decimal TotalPrice { get; set; } public int TotalTimeToPrepare { get; set; } public int TipPercentage { get; set; } public OrderStatus Status { get; set; } public IEnumerable<OrderDetailDto> Details { get; set; } } }
Jak wynika z powyższego kodu dla zamówień powyżej 5 sztuk będzie doliczony napiwek w wysokości 10% od całkowietej kwoty zamówienia. Zeby to przestestować dodam nowe zamówienie w postaci:
{ "details": [ { "productId": 3, "quantity": 2, "additionalInfo": "test" }, { "productId": 7, "quantity": 4, "additionalInfo": "test" } ] }
Pobierzemy teraz szczegóły tego zamówienia:
Widzimy do ceny zamówienie zostało doliczone 10% napiwku na co wskazuje także pole tipPercentage, dostajemy także informacje o całkowitym czasie na przygotowanie całego zamówienia.
4. Pobranie uproszczonej listy wszystkich zamówień.
namespace Application.Orders.Query.GetAllOrdersQuery { public class GetAllOrdersQuery : IRequest<OrdersListVm> { public class GetAllOrdersQueryHandler : IRequestHandler<GetAllOrdersQuery, OrdersListVm> { private readonly IContext _context; private readonly IMapper _mapper; public GetAllOrdersQueryHandler(IContext context, IMapper mapper) => (_context, _mapper) = (context, mapper); public async Task<OrdersListVm> Handle(GetAllOrdersQuery request, CancellationToken cancellationToken) { var orders = await _context.Orders.AsNoTracking() .ProjectTo<OrderDto>(_mapper.ConfigurationProvider) .ToListAsync(cancellationToken); var vm = new OrdersListVm { Orders = orders }; return vm; } } } public class OrderDto : IMapFrom<Order> { public int Id { get; set; } public DateTime OrderPlaced { get; set; } public DateTime? OrderCompleted { get; set; } public OrderStatus Status { get; set; } public void Mapping(Profile profile) { profile.CreateMap<Order, OrderDto>() .ForMember(vm => vm.Id, opt => opt.MapFrom(s => s.Id)) .ForMember(vm => vm.Status, opt => opt.MapFrom(s => s.Status)) .ForMember(vm => vm.OrderPlaced, opt => opt.MapFrom(s => s.OrderPlaced)) .ForMember(vm => vm.OrderCompleted, opt => opt.MapFrom(s => s.OrderCompleted)) .ForSourceMember(o => o.OrderDetails, opt => opt.DoNotValidate()); } } public class OrdersListVm { public IList<OrderDto> Orders { get; set; } } }
Dzięki dodaniu opcji AsNoTracking( ) EF Core automatycznie zakoczy transakcje z baz bez oczekiwania na jakiekolwiek zmiany.
Testujemy w Swaggerze.
Widzimy dostajemy uproszczoną mocno liste z której wiemy jedynie o której godzinie zostało złożone i skompletowane zamówienie.
Aby dowiedzieć się o wadach i zaletach CQRS polecam: https://www.programmingwithwolfgang.com/cqrs-in-asp-net-core-3-1/
Cały kod zamieszczam na github: https://github.com/MichaelStett/CoffeShop
Dziękuje za Twoją uwagę i do następnego 😀