diff --git a/src/Services/Order.Command/Order.Command.API/Endpoints/GetOrderById/Endpoint.cs b/src/Services/Order.Command/Order.Command.API/Endpoints/GetOrderById/Endpoint.cs new file mode 100644 index 0000000..da699e3 --- /dev/null +++ b/src/Services/Order.Command/Order.Command.API/Endpoints/GetOrderById/Endpoint.cs @@ -0,0 +1,37 @@ +using Order.Command.Application.Orders.Queries.GetOrderById; + +namespace Order.Command.API.Endpoints.GetOrderById; + +public class Endpoint : EndpointBase +{ + public override void MapEndpoint() + { + Get("/orders/{id}", HandleAsync); + Name("GetOrdersById"); + Produces(); + ProducesProblem(StatusCodes.Status400BadRequest); + ProducesProblem(StatusCodes.Status404NotFound); + Summary("Gets orders by Id."); + Description("Gets orders by Id"); + } + + public override async Task HandleAsync(Request request) + { + var query = MapToQuery(request); + var result = await SendAsync(query).ConfigureAwait(false); + Context.Response.Headers.ETag = $"W/\"{result.Order.Version}\""; + + var response = MapToResponse(result); + return TypedResults.Ok(response); + } + + private static GetOrdersByIdQuery MapToQuery(Request request) + { + return new GetOrdersByIdQuery(request.Id); + } + + private static Response MapToResponse(GetOrdersByIdResult result) + { + return new Response(result.Order); + } +} \ No newline at end of file diff --git a/src/Services/Order.Command/Order.Command.API/Endpoints/GetOrderById/Models.cs b/src/Services/Order.Command/Order.Command.API/Endpoints/GetOrderById/Models.cs new file mode 100644 index 0000000..78580bd --- /dev/null +++ b/src/Services/Order.Command/Order.Command.API/Endpoints/GetOrderById/Models.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; +using Order.Command.Application.Orders.Queries.GetOrderById; + +namespace Order.Command.API.Endpoints.GetOrderById; + +public record Request +{ + [FromRoute(Name = "id")] public string? Id { get; set; } +} + +public record Response(GetOrderByIdDto Order); \ No newline at end of file diff --git a/src/Services/Order.Command/Order.Command.Application/Dtos/OrderDto.cs b/src/Services/Order.Command/Order.Command.Application/Dtos/OrderDto.cs index 214d4fe..90243d7 100644 --- a/src/Services/Order.Command/Order.Command.Application/Dtos/OrderDto.cs +++ b/src/Services/Order.Command/Order.Command.Application/Dtos/OrderDto.cs @@ -3,7 +3,8 @@ namespace Order.Command.Application.Dtos; public record OrderDto( - [property: JsonPropertyName("id")] Ulid Id, + [property: JsonPropertyName("id")] + Ulid Id, [property: JsonPropertyName("customer_id")] Guid? CustomerId, [property: JsonPropertyName("order_name")] @@ -14,6 +15,7 @@ public record OrderDto( AddressDto BillingAddress, [property: JsonPropertyName("payment")] PaymentDto Payment, - [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("status")] + string Status, [property: JsonPropertyName("order_items")] List OrderItems); \ No newline at end of file diff --git a/src/Services/Order.Command/Order.Command.Application/Orders/Queries/GetOrderById/GetOrderByIdDto.cs b/src/Services/Order.Command/Order.Command.Application/Orders/Queries/GetOrderById/GetOrderByIdDto.cs new file mode 100644 index 0000000..48d7f3a8 --- /dev/null +++ b/src/Services/Order.Command/Order.Command.Application/Orders/Queries/GetOrderById/GetOrderByIdDto.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace Order.Command.Application.Orders.Queries.GetOrderById; + +public record GetOrderByIdDto( + [property: JsonPropertyName("id")] + Ulid Id, + [property: JsonPropertyName("customer_id")] + Guid? CustomerId, + [property: JsonPropertyName("order_name")] + string OrderName, + [property: JsonPropertyName("shipping_Address")] + AddressDto ShippingAddress, + [property: JsonPropertyName("billing_address")] + AddressDto BillingAddress, + [property: JsonPropertyName("payment")] + PaymentDto Payment, + [property: JsonPropertyName("status")] + string Status, + [property: JsonPropertyName("version")] + int Version, + [property: JsonPropertyName("order_items")] + List OrderItems); \ No newline at end of file diff --git a/src/Services/Order.Command/Order.Command.Application/Orders/Queries/GetOrderById/GetOrderByIdQueryHandler.cs b/src/Services/Order.Command/Order.Command.Application/Orders/Queries/GetOrderById/GetOrderByIdQueryHandler.cs new file mode 100644 index 0000000..b688cae --- /dev/null +++ b/src/Services/Order.Command/Order.Command.Application/Orders/Queries/GetOrderById/GetOrderByIdQueryHandler.cs @@ -0,0 +1,62 @@ +using BuildingBlocks.Exceptions; +using Order.Command.Application.Exceptions; + +namespace Order.Command.Application.Orders.Queries.GetOrderById; + +public record GetOrdersByIdQuery(string? Id) : IQuery; + +public record GetOrdersByIdResult(GetOrderByIdDto Order); + +public class GetOrderByIdHandler(IApplicationDbContext dbContext) : IQueryHandler +{ + public async Task Handle(GetOrdersByIdQuery request, CancellationToken cancellationToken) + { + var orderId = Ulid.Parse(request.Id); + var order = await dbContext.Orders + .Include(x => x.OrderItems) + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id.Equals(OrderId.From(orderId)), cancellationToken); + + if (order is null) + throw new OrderNotFoundExceptions(orderId); + + var result = MapResult(order); + return new GetOrdersByIdResult(result); + } + + private static GetOrderByIdDto MapResult(Domain.Models.Order order) + { + var result = new GetOrderByIdDto( + order.Id.Value, + order.CustomerId.Value, + order.OrderName.Value, + MapAddress(order.ShippingAddress), + MapAddress(order.BillingAddress), + MapPayment(order.Payment), + order.Status.Value, + order.RowVersion.Value, + MapOrderItems(order.OrderItems)); + + return result; + } + + private static AddressDto MapAddress(Address address) + { + return new AddressDto(address.FirstName, address.LastName, address.EmailAddress, address.AddressLine, + address.Country, + address.State, address.ZipCode); + } + + private static PaymentDto MapPayment(Payment payment) + { + return new PaymentDto(payment.CardName, payment.CardNumber, payment.Expiration, payment.CVV, + payment.PaymentMethod); + } + + private static List MapOrderItems(IReadOnlyCollection orderItems) + { + return orderItems.Select(x => + new OrderItems(x.Id.Value.ToString(), x.OrderId.Value.ToString(), x.ProductId.Value.ToString(), x.Quantity, + x.Price.Value)).ToList(); + } +} \ No newline at end of file diff --git a/src/Services/Order.Command/Order.Command.Application/Orders/Queries/GetOrderById/Validator.cs b/src/Services/Order.Command/Order.Command.Application/Orders/Queries/GetOrderById/Validator.cs new file mode 100644 index 0000000..7cee92c --- /dev/null +++ b/src/Services/Order.Command/Order.Command.Application/Orders/Queries/GetOrderById/Validator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Order.Command.Application.Orders.Queries.GetOrderById; + +public class Validator : AbstractValidator +{ + public Validator() + { + RuleFor(x => x.Id).NotEmpty().Must(x => Ulid.TryParse(x, out _)).WithMessage("Invalid order id"); + } +} \ No newline at end of file diff --git a/src/Services/Order.Command/Order.Command.Domain/Models/Order.cs b/src/Services/Order.Command/Order.Command.Domain/Models/Order.cs index b4dab9c..209df6c 100644 --- a/src/Services/Order.Command/Order.Command.Domain/Models/Order.cs +++ b/src/Services/Order.Command/Order.Command.Domain/Models/Order.cs @@ -14,8 +14,8 @@ public class Order : Aggregate public Payment Payment { get; private set; } = default!; public OrderStatus Status { get; private set; } = default!; - [Timestamp] - public byte[] RowVersion { get; set; } = default!; + [ConcurrencyCheck] + public VersionId RowVersion { get; set; } = default!; public Price TotalPrice { @@ -25,7 +25,7 @@ public Price TotalPrice public Order Create( OrderId id, - CustomerId? customerId, + CustomerId customerId, OrderName orderName, Address shippingAddress, Address billingAddress, @@ -41,6 +41,7 @@ public Order Create( BillingAddress = billingAddress, Payment = payment, Status = OrderStatus.Pending, + RowVersion = VersionId.InitialVersion }; order._orderItems.AddRange(orderItems?.Select(x => new OrderItem(id, x.ProductId, x.Quantity, x.Price)).ToArray() ?? []); order.AddDomainEvent(new OrderCreatedEvent(order)); diff --git a/src/Services/Order.Command/Order.Command.Domain/Models/ValueObjects/ValueObjects.cs b/src/Services/Order.Command/Order.Command.Domain/Models/ValueObjects/ValueObjects.cs index 6839cb7..964e891 100644 --- a/src/Services/Order.Command/Order.Command.Domain/Models/ValueObjects/ValueObjects.cs +++ b/src/Services/Order.Command/Order.Command.Domain/Models/ValueObjects/ValueObjects.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using ValueOf; namespace Order.Command.Domain.Models.ValueObjects; @@ -18,6 +19,26 @@ public class OrderItemId : ValueOf { } +public partial class VersionId : ValueOf +{ + public static VersionId InitialVersion => From(1); + + public static VersionId FromWeakEtag(string etag) + { + if (string.IsNullOrWhiteSpace(etag) || !EtagRegex().IsMatch(etag)) + { + throw new InvalidOperationException($"Invalid Etag value: {etag}."); + } + + return From(int.Parse(etag[3..^1])); + } + + public VersionId Increment() => From(Value + 1); + + [GeneratedRegex("""^W\/"\d+"$""")] + private static partial Regex EtagRegex(); +} + public class ProductName : ValueOf { public static ProductName? FromNullable(string? value) diff --git a/src/Services/Order.Command/Order.Command.Infrastructure/Data/Configuration/OrderConfiguration.cs b/src/Services/Order.Command/Order.Command.Infrastructure/Data/Configuration/OrderConfiguration.cs index 5b3a167..8e973ac 100644 --- a/src/Services/Order.Command/Order.Command.Infrastructure/Data/Configuration/OrderConfiguration.cs +++ b/src/Services/Order.Command/Order.Command.Infrastructure/Data/Configuration/OrderConfiguration.cs @@ -14,8 +14,10 @@ public void Configure(EntityTypeBuilder builder) id => id.ToString(), dbId => OrderId.From(Ulid.Parse(dbId))); - builder.Property(p => p.RowVersion) - .IsRowVersion(); + builder.Property(p => p.RowVersion).HasConversion( + versionId => versionId.Value, + dbVersionId => VersionId.From(dbVersionId)) + .IsConcurrencyToken(); builder.Property(x => x.CustomerId).HasConversion( customerId => customerId.Value, diff --git a/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241102205101_Initial.Designer.cs b/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241217213839_Initial.Designer.cs similarity index 97% rename from src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241102205101_Initial.Designer.cs rename to src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241217213839_Initial.Designer.cs index dbdf3f6..f394069 100644 --- a/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241102205101_Initial.Designer.cs +++ b/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241217213839_Initial.Designer.cs @@ -13,7 +13,7 @@ namespace Order.Command.Infrastructure.Data.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20241102205101_Initial")] + [Migration("20241217213839_Initial")] partial class Initial { /// @@ -51,11 +51,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("nvarchar(100)"); - b.Property("RowVersion") + b.Property("RowVersion") .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("rowversion"); + .HasColumnType("int"); b.Property("Status") .IsRequired() diff --git a/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241102205101_Initial.cs b/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241217213839_Initial.cs similarity index 98% rename from src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241102205101_Initial.cs rename to src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241217213839_Initial.cs index a076102..58a31de 100644 --- a/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241102205101_Initial.cs +++ b/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/20241217213839_Initial.cs @@ -19,7 +19,7 @@ protected override void Up(MigrationBuilder migrationBuilder) CustomerId = table.Column(type: "uniqueidentifier", nullable: false), OrderName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), Status = table.Column(type: "nvarchar(max)", nullable: false), - RowVersion = table.Column(type: "rowversion", rowVersion: true, nullable: false), + RowVersion = table.Column(type: "int", nullable: false), TotalPrice = table.Column(type: "decimal(18,2)", nullable: false), BillingAddress_AddressLine = table.Column(type: "nvarchar(180)", maxLength: 180, nullable: false), BillingAddress_Country = table.Column(type: "nvarchar(max)", nullable: false), diff --git a/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 98b1ac6..6ae0fa4 100644 --- a/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Services/Order.Command/Order.Command.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -48,11 +48,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("nvarchar(100)"); - b.Property("RowVersion") + b.Property("RowVersion") .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("rowversion"); + .HasColumnType("int"); b.Property("Status") .IsRequired()