diff --git a/src/HelloShop.OrderingService/AutoMapper/OrdersMapConfiguration.cs b/src/HelloShop.OrderingService/AutoMapper/OrdersMapConfiguration.cs new file mode 100644 index 0000000..f46077a --- /dev/null +++ b/src/HelloShop.OrderingService/AutoMapper/OrdersMapConfiguration.cs @@ -0,0 +1,18 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +using AutoMapper; +using HelloShop.OrderingService.Commands.Orders; +using HelloShop.OrderingService.Models.Orders; + +namespace HelloShop.OrderingService.AutoMapper +{ + public class OrdersMapConfiguration : Profile + { + public OrdersMapConfiguration() + { + CreateMap().ForMember(dest => dest.Units, opts => opts.MapFrom(src => src.Quantity)); + CreateMap().ForMember(dest => dest.OrderItems, opt => opt.MapFrom(src => src.Items)); + } + } +} diff --git a/src/HelloShop.OrderingService/Behaviors/LoggingBehavior.cs b/src/HelloShop.OrderingService/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..afb874a --- /dev/null +++ b/src/HelloShop.OrderingService/Behaviors/LoggingBehavior.cs @@ -0,0 +1,22 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +using HelloShop.OrderingService.Extensions; +using MediatR; + +namespace HelloShop.OrderingService.Behaviors +{ + public class LoggingBehavior(ILogger> logger) : IPipelineBehavior where TRequest : IRequest + { + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + logger.LogInformation("Handling command {CommandName} {Request}", request.GetGenericTypeName(), request); + + var response = await next(); + + logger.LogInformation("Command {CommandName} handled {Response}", request.GetGenericTypeName(), response); + + return response; + } + } +} diff --git a/src/HelloShop.OrderingService/Behaviors/ValidatorBehavior.cs b/src/HelloShop.OrderingService/Behaviors/ValidatorBehavior.cs new file mode 100644 index 0000000..2df82a7 --- /dev/null +++ b/src/HelloShop.OrderingService/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,29 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +using FluentValidation; +using HelloShop.OrderingService.Extensions; +using MediatR; + +namespace HelloShop.OrderingService.Behaviors +{ + public class ValidatorBehavior(IEnumerable> validators, ILogger> logger) : IPipelineBehavior where TRequest : IRequest + { + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + string typeName = request.GetGenericTypeName(); + + logger.LogInformation("Validating command {CommandType}", typeName); + + var failures = validators.Select(v => v.Validate(request)).SelectMany(result => result.Errors).Where(error => error != null).ToList(); + + if (failures.Count != 0) + { + logger.LogWarning("Validation errors {CommandType} Command {Command} Errors {ValidationErrors}", typeName, request, failures); + throw new ApplicationException($"Command Validation Errors for type {typeof(TRequest).Name}", new ValidationException("Command Validation exception", failures)); + } + + return await next(); + } + } +} diff --git a/src/HelloShop.OrderingService/Controllers/OrdersController.cs b/src/HelloShop.OrderingService/Controllers/OrdersController.cs new file mode 100644 index 0000000..cfaab74 --- /dev/null +++ b/src/HelloShop.OrderingService/Controllers/OrdersController.cs @@ -0,0 +1,65 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +using AutoMapper; +using HelloShop.OrderingService.Commands; +using HelloShop.OrderingService.Commands.Orders; +using HelloShop.OrderingService.Models.Orders; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Security.Claims; + +namespace HelloShop.OrderingService.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class OrdersController(ILogger logger, IMediator mediator, IMapper mapper) : ControllerBase + { + [HttpPost] + public async Task CreateOrder([FromHeader(Name = "x-request-id")] Guid requestId, CreateOrderRequest request) + { + using (logger.BeginScope(new List> { new("IdentifiedCommandId", requestId) })) + { + string? nameIdentifier = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + string? userName = User.FindFirst(ClaimTypes.Name)?.Value; + + if (!int.TryParse(nameIdentifier, out int userId) || string.IsNullOrWhiteSpace(userName)) + { + throw new InvalidOperationException("User id or name not found in claims."); + } + + CreateOrderCommand createOrderCommand = mapper.Map(request, opts => opts.AfterMap((src, dest) => + { + dest.UserId = userId; + dest.UserName = userName; + })); + + var createOrderIdentifiedCommand = new IdentifiedCommand(createOrderCommand, requestId); + + logger.LogInformation("Sending create order command."); + + try + { + var result = await mediator.Send(createOrderIdentifiedCommand); + return result ? Ok() : Problem(detail: "Create order failed to process.", statusCode: 500); + + } + catch (ApplicationException ex) when (ex.InnerException is FluentValidation.ValidationException validationException) + { + logger.LogWarning("Validation error in create order command."); + + ModelStateDictionary modelState = validationException.Errors.Aggregate(ModelState, (acc, error) => + { + acc.AddModelError(error.PropertyName, error.ErrorMessage); + return acc; + }); + + return ValidationProblem(modelState); + } + } + } + } +} diff --git a/src/HelloShop.OrderingService/Controllers/WeatherForecastController.cs b/src/HelloShop.OrderingService/Controllers/WeatherForecastController.cs deleted file mode 100644 index 931046f..0000000 --- a/src/HelloShop.OrderingService/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) HelloShop Corporation. All rights reserved. -// See the license file in the project root for more information. - -using Microsoft.AspNetCore.Mvc; - -namespace HelloShop.OrderingService.Controllers; - -[ApiController] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } -} diff --git a/src/HelloShop.OrderingService/Extensions/Extensions.cs b/src/HelloShop.OrderingService/Extensions/Extensions.cs index b617b94..4a7d5bd 100644 --- a/src/HelloShop.OrderingService/Extensions/Extensions.cs +++ b/src/HelloShop.OrderingService/Extensions/Extensions.cs @@ -1,6 +1,7 @@ // Copyright (c) HelloShop Corporation. All rights reserved. // See the license file in the project root for more information. +using HelloShop.OrderingService.Behaviors; using HelloShop.OrderingService.Constants; using HelloShop.OrderingService.Infrastructure; using HelloShop.OrderingService.Services; @@ -26,6 +27,8 @@ namespace HelloShop.OrderingService.Extensions builder.Services.AddMediatR(options => { options.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + options.AddBehavior(typeof(LoggingBehavior<,>)); + options.AddBehavior(typeof(ValidatorBehavior<,>)); }); } diff --git a/src/HelloShop.OrderingService/Extensions/GenericTypeExtensions.cs b/src/HelloShop.OrderingService/Extensions/GenericTypeExtensions.cs new file mode 100644 index 0000000..fc49557 --- /dev/null +++ b/src/HelloShop.OrderingService/Extensions/GenericTypeExtensions.cs @@ -0,0 +1,30 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +namespace HelloShop.OrderingService.Extensions +{ + public static class GenericTypeExtensions + { + public static string GetGenericTypeName(this Type type) + { + string typeName; + + if (type.IsGenericType) + { + var genericTypes = string.Join(",", type.GetGenericArguments().Select(t => t.Name).ToArray()); + typeName = $"{type.Name.Remove(type.Name.IndexOf('`'))}<{genericTypes}>"; + } + else + { + typeName = type.Name; + } + + return typeName; + } + + public static string GetGenericTypeName(this object @object) + { + return @object.GetType().GetGenericTypeName(); + } + } +} diff --git a/src/HelloShop.OrderingService/Models/Orders/BasketItem.cs b/src/HelloShop.OrderingService/Models/Orders/BasketItem.cs new file mode 100644 index 0000000..c39b3ab --- /dev/null +++ b/src/HelloShop.OrderingService/Models/Orders/BasketItem.cs @@ -0,0 +1,18 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +namespace HelloShop.OrderingService.Models.Orders +{ + public class BasketItem + { + public int ProductId { get; set; } + + public int Quantity { get; set; } + + public required string ProductName { get; set; } + + public required string PictureUrl { get; set; } + + public decimal UnitPrice { get; set; } + } +} diff --git a/src/HelloShop.OrderingService/Models/Orders/CreateOrderRequest.cs b/src/HelloShop.OrderingService/Models/Orders/CreateOrderRequest.cs new file mode 100644 index 0000000..19c9ee0 --- /dev/null +++ b/src/HelloShop.OrderingService/Models/Orders/CreateOrderRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +namespace HelloShop.OrderingService.Models.Orders +{ + public class CreateOrderRequest + { + public required List Items { get; init; } + + public required string Country { get; init; } + + public required string State { get; init; } + + public required string City { get; init; } + + public required string Street { get; init; } + + public required string ZipCode { get; init; } + + public required string CardAlias { get; init; } + + public required string CardNumber { get; init; } + + public required string CardHolderName { get; init; } + + public string? CardSecurityNumber { get; init; } + + public DateTimeOffset? CardExpiration { get; init; } + } +} diff --git a/src/HelloShop.OrderingService/Validations/Commands/CreateOrderCommandValidator.cs b/src/HelloShop.OrderingService/Validations/Commands/CreateOrderCommandValidator.cs new file mode 100644 index 0000000..36c8d76 --- /dev/null +++ b/src/HelloShop.OrderingService/Validations/Commands/CreateOrderCommandValidator.cs @@ -0,0 +1,41 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +using FluentValidation; +using HelloShop.OrderingService.Commands.Orders; + +namespace HelloShop.OrderingService.Validations.Commands +{ + public class CreateOrderCommandValidator : AbstractValidator + { + public CreateOrderCommandValidator() + { + RuleFor(x => x.UserId).GreaterThan(0); + RuleFor(x => x.UserName).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.OrderItems).NotNull().NotEmpty(); + RuleForEach(x => x.OrderItems).SetValidator(new BasketListItemValidator()); + RuleFor(x => x.Country).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.State).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.City).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.Street).NotNull().NotEmpty().MaximumLength(32); + RuleFor(x => x.ZipCode).NotNull().NotEmpty().Length(6).Must(x => x.All(char.IsNumber)); + RuleFor(x => x.CardAlias).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.CardNumber).NotNull().NotEmpty().Length(16).Must(x => x.All(char.IsNumber)); + RuleFor(x => x.CardHolderName).NotNull().NotEmpty().Length(1, 8); + RuleFor(x => x.CardSecurityNumber).NotNull().NotEmpty().Length(6).Must(x => x.All(char.IsNumber)); + RuleFor(x => x.CardExpiration).Must(x => x.HasValue && x.Value > DateTimeOffset.Now); + } + + public class BasketListItemValidator : AbstractValidator + { + public BasketListItemValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.ProductName).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.PictureUrl).NotNull().NotEmpty().Length(1, 256); + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.UnitPrice).GreaterThan(0); + } + } + } +} diff --git a/src/HelloShop.OrderingService/Validations/Commands/CreateOrderIdentifiedCommandValidator.cs b/src/HelloShop.OrderingService/Validations/Commands/CreateOrderIdentifiedCommandValidator.cs new file mode 100644 index 0000000..dcccb5c --- /dev/null +++ b/src/HelloShop.OrderingService/Validations/Commands/CreateOrderIdentifiedCommandValidator.cs @@ -0,0 +1,14 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +using FluentValidation; +using HelloShop.OrderingService.Commands; +using HelloShop.OrderingService.Commands.Orders; + +namespace HelloShop.OrderingService.Validations.Commands +{ + public class CreateOrderIdentifiedCommandValidator : AbstractValidator> + { + public CreateOrderIdentifiedCommandValidator() => RuleFor(command => command.Id).Must(id => id != Guid.Empty).WithMessage("Invalid x-request-id in the request header."); + } +} diff --git a/src/HelloShop.OrderingService/Validations/Models/CreateOrderRequestValidator.cs b/src/HelloShop.OrderingService/Validations/Models/CreateOrderRequestValidator.cs new file mode 100644 index 0000000..990c6cb --- /dev/null +++ b/src/HelloShop.OrderingService/Validations/Models/CreateOrderRequestValidator.cs @@ -0,0 +1,39 @@ +// Copyright (c) HelloShop Corporation. All rights reserved. +// See the license file in the project root for more information. + +using FluentValidation; +using HelloShop.OrderingService.Models.Orders; + +namespace HelloShop.OrderingService.Validations.Models +{ + public class CreateOrderRequestValidator : AbstractValidator + { + public CreateOrderRequestValidator() + { + RuleFor(x => x.Items).NotNull().NotEmpty(); + RuleForEach(x => x.Items).SetValidator(new BasketListItemValidator()); + RuleFor(x => x.Country).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.State).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.City).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.Street).NotNull().NotEmpty().MaximumLength(32); + RuleFor(x => x.ZipCode).NotNull().NotEmpty().Length(6).Must(x => x.All(char.IsNumber)); + RuleFor(x => x.CardAlias).NotNull().NotEmpty().Length(1, 16); + RuleFor(x => x.CardNumber).NotNull().NotEmpty().Length(16).Must(x => x.All(char.IsNumber)); + RuleFor(x => x.CardHolderName).NotNull().NotEmpty().Length(1, 8); + RuleFor(x => x.CardSecurityNumber).NotNull().NotEmpty().Length(6).Must(x => x.All(char.IsNumber)); + RuleFor(x => x.CardExpiration).Must(x => x.HasValue && x.Value > DateTimeOffset.Now); + } + + public class BasketListItemValidator : AbstractValidator + { + public BasketListItemValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.ProductName).Length(2, 16); + RuleFor(x => x.PictureUrl).MaximumLength(256); + RuleFor(x => x.UnitPrice).GreaterThan(0); + } + } + } +} diff --git a/src/HelloShop.OrderingService/WeatherForecast.cs b/src/HelloShop.OrderingService/WeatherForecast.cs deleted file mode 100644 index 2ae343e..0000000 --- a/src/HelloShop.OrderingService/WeatherForecast.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) HelloShop Corporation. All rights reserved. -// See the license file in the project root for more information. - -namespace HelloShop.OrderingService; - -public class WeatherForecast -{ - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } -}