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 😀