Skip to content

CoffeShop cz. 3 – Kontrolery i CQRS

Post date:

Rozbudujemy nasze kontrolery o następujące opcje:

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 😀