diff --git a/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs b/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs index 7a43714b6..016652aeb 100644 --- a/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs +++ b/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs @@ -2,36 +2,22 @@ using MediatR; namespace FSH.Framework.Infrastructure.Behaviours; -public class ValidationBehavior : IPipelineBehavior - where TRequest : notnull +public class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior + where TRequest : IRequest { - private readonly IEnumerable> _validators; - - public ValidationBehavior(IEnumerable> validators) - { - _validators = validators; - } + private readonly IEnumerable> _validators = validators; public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(next); - if (_validators.Any()) { var context = new ValidationContext(request); - - var validationResults = await Task.WhenAll( - _validators.Select(v => - v.ValidateAsync(context, cancellationToken))).ConfigureAwait(false); - - var failures = validationResults - .Where(r => r.Errors.Count > 0) - .SelectMany(r => r.Errors) - .ToList(); + var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken))); + var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList(); if (failures.Count > 0) throw new ValidationException(failures); } - return await next().ConfigureAwait(false); + return await next(); } } diff --git a/api/framework/Infrastructure/Persistence/FshDbContext.cs b/api/framework/Infrastructure/Persistence/FshDbContext.cs index 0182ae7ce..f30fc1966 100644 --- a/api/framework/Infrastructure/Persistence/FshDbContext.cs +++ b/api/framework/Infrastructure/Persistence/FshDbContext.cs @@ -28,6 +28,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) } public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { + this.TenantNotSetMode = TenantNotSetMode.Overwrite; int result = await base.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await PublishDomainEventsAsync().ConfigureAwait(false); return result; diff --git a/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductCommand.cs b/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductCommand.cs new file mode 100644 index 000000000..8e309e587 --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace FSH.WebApi.Catalog.Application.Products.Delete.v1; +public sealed record DeleteProductCommand( + Guid Id) : IRequest; diff --git a/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductHandler.cs b/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductHandler.cs new file mode 100644 index 000000000..de9dacca5 --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductHandler.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Core.Persistence; +using FSH.WebApi.Catalog.Domain; +using FSH.WebApi.Catalog.Domain.Exceptions; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FSH.WebApi.Catalog.Application.Products.Delete.v1; +public sealed class DeleteProductHandler( + ILogger logger, + [FromKeyedServices("catalog:products")] IRepository repository) + : IRequestHandler +{ + public async Task Handle(DeleteProductCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var product = await repository.GetByIdAsync(request.Id, cancellationToken); + _ = product ?? throw new ProductNotFoundException(request.Id); + await repository.DeleteAsync(product, cancellationToken); + logger.LogInformation("product with id : {ProductId} deleted", product.Id); + } +} diff --git a/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductHandler.cs b/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductHandler.cs new file mode 100644 index 000000000..615ba707d --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductHandler.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; +using FSH.WebApi.Catalog.Domain.Exceptions; +using FSH.Framework.Core.Persistence; +using FSH.Framework.Core.Caching; +using FSH.WebApi.Catalog.Domain; +using MediatR; + +namespace FSH.WebApi.Catalog.Application.Products.Get.v1; +public sealed class GetProductHandler( + [FromKeyedServices("catalog:products")] IReadRepository repository, + ICacheService cache) + : IRequestHandler +{ + public async Task Handle(GetProductRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var item = await cache.GetOrSetAsync( + $"product:{request.Id}", + async () => + { + var productItem = await repository.GetByIdAsync(request.Id, cancellationToken); + if (productItem == null) throw new ProductNotFoundException(request.Id); + return new GetProductResponse(productItem.Id, productItem.Name, productItem.Description, productItem.Price); + }, + cancellationToken: cancellationToken); + return item!; + } +} diff --git a/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductRequest.cs b/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductRequest.cs new file mode 100644 index 000000000..59e6b5ae4 --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductRequest.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace FSH.WebApi.Catalog.Application.Products.Get.v1; +public class GetProductRequest : IRequest +{ + public Guid Id { get; set; } + public GetProductRequest(Guid id) => Id = id; +} diff --git a/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductResponse.cs b/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductResponse.cs new file mode 100644 index 000000000..a16c2aa89 --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductResponse.cs @@ -0,0 +1,2 @@ +namespace FSH.WebApi.Catalog.Application.Products.Get.v1; +public sealed record GetProductResponse(Guid? Id, string Name, string? Description, decimal Price); diff --git a/api/modules/Catalog/Catalog.Application/Products/GetList/v1/GetProductListHandler.cs b/api/modules/Catalog/Catalog.Application/Products/GetList/v1/GetProductListHandler.cs new file mode 100644 index 000000000..84f54593d --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/GetList/v1/GetProductListHandler.cs @@ -0,0 +1,22 @@ +using FSH.WebApi.Catalog.Application.Products.Get.v1; +using Microsoft.Extensions.DependencyInjection; +using FSH.Framework.Core.Specifications; +using FSH.Framework.Core.Persistence; +using FSH.WebApi.Catalog.Domain; +using FSH.Framework.Core.Paging; +using MediatR; + + +namespace FSH.WebApi.Catalog.Application.Products.GetList.v1; +public sealed class GetProductListHandler( + [FromKeyedServices("catalog:products")] IReadRepository repository) + : IRequestHandler> +{ + public async Task> Handle(GetProductListRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var spec = new ListSpecification(request.PageNumber, request.PageSize); + var items = await repository.PaginatedListAsync(spec, request.PageNumber, request.PageSize, cancellationToken).ConfigureAwait(false); + return items; + } +} diff --git a/api/modules/Catalog/Catalog.Application/Products/GetList/v1/GetProductListRequest.cs b/api/modules/Catalog/Catalog.Application/Products/GetList/v1/GetProductListRequest.cs new file mode 100644 index 000000000..04cd1c191 --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/GetList/v1/GetProductListRequest.cs @@ -0,0 +1,7 @@ +using FSH.WebApi.Catalog.Application.Products.Get.v1; +using FSH.Framework.Core.Paging; +using MediatR; + +namespace FSH.WebApi.Catalog.Application.Products.GetList.v1; + +public record GetProductListRequest(int PageNumber, int PageSize) : IRequest>; diff --git a/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommand.cs b/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommand.cs new file mode 100644 index 000000000..f4fbce6cd --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace FSH.WebApi.Catalog.Application.Products.Update.v1; +public sealed record UpdateProductCommand( + Guid Id, + string? Name, + decimal Price, + string? Description = null) : IRequest; diff --git a/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommandValidator.cs b/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommandValidator.cs new file mode 100644 index 000000000..1c5f9773c --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace FSH.WebApi.Catalog.Application.Products.Update.v1; +public class UpdateProductCommandValidator : AbstractValidator +{ + public UpdateProductCommandValidator() + { + RuleFor(p => p.Name).NotEmpty().MinimumLength(2).MaximumLength(75); + RuleFor(p => p.Price).GreaterThan(0); + } +} diff --git a/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductHandler.cs b/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductHandler.cs new file mode 100644 index 000000000..c3a959222 --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductHandler.cs @@ -0,0 +1,24 @@ +using FSH.Framework.Core.Persistence; +using FSH.WebApi.Catalog.Domain; +using FSH.WebApi.Catalog.Domain.Exceptions; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FSH.WebApi.Catalog.Application.Products.Update.v1; +public sealed class UpdateProductHandler( + ILogger logger, + [FromKeyedServices("catalog:products")] IRepository repository) + : IRequestHandler +{ + public async Task Handle(UpdateProductCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + var product = await repository.GetByIdAsync(request.Id, cancellationToken); + _ = product ?? throw new ProductNotFoundException(request.Id); + var updatedProduct = product.Update(request.Name, request.Description, request.Price); + await repository.UpdateAsync(updatedProduct, cancellationToken); + logger.LogInformation("product with id : {ProductId} updated.", product.Id); + return new UpdateProductResponse(product.Id); + } +} diff --git a/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductResponse.cs b/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductResponse.cs new file mode 100644 index 000000000..991f380c0 --- /dev/null +++ b/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductResponse.cs @@ -0,0 +1,2 @@ +namespace FSH.WebApi.Catalog.Application.Products.Update.v1; +public sealed record UpdateProductResponse(Guid? Id); diff --git a/api/modules/Catalog/Catalog.Domain/Events/ProductUpdated.cs b/api/modules/Catalog/Catalog.Domain/Events/ProductUpdated.cs new file mode 100644 index 000000000..64c2fb5e7 --- /dev/null +++ b/api/modules/Catalog/Catalog.Domain/Events/ProductUpdated.cs @@ -0,0 +1,7 @@ +using FSH.Framework.Core.Domain.Events; + +namespace FSH.WebApi.Catalog.Domain.Events; +public sealed record ProductUpdated : DomainEvent +{ + public Product? Product { get; set; } +} diff --git a/api/modules/Catalog/Catalog.Domain/Exceptions/ProductNotFoundException.cs b/api/modules/Catalog/Catalog.Domain/Exceptions/ProductNotFoundException.cs new file mode 100644 index 000000000..3579b3ee1 --- /dev/null +++ b/api/modules/Catalog/Catalog.Domain/Exceptions/ProductNotFoundException.cs @@ -0,0 +1,10 @@ +using FSH.Framework.Core.Exceptions; + +namespace FSH.WebApi.Catalog.Domain.Exceptions; +public sealed class ProductNotFoundException : NotFoundException +{ + public ProductNotFoundException(Guid id) + : base($"product with id {id} not found") + { + } +} diff --git a/api/modules/Catalog/Catalog.Domain/Product.cs b/api/modules/Catalog/Catalog.Domain/Product.cs index 0b9f1437a..399453185 100644 --- a/api/modules/Catalog/Catalog.Domain/Product.cs +++ b/api/modules/Catalog/Catalog.Domain/Product.cs @@ -21,4 +21,29 @@ public static Product Create(string name, string? description, decimal price) return product; } + + public Product Update(string? name, string? description, decimal? price) + { + if (name is not null && Name?.Equals(name, StringComparison.OrdinalIgnoreCase) is not true) Name = name; + if (description is not null && Description?.Equals(description, StringComparison.OrdinalIgnoreCase) is not true) Description = description; + if (price.HasValue && Price != price) Price = price.Value; + + this.QueueDomainEvent(new ProductUpdated() { Product = this }); + return this; + } + + public static Product Update(Guid id, string name, string? description, decimal price) + { + var product = new Product + { + Id = id, + Name = name, + Description = description, + Price = price + }; + + product.QueueDomainEvent(new ProductUpdated() { Product = product }); + + return product; + } } diff --git a/api/modules/Catalog/Catalog.Infrastructure/CatalogModule.cs b/api/modules/Catalog/Catalog.Infrastructure/CatalogModule.cs index 6a724b6d0..90eeaf40d 100644 --- a/api/modules/Catalog/Catalog.Infrastructure/CatalogModule.cs +++ b/api/modules/Catalog/Catalog.Infrastructure/CatalogModule.cs @@ -19,6 +19,10 @@ public override void AddRoutes(IEndpointRouteBuilder app) { var productGroup = app.MapGroup("products").WithTags("products"); productGroup.MapProductCreationEndpoint(); + productGroup.MapGetProductEndpoint(); + productGroup.MapGetProductListEndpoint(); + productGroup.MapProductUpdateEndpoint(); + productGroup.MapProductDeleteEndpoint(); } } public static WebApplicationBuilder RegisterCatalogServices(this WebApplicationBuilder builder) diff --git a/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateProductEndpoint.cs b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateProductEndpoint.cs index cf6b318a7..2bf351609 100644 --- a/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateProductEndpoint.cs +++ b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateProductEndpoint.cs @@ -11,7 +11,11 @@ public static class CreateProductEndpoint internal static RouteHandlerBuilder MapProductCreationEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints - .MapPost("/", (CreateProductCommand request, ISender mediator) => mediator.Send(request)) + .MapPost("/", async (CreateProductCommand request, ISender mediator) => + { + var response = await mediator.Send(request); + return Results.Ok(response); + }) .WithName(nameof(CreateProductEndpoint)) .WithSummary("creates a product") .WithDescription("creates a product") diff --git a/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/DeleteProductEndpoint.cs b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/DeleteProductEndpoint.cs new file mode 100644 index 000000000..3dcafcc0e --- /dev/null +++ b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/DeleteProductEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.WebApi.Catalog.Application.Products.Delete.v1; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.WebApi.Catalog.Infrastructure.Endpoints.v1; +public static class DeleteProductEndpoint +{ + internal static RouteHandlerBuilder MapProductDeleteEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapDelete("/{id:guid}", async (Guid id, ISender mediator) => + { + await mediator.Send(new DeleteProductCommand(id)); + return Results.NoContent(); + }) + .WithName(nameof(DeleteProductEndpoint)) + .WithSummary("deletes product by id") + .WithDescription("deletes product by id") + .Produces(StatusCodes.Status204NoContent) + .RequirePermission("Permissions.Products.Delete") + .MapToApiVersion(1); + } +} diff --git a/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetProductEndpoint.cs b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetProductEndpoint.cs new file mode 100644 index 000000000..23614f07e --- /dev/null +++ b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetProductEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.WebApi.Catalog.Application.Products.Get.v1; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.WebApi.Catalog.Infrastructure.Endpoints.v1; +public static class GetProductEndpoint +{ + internal static RouteHandlerBuilder MapGetProductEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapGet("/{id:guid}", async (Guid id, ISender mediator) => + { + var response = await mediator.Send(new GetProductRequest(id)); + return Results.Ok(response); + }) + .WithName(nameof(GetProductEndpoint)) + .WithSummary("gets product by id") + .WithDescription("gets prodct by id") + .Produces() + .RequirePermission("Permissions.Products.View") + .MapToApiVersion(1); + } +} diff --git a/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetProductListEndpoint.cs b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetProductListEndpoint.cs new file mode 100644 index 000000000..9681971f4 --- /dev/null +++ b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetProductListEndpoint.cs @@ -0,0 +1,28 @@ +using FSH.Framework.Core.Paging; +using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.WebApi.Catalog.Application.Products.Get.v1; +using FSH.WebApi.Catalog.Application.Products.GetList.v1; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.WebApi.Catalog.Infrastructure.Endpoints.v1; +public static class GetProductListEndpoint +{ + internal static RouteHandlerBuilder MapGetProductListEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapGet("/", async (ISender mediator, int pageNumber = 1, int pageSize = 10) => + { + var response = await mediator.Send(new GetProductListRequest(pageNumber, pageSize)); + return Results.Ok(response); + }) + .WithName(nameof(GetProductListEndpoint)) + .WithSummary("gets a list of products") + .WithDescription("gets a list of products") + .Produces>() + .RequirePermission("Permissions.Products.View") + .MapToApiVersion(1); + } +} diff --git a/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/UpdateProductEndpoint.cs b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/UpdateProductEndpoint.cs new file mode 100644 index 000000000..bb527db30 --- /dev/null +++ b/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/UpdateProductEndpoint.cs @@ -0,0 +1,27 @@ +using FSH.Framework.Infrastructure.Auth.Policy; +using FSH.WebApi.Catalog.Application.Products.Update.v1; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.WebApi.Catalog.Infrastructure.Endpoints.v1; +public static class UpdateProductEndpoint +{ + internal static RouteHandlerBuilder MapProductUpdateEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPut("/{id:guid}", async (Guid id, UpdateProductCommand request, ISender mediator) => + { + if (id != request.Id) return Results.BadRequest(); + var response = await mediator.Send(request); + return Results.Ok(response); + }) + .WithName(nameof(UpdateProductEndpoint)) + .WithSummary("update a product") + .WithDescription("update a product") + .Produces() + .RequirePermission("Permissions.Products.Update") + .MapToApiVersion(1); + } +} diff --git a/api/server/Properties/launchSettings.json b/api/server/Properties/launchSettings.json index 321bf9738..c4337e022 100644 --- a/api/server/Properties/launchSettings.json +++ b/api/server/Properties/launchSettings.json @@ -12,4 +12,4 @@ } }, "$schema": "http://json.schemastore.org/launchsettings.json" -} \ No newline at end of file +}