From 12b547fa142fb4ec66ea775e13084e62f5cfcef0 Mon Sep 17 00:00:00 2001 From: "oskar.dudycz" Date: Mon, 17 May 2021 21:17:25 +0200 Subject: [PATCH] Added example of MapCommand for extreme endpoints handling --- .../GetProductDetailsTests.cs | 2 +- .../GettingProducts/GetProductsTests.cs | 2 +- .../RegisterProductRequest.cs | 8 ++++ .../Core/Commands/ICommandHandler.cs | 16 ++++++- .../Core/Extensions/EndpointsExtensions.cs | 36 ++++++++++++++ .../Core/Extensions/HttpExtensions.cs | 1 + .../Warehouse/Products/Configuration.cs | 5 +- .../GetProductDetails.cs | 3 +- .../RegisteringProduct/RegisterProduct.cs | 48 ++++++++++--------- .../Products/RegisteringProduct/Route.cs | 36 -------------- 10 files changed, 93 insertions(+), 64 deletions(-) create mode 100644 Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductRequest.cs create mode 100644 Sample/Warehouse/Warehouse/Core/Extensions/EndpointsExtensions.cs delete mode 100644 Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs index 4d629146d..5eaaa4b42 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs @@ -4,8 +4,8 @@ using Core.Testing; using FluentAssertions; using Microsoft.AspNetCore.Hosting; +using Warehouse.Api.Tests.Products.RegisteringProduct; using Warehouse.Products.GettingProductDetails; -using Warehouse.Products.RegisteringProduct; using Xunit; namespace Warehouse.Api.Tests.Products.GettingProductDetails diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs index a6c154b97..8166e4b39 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs @@ -6,8 +6,8 @@ using Core.Testing; using FluentAssertions; using Microsoft.AspNetCore.Hosting; +using Warehouse.Api.Tests.Products.RegisteringProduct; using Warehouse.Products.GettingProducts; -using Warehouse.Products.RegisteringProduct; using Xunit; namespace Warehouse.Api.Tests.Products.GettingProducts diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductRequest.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductRequest.cs new file mode 100644 index 000000000..406ef84a9 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductRequest.cs @@ -0,0 +1,8 @@ +namespace Warehouse.Api.Tests.Products.RegisteringProduct +{ + public record RegisterProductRequest( + string? SKU, + string? Name, + string? Description + ); +} diff --git a/Sample/Warehouse/Warehouse/Core/Commands/ICommandHandler.cs b/Sample/Warehouse/Warehouse/Core/Commands/ICommandHandler.cs index e13d72275..3aaf2c572 100644 --- a/Sample/Warehouse/Warehouse/Core/Commands/ICommandHandler.cs +++ b/Sample/Warehouse/Warehouse/Core/Commands/ICommandHandler.cs @@ -8,7 +8,19 @@ namespace Warehouse.Core.Commands { public interface ICommandHandler { - ValueTask Handle(T command, CancellationToken token); + ValueTask Handle(T command, CancellationToken token); + } + + public record CommandResult + { + public object? Result { get; } + + private CommandResult(object? result = null) + => Result = result; + + public static CommandResult None => new(); + + public static CommandResult Of(object result) => new(result); } public static class CommandHandlerConfiguration @@ -37,7 +49,7 @@ public static ICommandHandler GetCommandHandler(this HttpContext context) => context.RequestServices.GetRequiredService>(); - public static ValueTask SendCommand(this HttpContext context, T command) + public static ValueTask SendCommand(this HttpContext context, T command) => context.GetCommandHandler() .Handle(command, context.RequestAborted); } diff --git a/Sample/Warehouse/Warehouse/Core/Extensions/EndpointsExtensions.cs b/Sample/Warehouse/Warehouse/Core/Extensions/EndpointsExtensions.cs new file mode 100644 index 000000000..9bd0415ac --- /dev/null +++ b/Sample/Warehouse/Warehouse/Core/Extensions/EndpointsExtensions.cs @@ -0,0 +1,36 @@ +using System; +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Warehouse.Core.Commands; + +namespace Warehouse.Core.Extensions +{ +internal static class EndpointsExtensions +{ + internal static IEndpointRouteBuilder MapCommand( + this IEndpointRouteBuilder endpoints, + HttpMethod httpMethod, + string url, + HttpStatusCode statusCode = HttpStatusCode.OK) + { + endpoints.MapMethods(url, new []{httpMethod.ToString()} , async context => + { + var command = await context.FromBody(); + + var commandResult = await context.SendCommand(command); + + if (commandResult == CommandResult.None) + { + context.Response.StatusCode = (int)statusCode; + return; + } + + await context.ReturnJSON(commandResult.Result, statusCode); + }); + + return endpoints; + } +} +} diff --git a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs index c8dbf755f..d0f0ae8c9 100644 --- a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs +++ b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using System.Net; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Headers; diff --git a/Sample/Warehouse/Warehouse/Products/Configuration.cs b/Sample/Warehouse/Warehouse/Products/Configuration.cs index abbe5a1c7..c6c515139 100644 --- a/Sample/Warehouse/Warehouse/Products/Configuration.cs +++ b/Sample/Warehouse/Warehouse/Products/Configuration.cs @@ -1,9 +1,12 @@ using System.Collections.Generic; +using System.Net; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Warehouse.Core.Commands; using Warehouse.Core.Entities; +using Warehouse.Core.Extensions; using Warehouse.Core.Queries; using Warehouse.Products.GettingProductDetails; using Warehouse.Products.GettingProducts; @@ -35,7 +38,7 @@ public static IServiceCollection AddProductServices(this IServiceCollection serv public static IEndpointRouteBuilder UseProductsEndpoints(this IEndpointRouteBuilder endpoints) => endpoints - .UseRegisterProductEndpoint() + .MapCommand(HttpMethod.Post, "/api/products", HttpStatusCode.Created) .UseGetProductsEndpoint() .UseGetProductDetailsEndpoint(); diff --git a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs index 3862fbe53..063d3211a 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs @@ -19,7 +19,8 @@ public HandleGetProductDetails(IQueryable products) public async ValueTask Handle(GetProductDetails query, CancellationToken ct) { - // await is needed because of https://github.com/dotnet/efcore/issues/21793#issuecomment-667096367 + // btw. SingleOrDefaultAsync do not work properly with NullableReferenceTypes + // See more in: https://github.com/dotnet/efcore/issues/21793#issuecomment-667096367 var product = await products .SingleOrDefaultAsync(p => p.Id == query.ProductId, ct); diff --git a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs index 3f29a122b..d89ccac0e 100644 --- a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs +++ b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs @@ -1,12 +1,14 @@ using System; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Warehouse.Core.Commands; +using Warehouse.Core.Primitives; using Warehouse.Products.Primitives; namespace Warehouse.Products.RegisteringProduct { - internal class HandleRegisterProduct : ICommandHandler + internal class HandleRegisterProduct: ICommandHandler { private readonly Func addProduct; private readonly Func> productWithSKUExists; @@ -20,49 +22,51 @@ Func> productWithSKUExists this.productWithSKUExists = productWithSKUExists; } - public async ValueTask Handle(RegisterProduct command, CancellationToken ct) + public async ValueTask Handle(RegisterProduct command, CancellationToken ct) { + var productId = Guid.NewGuid(); + var (skuValue, name, description) = command; + + var sku = SKU.Create(skuValue); + var product = new Product( - command.ProductId, - command.SKU, - command.Name, - command.Description + productId, + sku, + name, + description ); - if (await productWithSKUExists(command.SKU, ct)) + if (await productWithSKUExists(sku, ct)) throw new InvalidOperationException( - $"Product with SKU `{command.SKU} already exists."); + $"Product with SKU `{command.Sku} already exists."); await addProduct(product, ct); + + return CommandResult.Of(productId); } } public record RegisterProduct { - public Guid ProductId { get;} - - public SKU SKU { get; } + public string Sku { get; } public string Name { get; } public string? Description { get; } - private RegisterProduct(Guid productId, SKU sku, string name, string? description) + [JsonConstructor] + public RegisterProduct(string? sku, string? name, string? description) { - ProductId = productId; - SKU = sku; - Name = name; + Sku = sku.AssertNotEmpty(); + Name = name.AssertNotEmpty(); Description = description; } - public static RegisterProduct Create(Guid? id, string? sku, string? name, string? description) + public void Deconstruct(out string sku, out string name, out string? description) { - if (!id.HasValue || id == Guid.Empty) throw new ArgumentOutOfRangeException(nameof(id)); - if (string.IsNullOrEmpty(sku)) throw new ArgumentOutOfRangeException(nameof(sku)); - if (string.IsNullOrEmpty(name)) throw new ArgumentOutOfRangeException(nameof(name)); - if (description is "") throw new ArgumentOutOfRangeException(nameof(name)); - - return new RegisterProduct(id.Value, SKU.Create(sku), name, description); + sku = Sku; + name = Name; + description = Description; } } } diff --git a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs deleted file mode 100644 index 7f34d680e..000000000 --- a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using Warehouse.Core.Commands; -using Warehouse.Core.Extensions; - -namespace Warehouse.Products.RegisteringProduct -{ - public record RegisterProductRequest( - string? SKU, - string? Name, - string? Description - ); - - internal static class Route - { - internal static IEndpointRouteBuilder UseRegisterProductEndpoint(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost("api/products/", async context => - { - var (sku, name, description) = await context.FromBody(); - var productId = Guid.NewGuid(); - - var command = RegisterProduct.Create(productId, sku, name, description); - - await context.SendCommand(command); - - await context.Created(productId); - }); - - return endpoints; - } - } -} - -