From d27d10041fa2188e6a957769b77b3e2a80399d98 Mon Sep 17 00:00:00 2001 From: "oskar.dudycz" Date: Thu, 13 May 2021 17:06:26 +0200 Subject: [PATCH 1/4] Added example of basic CQRS using Endpoints, Nullable Reference Types, Records and other C# 8-9 goodies --- EventSourcing.NetCore.sln | 24 ++++++ .../RegisterProductTests.cs | 50 ++++++++++++ .../Warehouse.Api.Tests.csproj | 52 +++++++++++++ .../WarehouseTestWebHostBuilder.cs | 38 +++++++++ .../appsettings.Development.json | 9 +++ .../Warehouse.Api.Tests/appsettings.json | 13 ++++ Sample/Warehouse/Warehouse.Api/Program.cs | 42 ++++++++++ .../Properties/launchSettings.json | 31 ++++++++ .../Warehouse.Api/Warehouse.Api.csproj | 23 ++++++ .../appsettings.Development.json | 9 +++ .../Warehouse/Warehouse.Api/appsettings.json | 13 ++++ Sample/Warehouse/Warehouse.sln | 46 +++++++++++ Sample/Warehouse/Warehouse/Configuration.cs | 20 +++++ .../Core/Commands/ICommandHandler.cs | 44 +++++++++++ .../Core/Entities/EntitiesExtensions.cs | 20 +++++ .../Core/Extensions/HttpExtensions.cs | 52 +++++++++++++ .../Core/Primitives/MappingExtensions.cs | 34 ++++++++ .../Warehouse/Core/Queries/IQueryHandler.cs | 43 ++++++++++ .../20210512081922_InitialSetup.Designer.cs | 67 ++++++++++++++++ .../Migrations/20210512081922_InitialSetup.cs | 31 ++++++++ .../WarehouseDBContextModelSnapshot.cs | 65 ++++++++++++++++ .../Warehouse/Products/Configuration.cs | 46 +++++++++++ .../GetProductDetails.cs | 40 ++++++++++ .../Products/GettingProductDetails/Route.cs | 29 +++++++ .../Products/GettingProducts/GetProducts.cs | 70 +++++++++++++++++ .../Products/GettingProducts/Route.cs | 31 ++++++++ .../Warehouse/Products/Primitives/SKU.cs | 25 ++++++ .../Warehouse/Warehouse/Products/Product.cs | 36 +++++++++ .../Warehouse/Products/ProductsRepository.cs | 14 ++++ .../RegisteringProduct/RegisterProduct.cs | 78 +++++++++++++++++++ .../Products/RegisteringProduct/Route.cs | 44 +++++++++++ .../Warehouse/Storage/WarehouseDBContext.cs | 52 +++++++++++++ Sample/Warehouse/Warehouse/Warehouse.csproj | 31 ++++++++ 33 files changed, 1222 insertions(+) create mode 100644 Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs create mode 100644 Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj create mode 100644 Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs create mode 100644 Sample/Warehouse/Warehouse.Api.Tests/appsettings.Development.json create mode 100644 Sample/Warehouse/Warehouse.Api.Tests/appsettings.json create mode 100644 Sample/Warehouse/Warehouse.Api/Program.cs create mode 100644 Sample/Warehouse/Warehouse.Api/Properties/launchSettings.json create mode 100644 Sample/Warehouse/Warehouse.Api/Warehouse.Api.csproj create mode 100644 Sample/Warehouse/Warehouse.Api/appsettings.Development.json create mode 100644 Sample/Warehouse/Warehouse.Api/appsettings.json create mode 100644 Sample/Warehouse/Warehouse.sln create mode 100644 Sample/Warehouse/Warehouse/Configuration.cs create mode 100644 Sample/Warehouse/Warehouse/Core/Commands/ICommandHandler.cs create mode 100644 Sample/Warehouse/Warehouse/Core/Entities/EntitiesExtensions.cs create mode 100644 Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs create mode 100644 Sample/Warehouse/Warehouse/Core/Primitives/MappingExtensions.cs create mode 100644 Sample/Warehouse/Warehouse/Core/Queries/IQueryHandler.cs create mode 100644 Sample/Warehouse/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs create mode 100644 Sample/Warehouse/Warehouse/Migrations/20210512081922_InitialSetup.cs create mode 100644 Sample/Warehouse/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs create mode 100644 Sample/Warehouse/Warehouse/Products/Configuration.cs create mode 100644 Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs create mode 100644 Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs create mode 100644 Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs create mode 100644 Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs create mode 100644 Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs create mode 100644 Sample/Warehouse/Warehouse/Products/Product.cs create mode 100644 Sample/Warehouse/Warehouse/Products/ProductsRepository.cs create mode 100644 Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs create mode 100644 Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs create mode 100644 Sample/Warehouse/Warehouse/Storage/WarehouseDBContext.cs create mode 100644 Sample/Warehouse/Warehouse/Warehouse.csproj diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index e3d35d0cf..6766c6a93 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -177,6 +177,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools", "Workshops\BuildYou EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solved", "Solved", "{C9011E7F-42EA-4FEE-A5BB-EFC11BBA0DB0}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Warehouse", "Warehouse", "{4AC3138B-6FD1-4620-A75A-3FCACE995162}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse", "Sample\Warehouse\Warehouse\Warehouse.csproj", "{C45ACE62-41BA-49D9-956A-39B479D7A50A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "Sample\Warehouse\Warehouse.Api\Warehouse.Api.csproj", "{76C04CB6-32C7-47EA-884A-6343BDD39644}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api.Tests", "Sample\Warehouse\Warehouse.Api.Tests\Warehouse.Api.Tests.csproj", "{69B22937-CA8B-478D-97F8-4D33558B5BC9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -419,6 +427,18 @@ Global {5C54B28C-7746-4DD0-865E-1AC0D8E8D46B}.Debug|Any CPU.Build.0 = Debug|Any CPU {5C54B28C-7746-4DD0-865E-1AC0D8E8D46B}.Release|Any CPU.ActiveCfg = Release|Any CPU {5C54B28C-7746-4DD0-865E-1AC0D8E8D46B}.Release|Any CPU.Build.0 = Release|Any CPU + {C45ACE62-41BA-49D9-956A-39B479D7A50A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C45ACE62-41BA-49D9-956A-39B479D7A50A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C45ACE62-41BA-49D9-956A-39B479D7A50A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C45ACE62-41BA-49D9-956A-39B479D7A50A}.Release|Any CPU.Build.0 = Release|Any CPU + {76C04CB6-32C7-47EA-884A-6343BDD39644}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76C04CB6-32C7-47EA-884A-6343BDD39644}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76C04CB6-32C7-47EA-884A-6343BDD39644}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76C04CB6-32C7-47EA-884A-6343BDD39644}.Release|Any CPU.Build.0 = Release|Any CPU + {69B22937-CA8B-478D-97F8-4D33558B5BC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {69B22937-CA8B-478D-97F8-4D33558B5BC9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {69B22937-CA8B-478D-97F8-4D33558B5BC9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {69B22937-CA8B-478D-97F8-4D33558B5BC9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -496,6 +516,10 @@ Global {C9011E7F-42EA-4FEE-A5BB-EFC11BBA0DB0} = {94524EA9-A4BA-4684-99B8-BBE9EE85E791} {7ACC398F-87BF-4B3E-AD61-DFB5F56D4B25} = {C9011E7F-42EA-4FEE-A5BB-EFC11BBA0DB0} {03D0848C-7B19-4685-BA1F-59FFAF1DCEA6} = {C9011E7F-42EA-4FEE-A5BB-EFC11BBA0DB0} + {4AC3138B-6FD1-4620-A75A-3FCACE995162} = {A7186B6B-D56D-4AEF-B6B7-FAA827764C34} + {C45ACE62-41BA-49D9-956A-39B479D7A50A} = {4AC3138B-6FD1-4620-A75A-3FCACE995162} + {76C04CB6-32C7-47EA-884A-6343BDD39644} = {4AC3138B-6FD1-4620-A75A-3FCACE995162} + {69B22937-CA8B-478D-97F8-4D33558B5BC9} = {4AC3138B-6FD1-4620-A75A-3FCACE995162} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B} diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs new file mode 100644 index 000000000..b0961f0af --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs @@ -0,0 +1,50 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Core.Testing; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Warehouse.Products.RegisteringProduct; +using Xunit; + +namespace Warehouse.Api.Tests.Products.RegisteringProduct +{ + public class RegisteringProduct + { + public class RegisterProductFixture: ApiFixture + { + protected override string ApiUrl => "/api/products"; + + protected override Func SetupWebHostBuilder => + WarehouseTestWebHostBuilder.Configure; + } + + public class RegisterProductTests: IClassFixture + { + private readonly RegisterProductFixture fixture; + + public RegisterProductTests(RegisterProductFixture fixture) + { + this.fixture = fixture; + } + + [Fact] + public async Task ValidRequest_ShouldReturn_OK() + { + // Given + const string sku = "test"; + const string name = "test"; + const string description = "test"; + var request = new RegisterProductRequest(sku, name, description); + + // When + var response = await fixture.Post(request); + + // Then + response.EnsureSuccessStatusCode(); + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + } + } +} diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj b/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj new file mode 100644 index 000000000..bcdf50ee8 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj @@ -0,0 +1,52 @@ + + + + net5.0 + enable + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + true + PreserveNewest + PreserveNewest + + + true + PreserveNewest + PreserveNewest + + + + diff --git a/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs new file mode 100644 index 000000000..f30ef7576 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Warehouse.Storage; + +namespace Warehouse.Api.Tests +{ + public static class WarehouseTestWebHostBuilder + { + public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder) + { + webHostBuilder + .ConfigureServices(services => + { + services.AddMvcCore() + .AddAuthorization() + .AddCors() + .AddDataAnnotations() + .AddFormatterMappings(); + + services.AddWarehouseServices(); + }) + .Configure(app => + { + app.UseHttpsRedirection() + .UseRouting() + .UseAuthorization() + .UseEndpoints(endpoints => { endpoints.UseWarehouseEndpoints(); }); + + // Kids, do not try this at home! + app.ApplicationServices.GetRequiredService().Database.Migrate(); + }); + + return webHostBuilder; + } + } +} diff --git a/Sample/Warehouse/Warehouse.Api.Tests/appsettings.Development.json b/Sample/Warehouse/Warehouse.Api.Tests/appsettings.Development.json new file mode 100644 index 000000000..8983e0fc1 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/Sample/Warehouse/Warehouse.Api.Tests/appsettings.json b/Sample/Warehouse/Warehouse.Api.Tests/appsettings.json new file mode 100644 index 000000000..521450942 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'" + }, + "AllowedHosts": "*" +} diff --git a/Sample/Warehouse/Warehouse.Api/Program.cs b/Sample/Warehouse/Warehouse.Api/Program.cs new file mode 100644 index 000000000..989799d9c --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api/Program.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; +using Warehouse; + +var builder = Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder + .ConfigureServices(services => + { + services.AddMvcCore() + .AddApiExplorer() + .AddAuthorization() + .AddCors() + .AddDataAnnotations() + .AddFormatterMappings(); + + services + .AddWarehouseServices() + .AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo {Title = "Warehouse.Api", Version = "v1"}); + }); + }) + .Configure(app => + { + app.UseSwagger() + .UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "CashRegisters.Api v1")) + .UseHttpsRedirection() + .UseRouting() + .UseAuthorization() + .UseEndpoints(endpoints => + { + endpoints.UseWarehouseEndpoints(); + }); + }); + }) + .Build(); +builder.Run(); diff --git a/Sample/Warehouse/Warehouse.Api/Properties/launchSettings.json b/Sample/Warehouse/Warehouse.Api/Properties/launchSettings.json new file mode 100644 index 000000000..423e4f091 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:59471", + "sslPort": 44389 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "CashRegisters.Api": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Sample/Warehouse/Warehouse.Api/Warehouse.Api.csproj b/Sample/Warehouse/Warehouse.Api/Warehouse.Api.csproj new file mode 100644 index 000000000..4f9ddeed5 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api/Warehouse.Api.csproj @@ -0,0 +1,23 @@ + + + + net5.0 + enable + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Sample/Warehouse/Warehouse.Api/appsettings.Development.json b/Sample/Warehouse/Warehouse.Api/appsettings.Development.json new file mode 100644 index 000000000..8983e0fc1 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/Sample/Warehouse/Warehouse.Api/appsettings.json b/Sample/Warehouse/Warehouse.Api/appsettings.json new file mode 100644 index 000000000..521450942 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'" + }, + "AllowedHosts": "*" +} diff --git a/Sample/Warehouse/Warehouse.sln b/Sample/Warehouse/Warehouse.sln new file mode 100644 index 000000000..19898dceb --- /dev/null +++ b/Sample/Warehouse/Warehouse.sln @@ -0,0 +1,46 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{F6A27B3D-4018-4E66-A008-3E1280C8C685}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Testing", "..\..\Core.Testing\Core.Testing.csproj", "{DD7FF547-0FF1-4B10-9248-1E2700BA3770}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "..\..\Core\Core.csproj", "{35632837-CB02-455C-9570-E79E476C1D90}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse", "Warehouse\Warehouse.csproj", "{00DCEE41-018D-4CCA-99F3-00876BEB7E06}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "Warehouse.Api\Warehouse.Api.csproj", "{46D1830E-55B1-4F36-959F-2ACF936BFC7C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api.Tests", "Warehouse.Api.Tests\Warehouse.Api.Tests.csproj", "{6E5C1CF1-29FF-408E-9E01-E7109FB2ECA0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DD7FF547-0FF1-4B10-9248-1E2700BA3770} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} + {35632837-CB02-455C-9570-E79E476C1D90} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DD7FF547-0FF1-4B10-9248-1E2700BA3770}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD7FF547-0FF1-4B10-9248-1E2700BA3770}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD7FF547-0FF1-4B10-9248-1E2700BA3770}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD7FF547-0FF1-4B10-9248-1E2700BA3770}.Release|Any CPU.Build.0 = Release|Any CPU + {35632837-CB02-455C-9570-E79E476C1D90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35632837-CB02-455C-9570-E79E476C1D90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35632837-CB02-455C-9570-E79E476C1D90}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35632837-CB02-455C-9570-E79E476C1D90}.Release|Any CPU.Build.0 = Release|Any CPU + {00DCEE41-018D-4CCA-99F3-00876BEB7E06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00DCEE41-018D-4CCA-99F3-00876BEB7E06}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00DCEE41-018D-4CCA-99F3-00876BEB7E06}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00DCEE41-018D-4CCA-99F3-00876BEB7E06}.Release|Any CPU.Build.0 = Release|Any CPU + {46D1830E-55B1-4F36-959F-2ACF936BFC7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46D1830E-55B1-4F36-959F-2ACF936BFC7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46D1830E-55B1-4F36-959F-2ACF936BFC7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46D1830E-55B1-4F36-959F-2ACF936BFC7C}.Release|Any CPU.Build.0 = Release|Any CPU + {6E5C1CF1-29FF-408E-9E01-E7109FB2ECA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E5C1CF1-29FF-408E-9E01-E7109FB2ECA0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E5C1CF1-29FF-408E-9E01-E7109FB2ECA0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E5C1CF1-29FF-408E-9E01-E7109FB2ECA0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Sample/Warehouse/Warehouse/Configuration.cs b/Sample/Warehouse/Warehouse/Configuration.cs new file mode 100644 index 000000000..626363940 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Configuration.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Warehouse.Products; +using Warehouse.Storage; + +namespace Warehouse +{ + public static class WarehouseConfiguration + { + public static IServiceCollection AddWarehouseServices(this IServiceCollection services) + => services + .AddDbContext( + options => options.UseNpgsql("name=ConnectionStrings:WarehouseDB")) + .AddProductServices(); + + public static IEndpointRouteBuilder UseWarehouseEndpoints(this IEndpointRouteBuilder endpoints) + => endpoints.UseProductsEndpoints(); + } +} diff --git a/Sample/Warehouse/Warehouse/Core/Commands/ICommandHandler.cs b/Sample/Warehouse/Warehouse/Core/Commands/ICommandHandler.cs new file mode 100644 index 000000000..e13d72275 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Core/Commands/ICommandHandler.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Warehouse.Core.Commands +{ + public interface ICommandHandler + { + ValueTask Handle(T command, CancellationToken token); + } + + public static class CommandHandlerConfiguration + { + public static IServiceCollection AddCommandHandler( + this IServiceCollection services, + Func? configure = null + ) where TCommandHandler: class, ICommandHandler + { + + if (configure == null) + { + services.AddTransient(); + services.AddTransient, TCommandHandler>(); + } + else + { + services.AddTransient(configure); + services.AddTransient, TCommandHandler>(configure); + } + + return services; + } + + public static ICommandHandler GetCommandHandler(this HttpContext context) + => context.RequestServices.GetRequiredService>(); + + + public static ValueTask SendCommand(this HttpContext context, T command) + => context.GetCommandHandler() + .Handle(command, context.RequestAborted); + } +} diff --git a/Sample/Warehouse/Warehouse/Core/Entities/EntitiesExtensions.cs b/Sample/Warehouse/Warehouse/Core/Entities/EntitiesExtensions.cs new file mode 100644 index 000000000..347177772 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Core/Entities/EntitiesExtensions.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Warehouse.Core.Entities +{ + public static class EntitiesExtensions + { + public static async ValueTask AddAndSave(this DbContext dbContext, T entity, CancellationToken ct) + where T : notnull + { + await dbContext.AddAsync(entity, ct); + await dbContext.SaveChangesAsync(ct); + } + + public static ValueTask Find(this DbContext dbContext, TId id, CancellationToken ct) + where T : class where TId : notnull + => dbContext.FindAsync(new object[] {id}, ct); + } +} diff --git a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs new file mode 100644 index 000000000..d20988f5d --- /dev/null +++ b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs @@ -0,0 +1,52 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Warehouse.Core.Extensions +{ + public static class HttpExtensions + { + public static T FromRoute(this HttpContext context, string name) + { + var value = context.Request.RouteValues[name]; + + if (value is not T typedValue) + throw new ArgumentOutOfRangeException(name); + + return typedValue; + } + + public static T FromQuery(this HttpContext context, string name) + { + var value = context.Request.Query[name]; + + if (value is not T typedValue) + throw new ArgumentOutOfRangeException(name); + + return typedValue; + } + + public static async Task FromBody(this HttpContext context) + { + return await context.Request.ReadFromJsonAsync() ?? + throw new ArgumentNullException("request"); + } + + public static Task OK(this HttpContext context, T result) + => context.ReturnJSON(result); + + public static Task Created(this HttpContext context, T result) + => context.ReturnJSON(result, HttpStatusCode.Created); + + public static async Task ReturnJSON(this HttpContext context, T result, HttpStatusCode statusCode = HttpStatusCode.OK) + { + context.Response.StatusCode = (int)statusCode; + + if (result != null) + return; + + await context.Response.WriteAsJsonAsync(result); + } + } +} diff --git a/Sample/Warehouse/Warehouse/Core/Primitives/MappingExtensions.cs b/Sample/Warehouse/Warehouse/Core/Primitives/MappingExtensions.cs new file mode 100644 index 000000000..89e89e358 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Core/Primitives/MappingExtensions.cs @@ -0,0 +1,34 @@ +using System; + +namespace Warehouse.Core.Primitives +{ + internal static class MappingExtensions + { + public static T AssertNotNull(this T? value, string? paramName = null) + where T : struct + { + if (value == null) + throw new ArgumentNullException(paramName); + + return (T)value; + } + + public static string AssertNotEmpty(this string? value, string? paramName = null) + => string.IsNullOrWhiteSpace(value) ? value! : throw new ArgumentOutOfRangeException(paramName); + + public static T AssertNotEmpty(this T value, string? paramName = null) + where T : struct + => AssertNotEmpty((T?)value, paramName); + + public static T AssertNotEmpty(this T? value, string? paramName = null) + where T : struct + { + var notNullValue = value.AssertNotNull(paramName); + + if(Equals(notNullValue, default(T))) + throw new ArgumentOutOfRangeException(paramName); + + return notNullValue; + } + } +} diff --git a/Sample/Warehouse/Warehouse/Core/Queries/IQueryHandler.cs b/Sample/Warehouse/Warehouse/Core/Queries/IQueryHandler.cs new file mode 100644 index 000000000..ce4b00539 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Core/Queries/IQueryHandler.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Warehouse.Core.Queries +{ + public interface IQueryHandler + { + ValueTask Handle(T query, CancellationToken ct); + } + + public static class QueryHandlerConfiguration + { + public static IServiceCollection AddQueryHandler( + this IServiceCollection services, + Func? configure = null + ) where TQueryHandler: class, IQueryHandler + { + + if (configure == null) + { + services.AddTransient(); + services.AddTransient, TQueryHandler>(); + } + else + { + services.AddTransient(configure); + services.AddTransient, TQueryHandler>(configure); + } + + return services; + } + + public static IQueryHandler GetQueryHandler(this HttpContext context) + => context.RequestServices.GetRequiredService>(); + + public static ValueTask SendQuery(this HttpContext context, T query) + => context.GetQueryHandler() + .Handle(query, context.RequestAborted); + } +} diff --git a/Sample/Warehouse/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs b/Sample/Warehouse/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs new file mode 100644 index 000000000..bf8e29d14 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs @@ -0,0 +1,67 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Warehouse.Storage; + +namespace Warehouse.Migrations +{ + [DbContext(typeof(WarehouseDBContext))] + [Migration("20210512081922_InitialSetup")] + partial class InitialSetup + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("ProductVersion", "5.0.5") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + modelBuilder.Entity("Warehouse.Products.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Product"); + }); + + modelBuilder.Entity("Warehouse.Products.Product", b => + { + b.OwnsOne("Warehouse.Products.Primitives.SKU", "Sku", b1 => + { + b1.Property("ProductId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ProductId"); + + b1.ToTable("Product"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.Navigation("Sku") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Sample/Warehouse/Warehouse/Migrations/20210512081922_InitialSetup.cs b/Sample/Warehouse/Warehouse/Migrations/20210512081922_InitialSetup.cs new file mode 100644 index 000000000..e921a219d --- /dev/null +++ b/Sample/Warehouse/Warehouse/Migrations/20210512081922_InitialSetup.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Warehouse.Migrations +{ + public partial class InitialSetup : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Product", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Sku_Value = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Product", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Product"); + } + } +} diff --git a/Sample/Warehouse/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs b/Sample/Warehouse/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs new file mode 100644 index 000000000..a53632ce1 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs @@ -0,0 +1,65 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Warehouse.Storage; + +namespace Warehouse.Migrations +{ + [DbContext(typeof(WarehouseDBContext))] + partial class WarehouseDBContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("ProductVersion", "5.0.5") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + modelBuilder.Entity("Warehouse.Products.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Product"); + }); + + modelBuilder.Entity("Warehouse.Products.Product", b => + { + b.OwnsOne("Warehouse.Products.Primitives.SKU", "Sku", b1 => + { + b1.Property("ProductId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ProductId"); + + b1.ToTable("Product"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.Navigation("Sku") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Sample/Warehouse/Warehouse/Products/Configuration.cs b/Sample/Warehouse/Warehouse/Products/Configuration.cs new file mode 100644 index 000000000..9bfe8371e --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/Configuration.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Warehouse.Core.Commands; +using Warehouse.Core.Entities; +using Warehouse.Core.Queries; +using Warehouse.Products.GettingProductDetails; +using Warehouse.Products.GettingProducts; +using Warehouse.Products.RegisteringProduct; +using Warehouse.Storage; + +namespace Warehouse.Products +{ + internal static class Configuration + { + public static IServiceCollection AddProductServices(this IServiceCollection services) + => services + .AddCommandHandler(s => + { + var dbContext = s.GetRequiredService(); + return new HandleRegisterProduct(dbContext.AddAndSave, dbContext.ProductWithSKUExists); + }) + .AddQueryHandler, HandleGetProducts>(s => + { + var dbContext = s.GetRequiredService(); + return new HandleGetProducts(dbContext.Set().AsNoTracking()); + }) + .AddQueryHandler(s => + { + var dbContext = s.GetRequiredService(); + return new HandleGetProductDetails(dbContext.Set().AsNoTracking()); + }); + + + public static IEndpointRouteBuilder UseProductsEndpoints(this IEndpointRouteBuilder endpoints) => + endpoints + .UseRegisterProductEndpoint() + .UseGetProductsEndpoint() + .UseGetProductDetailsEndpoint(); + + public static void SetupProductsModel(this ModelBuilder modelBuilder) + => modelBuilder.Entity() + .OwnsOne(p => p.Sku); + } +} diff --git a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs new file mode 100644 index 000000000..6e98cb33e --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Warehouse.Core.Primitives; +using Warehouse.Core.Queries; + +namespace Warehouse.Products.GettingProductDetails +{ + internal class HandleGetProductDetails: IQueryHandler + { + private readonly IQueryable products; + + public HandleGetProductDetails(IQueryable products) + { + this.products = products; + } + + public async ValueTask Handle(GetProductDetails query, CancellationToken ct) + { + // await is needed because of https://github.com/dotnet/efcore/issues/21793#issuecomment-667096367 + return await products + .SingleOrDefaultAsync(p => p.Id == query.ProductId, ct); + } + } + + public record GetProductDetails + { + public Guid ProductId { get;} + + private GetProductDetails(Guid productId) + { + ProductId = productId; + } + + public static GetProductDetails Create(Guid productId) + => new(productId.AssertNotEmpty(nameof(productId))); + } +} diff --git a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs new file mode 100644 index 000000000..7f9b6b686 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Warehouse.Core.Extensions; +using Warehouse.Core.Queries; + +namespace Warehouse.Products.GettingProductDetails +{ + public static class Route + { + internal static IEndpointRouteBuilder UseGetProductDetailsEndpoint(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/api/products/{id}", async context => + { + // var dbContext = WarehouseDBContextFactory.Create(); + // var handler = new HandleGetProductDetails(dbContext.Set().AsQueryable()); + + var productId = context.FromRoute("id"); + var query = GetProductDetails.Create(productId); + + var result = await context + .SendQuery(query); + + await context.OK(result); + }); + return endpoints; + } + } +} diff --git a/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs b/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs new file mode 100644 index 000000000..e4ff49469 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Warehouse.Core.Queries; + +namespace Warehouse.Products.GettingProducts +{ + internal class HandleGetProducts : IQueryHandler> + { + private const int PageSize = 10; + private readonly IQueryable products; + + public HandleGetProducts(IQueryable products) + { + this.products = products; + } + + public async ValueTask> Handle(GetProducts query, CancellationToken ct) + { + var (filter, page) = query; + + var filteredProducts = string.IsNullOrEmpty(filter) + ? products + : products + .Where(p => + p.Sku.Value.Contains(query.Filter!) || + p.Name.Contains(query.Filter!) || + p.Description!.Contains(query.Filter!) + ); + + // await is needed because of https://github.com/dotnet/efcore/issues/21793#issuecomment-667096367 + return await filteredProducts + .Skip(PageSize * page - 1) + .Take(PageSize) + .ToListAsync(ct); + } + } + + public record GetProducts + { + public string? Filter { get; } + + public int Page { get; } + + private GetProducts(string? filter, int page) + { + Filter = filter; + Page = page; + } + + public static GetProducts Create(string? filter, int? page) + { + page ??= 1; + + if (page <= 0) + throw new ArgumentOutOfRangeException(nameof(page)); + + return new GetProducts(filter, page.Value); + } + + public void Deconstruct(out string? filter, out int page) + { + filter = Filter; + page = Page; + } + }; +} diff --git a/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs b/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs new file mode 100644 index 000000000..2ca0a111c --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Warehouse.Core.Extensions; +using Warehouse.Core.Queries; + +namespace Warehouse.Products.GettingProducts +{ + public static class Route + { + internal static IEndpointRouteBuilder UseGetProductsEndpoint(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/api/products", async context => + { + // var dbContext = WarehouseDBContextFactory.Create(); + // var handler = new HandleGetProducts(dbContext.Set().AsQueryable()); + + var filter = context.FromQuery("filter"); + var page = context.FromQuery("page"); + var query = GetProducts.Create(filter, page); + + var result = context + .SendQuery>(query); + + await context.OK(result); + }); + return endpoints; + } + } +} diff --git a/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs b/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs new file mode 100644 index 000000000..7281d67cc --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs @@ -0,0 +1,25 @@ +using System; +using System.Text.RegularExpressions; + +namespace Warehouse.Products.Primitives +{ + public record SKU + { + public string Value { get; init; } + + private SKU(string value) + { + Value = value; + } + + public static SKU Create(string? value) + { + if (value == null) + throw new ArgumentNullException(nameof(SKU)); + if (string.IsNullOrWhiteSpace(value) || Regex.IsMatch(value, "[A-Z]{2,4}[0-9]{4,12}")) + throw new ArgumentOutOfRangeException(nameof(SKU)); + + return new SKU(value); + } + } +} diff --git a/Sample/Warehouse/Warehouse/Products/Product.cs b/Sample/Warehouse/Warehouse/Products/Product.cs new file mode 100644 index 000000000..61b3512e0 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/Product.cs @@ -0,0 +1,36 @@ +using System; +using Warehouse.Products.Primitives; + +namespace Warehouse.Products +{ + internal class Product + { + public Guid Id { get; set; } + + /// + /// The Stock Keeping Unit (SKU), i.e. a merchant-specific identifier for a product or service, or the product to which the offer refers. + /// + /// + public SKU Sku { get; set; } = default!; + + /// + /// Product Name + /// + public string Name { get; set; } = default!; + + /// + /// Optional Product description + /// + public string? Description { get; set; } + + private Product(){} + + public Product(Guid id, SKU sku, string name, string? description) + { + Id = id; + Sku = sku; + Name = name; + Description = description; + } + } +} diff --git a/Sample/Warehouse/Warehouse/Products/ProductsRepository.cs b/Sample/Warehouse/Warehouse/Products/ProductsRepository.cs new file mode 100644 index 000000000..94c79513c --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/ProductsRepository.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Warehouse.Products.Primitives; +using Warehouse.Storage; + +namespace Warehouse.Products +{ + internal static class ProductsRepository + { + public static ValueTask ProductWithSKUExists(this WarehouseDBContext dbContext, SKU productSKU, CancellationToken ct) + => new (dbContext.Set().AnyAsync(product => product.Sku.Value == productSKU.Value, ct)); + } +} diff --git a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs new file mode 100644 index 000000000..ab272c1f8 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Warehouse.Core.Commands; +using Warehouse.Products.Primitives; + +namespace Warehouse.Products.RegisteringProduct +{ + internal class HandleRegisterProduct : ICommandHandler + { + private readonly Func addProduct; + private readonly Func> productWithSKUExists; + + public HandleRegisterProduct( + Func addProduct, + Func> productWithSKUExists + ) + { + this.addProduct = addProduct; + this.productWithSKUExists = productWithSKUExists; + } + + public async ValueTask Handle(RegisterProduct command, CancellationToken ct) + { + var product = new Product( + command.ProductId, + command.SKU, + command.Name, + command.Description + ); + + if (await productWithSKUExists(command.SKU, ct)) + throw new ArgumentOutOfRangeException( + nameof(command.SKU), + $"Product with SKU `{command.SKU} already exists."); + + await addProduct(product, ct); + } + } + + public record RegisterProduct + { + public Guid ProductId { get;} + + /// + /// The Stock Keeping Unit (SKU), i.e. a merchant-specific identifier for a product or service, or the product to which the offer refers. + /// + /// + public SKU SKU { get; } + + /// + /// The area where a cashier works + /// + public string Name { get; } + + /// + /// Current cashier working on the cash register + /// + public string? Description { get; } + + private RegisterProduct(Guid productId, SKU sku, string name, string? description) + { + ProductId = productId; + SKU = sku; + Name = name; + Description = description; + } + + public static RegisterProduct Create(Guid? id, string? sku, string? name, string? description) + { + if (!id.HasValue) throw new ArgumentNullException(nameof(id)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (description == null) throw new ArgumentNullException(nameof(description)); + + return new RegisterProduct(id.Value, SKU.Create(sku), name, description); + } + } +} diff --git a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs new file mode 100644 index 000000000..b8970c269 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +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 dbContext = WarehouseDBContextFactory.Create(); + // + // var handler = new HandleRegisterProduct( + // dbContext.AddAndSave, + // dbContext.ProductWithSKUExists + // ); + + 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; + } + } +} + + diff --git a/Sample/Warehouse/Warehouse/Storage/WarehouseDBContext.cs b/Sample/Warehouse/Warehouse/Storage/WarehouseDBContext.cs new file mode 100644 index 000000000..371dedebf --- /dev/null +++ b/Sample/Warehouse/Warehouse/Storage/WarehouseDBContext.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using Warehouse.Products; + +namespace Warehouse.Storage +{ + public class WarehouseDBContext: DbContext + { + public WarehouseDBContext(DbContextOptions options) + : base(options) + { + + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.SetupProductsModel(); + } + } + + public class WarehouseDBContextFactory: IDesignTimeDbContextFactory + { + public WarehouseDBContext CreateDbContext(params string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + if (optionsBuilder.IsConfigured) + return new WarehouseDBContext(optionsBuilder.Options); + + //Called by parameterless ctor Usually Migrations + var environmentName = Environment.GetEnvironmentVariable("EnvironmentName") ?? "Development"; + + var connectionString = + new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json") + .AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build() + .GetConnectionString("WarehouseDB"); + + optionsBuilder.UseNpgsql(connectionString); + + return new WarehouseDBContext(optionsBuilder.Options); + } + + public static WarehouseDBContext Create() + => new WarehouseDBContextFactory().CreateDbContext(); + } +} diff --git a/Sample/Warehouse/Warehouse/Warehouse.csproj b/Sample/Warehouse/Warehouse/Warehouse.csproj new file mode 100644 index 000000000..f8ddf73de --- /dev/null +++ b/Sample/Warehouse/Warehouse/Warehouse.csproj @@ -0,0 +1,31 @@ + + + + net5.0 + enable + true + + + + + + + + + <_Parameter1>$(MSBuildProjectName).Tests + + + <_Parameter1>$(MSBuildProjectName).Api.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + From d83bc9a638d3569e028f41d7d1cb955120ad93ca Mon Sep 17 00:00:00 2001 From: "oskar.dudycz" Date: Fri, 14 May 2021 20:58:32 +0200 Subject: [PATCH 2/4] Added tests for Registering the Product --- .../RegisterProductTests.cs | 63 ++++++++++++++++--- .../Warehouse.Api.Tests.csproj | 3 +- .../WarehouseTestWebHostBuilder.cs | 15 ++--- Sample/Warehouse/Warehouse.Api/Program.cs | 22 ++----- .../Warehouse.Api/Warehouse.Api.csproj | 1 + Sample/Warehouse/Warehouse.sln | 7 +++ .../Core/Extensions/HttpExtensions.cs | 2 +- .../Products/GettingProducts/GetProducts.cs | 2 +- .../Warehouse/Products/Primitives/SKU.cs | 2 +- .../Warehouse/Warehouse/Products/Product.cs | 3 + .../RegisteringProduct/RegisterProduct.cs | 14 +---- .../Products/RegisteringProduct/Route.cs | 8 --- 12 files changed, 85 insertions(+), 57 deletions(-) diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs index b0961f0af..f38a4ca0a 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs @@ -1,6 +1,5 @@ using System; using System.Net; -using System.Net.Http; using System.Threading.Tasks; using Core.Testing; using FluentAssertions; @@ -29,22 +28,68 @@ public RegisterProductTests(RegisterProductFixture fixture) this.fixture = fixture; } - [Fact] - public async Task ValidRequest_ShouldReturn_OK() + [Theory] + [MemberData(nameof(ValidRequests))] + public async Task ValidRequest_ShouldReturn_201(RegisterProductRequest validRequest) { // Given - const string sku = "test"; - const string name = "test"; - const string description = "test"; - var request = new RegisterProductRequest(sku, name, description); // When - var response = await fixture.Post(request); + var response = await fixture.Post(validRequest); + + // Then + response.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.Created); + + var createdId = await response.GetResultFromJson(); + createdId.Should().NotBeEmpty(); + } + + [Theory] + [MemberData(nameof(InvalidRequests))] + public async Task InvalidRequest_ShouldReturn_400(RegisterProductRequest invalidRequest) + { + // Given + + // When + var response = await fixture.Post(invalidRequest); // Then - response.EnsureSuccessStatusCode(); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task RequestForExistingSKUShouldFail_ShouldReturn_409() + { + // Given + var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription); + + var response = await fixture.Post(request); response.StatusCode.Should().Be(HttpStatusCode.Created); + + // When + response = await fixture.Post(request); + + // Then + response.StatusCode.Should().Be(HttpStatusCode.Conflict); } + + private const string ValidName = "VALID_NAME"; + private static string ValidSKU => $"CC{DateTime.Now.Ticks}"; + private const string ValidDescription = "VALID_DESCRIPTION"; + + public static TheoryData ValidRequests = new () + { + new RegisterProductRequest(ValidSKU, ValidName, ValidDescription), + new RegisterProductRequest(ValidSKU, ValidName, null) + }; + + public static TheoryData InvalidRequests = new() + { + new RegisterProductRequest(null, ValidName, ValidDescription), + new RegisterProductRequest("INVALID_SKU", ValidName, ValidDescription), + new RegisterProductRequest(ValidSKU, null, ValidDescription), + }; } } } diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj b/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj index bcdf50ee8..7d5a53c82 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj +++ b/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj @@ -33,7 +33,8 @@ - + + diff --git a/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs index f30ef7576..4a85136e4 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Builder; +using Core.WebApi.Middlewares.ExceptionHandling; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -13,23 +14,23 @@ public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder) webHostBuilder .ConfigureServices(services => { - services.AddMvcCore() + services.AddRouting() .AddAuthorization() .AddCors() - .AddDataAnnotations() - .AddFormatterMappings(); - - services.AddWarehouseServices(); + .AddWarehouseServices(); }) .Configure(app => { app.UseHttpsRedirection() + .UseMiddleware(typeof(ExceptionHandlingMiddleware)) .UseRouting() .UseAuthorization() .UseEndpoints(endpoints => { endpoints.UseWarehouseEndpoints(); }); // Kids, do not try this at home! - app.ApplicationServices.GetRequiredService().Database.Migrate(); + var database = app.ApplicationServices.GetRequiredService().Database; + database.Migrate(); + database.ExecuteSqlRaw("TRUNCATE TABLE \"Product\""); }); return webHostBuilder; diff --git a/Sample/Warehouse/Warehouse.Api/Program.cs b/Sample/Warehouse/Warehouse.Api/Program.cs index 989799d9c..359850504 100644 --- a/Sample/Warehouse/Warehouse.Api/Program.cs +++ b/Sample/Warehouse/Warehouse.Api/Program.cs @@ -1,8 +1,8 @@ +using Core.WebApi.Middlewares.ExceptionHandling; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.OpenApi.Models; using Warehouse; var builder = Host.CreateDefaultBuilder(args) @@ -11,25 +11,15 @@ webBuilder .ConfigureServices(services => { - services.AddMvcCore() - .AddApiExplorer() - .AddAuthorization() + services.AddRouting() .AddCors() - .AddDataAnnotations() - .AddFormatterMappings(); - - services - .AddWarehouseServices() - .AddSwaggerGen(c => - { - c.SwaggerDoc("v1", new OpenApiInfo {Title = "Warehouse.Api", Version = "v1"}); - }); + .AddAuthorization() + .AddWarehouseServices(); }) .Configure(app => { - app.UseSwagger() - .UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "CashRegisters.Api v1")) - .UseHttpsRedirection() + app.UseHttpsRedirection() + .UseMiddleware(typeof(ExceptionHandlingMiddleware)) .UseRouting() .UseAuthorization() .UseEndpoints(endpoints => diff --git a/Sample/Warehouse/Warehouse.Api/Warehouse.Api.csproj b/Sample/Warehouse/Warehouse.Api/Warehouse.Api.csproj index 4f9ddeed5..0d3177930 100644 --- a/Sample/Warehouse/Warehouse.Api/Warehouse.Api.csproj +++ b/Sample/Warehouse/Warehouse.Api/Warehouse.Api.csproj @@ -17,6 +17,7 @@ + diff --git a/Sample/Warehouse/Warehouse.sln b/Sample/Warehouse/Warehouse.sln index 19898dceb..95f16f75c 100644 --- a/Sample/Warehouse/Warehouse.sln +++ b/Sample/Warehouse/Warehouse.sln @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "Warehouse. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api.Tests", "Warehouse.Api.Tests\Warehouse.Api.Tests.csproj", "{6E5C1CF1-29FF-408E-9E01-E7109FB2ECA0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.WebApi", "..\..\Core.WebApi\Core.WebApi.csproj", "{A7A09EBA-0B66-4402-A063-69B47D43A66D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,6 +22,7 @@ Global GlobalSection(NestedProjects) = preSolution {DD7FF547-0FF1-4B10-9248-1E2700BA3770} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} {35632837-CB02-455C-9570-E79E476C1D90} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} + {A7A09EBA-0B66-4402-A063-69B47D43A66D} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {DD7FF547-0FF1-4B10-9248-1E2700BA3770}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -42,5 +45,9 @@ Global {6E5C1CF1-29FF-408E-9E01-E7109FB2ECA0}.Debug|Any CPU.Build.0 = Debug|Any CPU {6E5C1CF1-29FF-408E-9E01-E7109FB2ECA0}.Release|Any CPU.ActiveCfg = Release|Any CPU {6E5C1CF1-29FF-408E-9E01-E7109FB2ECA0}.Release|Any CPU.Build.0 = Release|Any CPU + {A7A09EBA-0B66-4402-A063-69B47D43A66D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7A09EBA-0B66-4402-A063-69B47D43A66D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7A09EBA-0B66-4402-A063-69B47D43A66D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7A09EBA-0B66-4402-A063-69B47D43A66D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs index d20988f5d..80490b7ce 100644 --- a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs +++ b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs @@ -43,7 +43,7 @@ public static async Task ReturnJSON(this HttpContext context, T result, HttpS { context.Response.StatusCode = (int)statusCode; - if (result != null) + if (result == null) return; await context.Response.WriteAsJsonAsync(result); diff --git a/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs b/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs index e4ff49469..acf571395 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs @@ -58,7 +58,7 @@ public static GetProducts Create(string? filter, int? page) if (page <= 0) throw new ArgumentOutOfRangeException(nameof(page)); - return new GetProducts(filter, page.Value); + return new (filter, page.Value); } public void Deconstruct(out string? filter, out int page) diff --git a/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs b/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs index 7281d67cc..9bb422eab 100644 --- a/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs +++ b/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs @@ -16,7 +16,7 @@ public static SKU Create(string? value) { if (value == null) throw new ArgumentNullException(nameof(SKU)); - if (string.IsNullOrWhiteSpace(value) || Regex.IsMatch(value, "[A-Z]{2,4}[0-9]{4,12}")) + if (string.IsNullOrWhiteSpace(value) || !Regex.IsMatch(value, "[A-Z]{2,4}[0-9]{4,18}")) throw new ArgumentOutOfRangeException(nameof(SKU)); return new SKU(value); diff --git a/Sample/Warehouse/Warehouse/Products/Product.cs b/Sample/Warehouse/Warehouse/Products/Product.cs index 61b3512e0..cfef2a39e 100644 --- a/Sample/Warehouse/Warehouse/Products/Product.cs +++ b/Sample/Warehouse/Warehouse/Products/Product.cs @@ -23,6 +23,9 @@ internal class Product /// public string? Description { get; set; } + // Note: this is needed because we're using SKU DTO. + // It would work if we had just primitives + // Should be fixed in .NET 6 private Product(){} public Product(Guid id, SKU sku, string name, string? description) diff --git a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs index ab272c1f8..507abff21 100644 --- a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs +++ b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs @@ -30,8 +30,7 @@ public async ValueTask Handle(RegisterProduct command, CancellationToken ct) ); if (await productWithSKUExists(command.SKU, ct)) - throw new ArgumentOutOfRangeException( - nameof(command.SKU), + throw new InvalidOperationException( $"Product with SKU `{command.SKU} already exists."); await addProduct(product, ct); @@ -42,20 +41,10 @@ public record RegisterProduct { public Guid ProductId { get;} - /// - /// The Stock Keeping Unit (SKU), i.e. a merchant-specific identifier for a product or service, or the product to which the offer refers. - /// - /// public SKU SKU { get; } - /// - /// The area where a cashier works - /// public string Name { get; } - /// - /// Current cashier working on the cash register - /// public string? Description { get; } private RegisterProduct(Guid productId, SKU sku, string name, string? description) @@ -70,7 +59,6 @@ public static RegisterProduct Create(Guid? id, string? sku, string? name, string { if (!id.HasValue) throw new ArgumentNullException(nameof(id)); if (name == null) throw new ArgumentNullException(nameof(name)); - if (description == null) throw new ArgumentNullException(nameof(description)); return new RegisterProduct(id.Value, SKU.Create(sku), name, description); } diff --git a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs index b8970c269..7f34d680e 100644 --- a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs +++ b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs @@ -1,6 +1,5 @@ using System; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Warehouse.Core.Commands; using Warehouse.Core.Extensions; @@ -19,13 +18,6 @@ internal static IEndpointRouteBuilder UseRegisterProductEndpoint(this IEndpointR { endpoints.MapPost("api/products/", async context => { - // var dbContext = WarehouseDBContextFactory.Create(); - // - // var handler = new HandleRegisterProduct( - // dbContext.AddAndSave, - // dbContext.ProductWithSKUExists - // ); - var (sku, name, description) = await context.FromBody(); var productId = Guid.NewGuid(); From fa065c6b689de54c477f68953c4fc5f5cf22ebd2 Mon Sep 17 00:00:00 2001 From: "oskar.dudycz" Date: Sat, 15 May 2021 22:04:11 +0200 Subject: [PATCH 3/4] Added tests for the Warehouse example's queries --- .../GetProductDetailsTests.cs | 94 ++++++++++++ .../GettingProducts/GetProductsTests.cs | 144 ++++++++++++++++++ .../RegisterProductTests.cs | 6 +- .../Warehouse.Api.Tests.csproj | 5 - .../WarehouseTestWebHostBuilder.cs | 18 ++- .../Core/Extensions/HttpExtensions.cs | 69 +++++++-- .../Warehouse/Products/Configuration.cs | 4 +- .../GetProductDetails.cs | 23 ++- .../Products/GettingProductDetails/Route.cs | 8 +- .../Products/GettingProducts/GetProducts.cs | 41 +++-- .../Products/GettingProducts/Route.cs | 12 +- .../Warehouse/Products/Primitives/SKU.cs | 2 + .../Warehouse/Warehouse/Products/Product.cs | 1 + 13 files changed, 382 insertions(+), 45 deletions(-) create mode 100644 Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs create mode 100644 Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs new file mode 100644 index 000000000..5a9cb1bf3 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Core.Testing; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Warehouse.Products.GettingProductDetails; +using Warehouse.Products.GettingProducts; +using Warehouse.Products.RegisteringProduct; +using Xunit; + +namespace Warehouse.Api.Tests.Products.GettingProductDetails +{ + public class GetProductDetailsFixture: ApiFixture + { + protected override string ApiUrl => "/api/products"; + + protected override Func SetupWebHostBuilder => + whb => WarehouseTestWebHostBuilder.Configure(whb, nameof(GetProductDetailsFixture)); + + public ProductDetails ExistingProduct = default!; + + public Guid ProductId = default!; + + public override async Task InitializeAsync() + { + var registerProduct = new RegisterProductRequest("IN11111", "ValidName", "ValidDescription"); + var registerResponse = await Post(registerProduct); + + registerResponse.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.Created); + + ProductId = await registerResponse.GetResultFromJson(); + + var (sku, name, description) = registerProduct; + ExistingProduct = new ProductDetails(ProductId, sku!, name!, description); + } + } + + public class GetProductDetailsTests: IClassFixture + { + private readonly GetProductDetailsFixture fixture; + + public GetProductDetailsTests(GetProductDetailsFixture fixture) + { + this.fixture = fixture; + } + + [Fact] + public async Task ValidRequest_With_NoParams_ShouldReturn_200() + { + // Given + + // When + var response = await fixture.Get(fixture.ProductId.ToString()); + + // Then + response.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.OK); + + var product = await response.GetResultFromJson(); + product.Should().NotBeNull(); + product.Should().BeEquivalentTo(fixture.ExistingProduct); + } + + [Theory] + [InlineData(12)] + [InlineData("not-a-guid")] + public async Task InvalidGuidId_ShouldReturn_400(object invalidId) + { + // Given + + // When + var response = await fixture.Get($"{invalidId}"); + + // Then + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task NotExistingId_ShouldReturn_404() + { + // Given + var notExistingId = Guid.NewGuid(); + + // When + var response = await fixture.Get($"{notExistingId}"); + + // Then + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + } +} diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs new file mode 100644 index 000000000..a6c154b97 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Core.Testing; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Warehouse.Products.GettingProducts; +using Warehouse.Products.RegisteringProduct; +using Xunit; + +namespace Warehouse.Api.Tests.Products.GettingProducts +{ + public class GetProductsFixture: ApiFixture + { + protected override string ApiUrl => "/api/products"; + + protected override Func SetupWebHostBuilder => + whb => WarehouseTestWebHostBuilder.Configure(whb, nameof(GetProductsFixture)); + + public IList RegisteredProducts = new List(); + + public override async Task InitializeAsync() + { + var productsToRegister = new[] + { + new RegisterProductRequest("ZX1234", "ValidName", "ValidDescription"), + new RegisterProductRequest("AD5678", "OtherValidName", "OtherValidDescription"), + new RegisterProductRequest("BH90210", "AnotherValid", "AnotherValidDescription") + }; + + foreach (var registerProduct in productsToRegister) + { + var registerResponse = await Post(registerProduct); + registerResponse.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.Created); + + var createdId = await registerResponse.GetResultFromJson(); + + var (sku, name, _) = registerProduct; + RegisteredProducts.Add(new ProductListItem(createdId, sku!, name!)); + } + } + } + + public class GetProductsTests: IClassFixture + { + private readonly GetProductsFixture fixture; + + public GetProductsTests(GetProductsFixture fixture) + { + this.fixture = fixture; + } + + [Fact] + public async Task ValidRequest_With_NoParams_ShouldReturn_200() + { + // Given + + // When + var response = await fixture.Get(); + + // Then + response.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.OK); + + var products = await response.GetResultFromJson>(); + products.Should().NotBeEmpty(); + products.Should().BeEquivalentTo(fixture.RegisteredProducts); + } + + [Fact] + public async Task ValidRequest_With_Filter_ShouldReturn_SubsetOfRecords() + { + // Given + var filteredRecord = fixture.RegisteredProducts.First(); + var filter = fixture.RegisteredProducts.First().Sku.Substring(1); + + // When + var response = await fixture.Get($"?filter={filter}"); + + // Then + response.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.OK); + + var products = await response.GetResultFromJson>(); + products.Should().NotBeEmpty(); + products.Should().BeEquivalentTo(new List{filteredRecord}); + } + + + + [Fact] + public async Task ValidRequest_With_Paging_ShouldReturn_PageOfRecords() + { + // Given + const int page = 2; + const int pageSize = 1; + var filteredRecords = fixture.RegisteredProducts + .Skip(page - 1) + .Take(pageSize) + .ToList(); + + // When + var response = await fixture.Get($"?page={page}&pageSize={pageSize}"); + + // Then + response.EnsureSuccessStatusCode() + .StatusCode.Should().Be(HttpStatusCode.OK); + + var products = await response.GetResultFromJson>(); + products.Should().NotBeEmpty(); + products.Should().BeEquivalentTo(filteredRecords); + } + + [Fact] + public async Task NegativePage_ShouldReturn_400() + { + // Given + var pageSize = -20; + + // When + var response = await fixture.Get($"?page={pageSize}"); + + // Then + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Theory] + [InlineData(0)] + [InlineData(-20)] + public async Task NegativeOrZeroPageSize_ShouldReturn_400(int pageSize) + { + // Given + + // When + var response = await fixture.Get($"?page={pageSize}"); + + // Then + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + } +} diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs index f38a4ca0a..beb125560 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs @@ -9,14 +9,14 @@ namespace Warehouse.Api.Tests.Products.RegisteringProduct { - public class RegisteringProduct + public class RegisteringProductTests { public class RegisterProductFixture: ApiFixture { protected override string ApiUrl => "/api/products"; protected override Func SetupWebHostBuilder => - WarehouseTestWebHostBuilder.Configure; + whb => WarehouseTestWebHostBuilder.Configure(whb, nameof(RegisterProductFixture)); } public class RegisterProductTests: IClassFixture @@ -78,7 +78,7 @@ public async Task RequestForExistingSKUShouldFail_ShouldReturn_409() private static string ValidSKU => $"CC{DateTime.Now.Ticks}"; private const string ValidDescription = "VALID_DESCRIPTION"; - public static TheoryData ValidRequests = new () + public static TheoryData ValidRequests = new() { new RegisterProductRequest(ValidSKU, ValidName, ValidDescription), new RegisterProductRequest(ValidSKU, ValidName, null) diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj b/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj index 7d5a53c82..fde345ac8 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj +++ b/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj @@ -32,11 +32,6 @@ - - - - - true diff --git a/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs index 4a85136e4..4f3302874 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs @@ -1,15 +1,18 @@ -using Core.WebApi.Middlewares.ExceptionHandling; +using Castle.Core.Configuration; +using Core.WebApi.Middlewares.ExceptionHandling; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Warehouse.Storage; +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; namespace Warehouse.Api.Tests { public static class WarehouseTestWebHostBuilder { - public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder) + public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder, string schemaName) { webHostBuilder .ConfigureServices(services => @@ -17,7 +20,16 @@ public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder) services.AddRouting() .AddAuthorization() .AddCors() - .AddWarehouseServices(); + .AddWarehouseServices() + .AddTransient>(s => + { + var connectionString = s.GetRequiredService().GetConnectionString("WarehouseDB"); + var options = new DbContextOptionsBuilder(); + options.UseNpgsql( + $"{connectionString}; searchpath = {schemaName.ToLower()}", + x => x.MigrationsHistoryTable("__EFMigrationsHistory", schemaName.ToLower())); + return options.Options; + }); }) .Configure(app => { diff --git a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs index 80490b7ce..52125983a 100644 --- a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs +++ b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs @@ -1,36 +1,79 @@ using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; namespace Warehouse.Core.Extensions { public static class HttpExtensions { - public static T FromRoute(this HttpContext context, string name) + public static string FromRoute(this HttpContext context, string name) { - var value = context.Request.RouteValues[name]; + var routeValue = context.Request.RouteValues[name]; + + if (routeValue == null) + throw new ArgumentNullException(name); - if (value is not T typedValue) + if (routeValue is not string stringValue) throw new ArgumentOutOfRangeException(name); - return typedValue; + return stringValue; } - public static T FromQuery(this HttpContext context, string name) + public static T FromRoute(this HttpContext context, string name) + where T: struct { - var value = context.Request.Query[name]; + var routeValue = context.Request.RouteValues[name]; - if (value is not T typedValue) - throw new ArgumentOutOfRangeException(name); + return ConvertTo(routeValue, name) ?? throw new ArgumentNullException(name); + } - return typedValue; + public static string? FromQuery(this HttpContext context, string name) + { + var stringValues = context.Request.Query[name]; + + return !StringValues.IsNullOrEmpty(stringValues) + ? stringValues.ToString() + : null; + } + + + public static T? FromQuery(this HttpContext context, string name) + where T: struct + { + var stringValues = context.Request.Query[name]; + + return !StringValues.IsNullOrEmpty(stringValues) + ? ConvertTo(stringValues.ToString(), name) + : null; } public static async Task FromBody(this HttpContext context) { return await context.Request.ReadFromJsonAsync() ?? - throw new ArgumentNullException("request"); + throw new ArgumentNullException("request"); + } + + public static T? ConvertTo(object? value, string name) + where T: struct + { + if (value == null) + return null; + + T? result; + try + { + result = (T?) TypeDescriptor.GetConverter(typeof(T)).ConvertFrom(value); + } + catch + { + throw new ArgumentOutOfRangeException(name); + } + + return result; } public static Task OK(this HttpContext context, T result) @@ -39,7 +82,11 @@ public static Task OK(this HttpContext context, T result) public static Task Created(this HttpContext context, T result) => context.ReturnJSON(result, HttpStatusCode.Created); - public static async Task ReturnJSON(this HttpContext context, T result, HttpStatusCode statusCode = HttpStatusCode.OK) + public static void NotFound(this HttpContext context) + => context.Response.StatusCode = (int)HttpStatusCode.NotFound; + + public static async Task ReturnJSON(this HttpContext context, T result, + HttpStatusCode statusCode = HttpStatusCode.OK) { context.Response.StatusCode = (int)statusCode; diff --git a/Sample/Warehouse/Warehouse/Products/Configuration.cs b/Sample/Warehouse/Warehouse/Products/Configuration.cs index 9bfe8371e..abbe5a1c7 100644 --- a/Sample/Warehouse/Warehouse/Products/Configuration.cs +++ b/Sample/Warehouse/Warehouse/Products/Configuration.cs @@ -21,12 +21,12 @@ public static IServiceCollection AddProductServices(this IServiceCollection serv var dbContext = s.GetRequiredService(); return new HandleRegisterProduct(dbContext.AddAndSave, dbContext.ProductWithSKUExists); }) - .AddQueryHandler, HandleGetProducts>(s => + .AddQueryHandler, HandleGetProducts>(s => { var dbContext = s.GetRequiredService(); return new HandleGetProducts(dbContext.Set().AsNoTracking()); }) - .AddQueryHandler(s => + .AddQueryHandler(s => { var dbContext = s.GetRequiredService(); return new HandleGetProductDetails(dbContext.Set().AsNoTracking()); diff --git a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs index 6e98cb33e..3862fbe53 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs @@ -8,7 +8,7 @@ namespace Warehouse.Products.GettingProductDetails { - internal class HandleGetProductDetails: IQueryHandler + internal class HandleGetProductDetails: IQueryHandler { private readonly IQueryable products; @@ -17,11 +17,21 @@ public HandleGetProductDetails(IQueryable products) this.products = products; } - public async ValueTask Handle(GetProductDetails query, CancellationToken ct) + public async ValueTask Handle(GetProductDetails query, CancellationToken ct) { // await is needed because of https://github.com/dotnet/efcore/issues/21793#issuecomment-667096367 - return await products + var product = await products .SingleOrDefaultAsync(p => p.Id == query.ProductId, ct); + + if (product == null) + return null; + + return new ProductDetails( + product.Id, + product.Sku.Value, + product.Name, + product.Description + ); } } @@ -37,4 +47,11 @@ private GetProductDetails(Guid productId) public static GetProductDetails Create(Guid productId) => new(productId.AssertNotEmpty(nameof(productId))); } + + public record ProductDetails( + Guid Id, + string Sku, + string Name, + string? Description + ); } diff --git a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs index 7f9b6b686..5dfd31646 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs @@ -19,7 +19,13 @@ internal static IEndpointRouteBuilder UseGetProductDetailsEndpoint(this IEndpoin var query = GetProductDetails.Create(productId); var result = await context - .SendQuery(query); + .SendQuery(query); + + if (result == null) + { + context.NotFound(); + return; + } await context.OK(result); }); diff --git a/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs b/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs index acf571395..1802b27e1 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs @@ -8,9 +8,8 @@ namespace Warehouse.Products.GettingProducts { - internal class HandleGetProducts : IQueryHandler> + internal class HandleGetProducts : IQueryHandler> { - private const int PageSize = 10; private readonly IQueryable products; public HandleGetProducts(IQueryable products) @@ -18,9 +17,9 @@ public HandleGetProducts(IQueryable products) this.products = products; } - public async ValueTask> Handle(GetProducts query, CancellationToken ct) + public async ValueTask> Handle(GetProducts query, CancellationToken ct) { - var (filter, page) = query; + var (filter, page, pageSize) = query; var filteredProducts = string.IsNullOrEmpty(filter) ? products @@ -33,38 +32,56 @@ public async ValueTask> Handle(GetProducts query, Cancell // await is needed because of https://github.com/dotnet/efcore/issues/21793#issuecomment-667096367 return await filteredProducts - .Skip(PageSize * page - 1) - .Take(PageSize) + .Skip(pageSize * (page - 1)) + .Take(pageSize) + .Select(p => new ProductListItem(p.Id, p.Sku.Value, p.Name)) .ToListAsync(ct); } } public record GetProducts { + private const int DefaultPage = 1; + private const int DefaultPageSize = 10; + public string? Filter { get; } public int Page { get; } - private GetProducts(string? filter, int page) + public int PageSize { get; } + + private GetProducts(string? filter, int page, int pageSize) { Filter = filter; Page = page; + PageSize = pageSize; } - public static GetProducts Create(string? filter, int? page) + public static GetProducts Create(string? filter, int? page, int? pageSize) { - page ??= 1; + page ??= DefaultPage; + pageSize ??= DefaultPageSize; if (page <= 0) throw new ArgumentOutOfRangeException(nameof(page)); - return new (filter, page.Value); + if (pageSize <= 0) + throw new ArgumentOutOfRangeException(nameof(pageSize)); + + return new (filter, page.Value, pageSize.Value); } - public void Deconstruct(out string? filter, out int page) + public void Deconstruct(out string? filter, out int page, out int pageSize) { filter = Filter; page = Page; + pageSize = PageSize; } - }; + } + + public record ProductListItem( + Guid Id, + string Sku, + string Name + ); } diff --git a/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs b/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs index 2ca0a111c..d88eeb2e3 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs @@ -16,12 +16,14 @@ internal static IEndpointRouteBuilder UseGetProductsEndpoint(this IEndpointRoute // var dbContext = WarehouseDBContextFactory.Create(); // var handler = new HandleGetProducts(dbContext.Set().AsQueryable()); - var filter = context.FromQuery("filter"); - var page = context.FromQuery("page"); - var query = GetProducts.Create(filter, page); + var filter = context.FromQuery("filter"); + var page = context.FromQuery("page"); + var pageSize = context.FromQuery("pageSize"); - var result = context - .SendQuery>(query); + var query = GetProducts.Create(filter, page, pageSize); + + var result = await context + .SendQuery>(query); await context.OK(result); }); diff --git a/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs b/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs index 9bb422eab..9d1e1e4e0 100644 --- a/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs +++ b/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace Warehouse.Products.Primitives @@ -7,6 +8,7 @@ public record SKU { public string Value { get; init; } + [JsonConstructor] private SKU(string value) { Value = value; diff --git a/Sample/Warehouse/Warehouse/Products/Product.cs b/Sample/Warehouse/Warehouse/Products/Product.cs index cfef2a39e..8aadee1a3 100644 --- a/Sample/Warehouse/Warehouse/Products/Product.cs +++ b/Sample/Warehouse/Warehouse/Products/Product.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json.Serialization; using Warehouse.Products.Primitives; namespace Warehouse.Products From 54fdc6b81551f55c85a3862069af3e60f345f710 Mon Sep 17 00:00:00 2001 From: "oskar.dudycz" Date: Sun, 16 May 2021 10:15:16 +0200 Subject: [PATCH 4/4] Updated project and test configuration to run migrations automatically --- Core.Marten/Config.cs | 7 +++++-- Core.Testing/ApiFixture.cs | 2 ++ Core.Testing/TestWebHostBuilder.cs | 2 +- .../Meetings/CreateMeetingTests.cs | 3 --- .../Meetings/ScheduleMeetingTests.cs | 3 --- .../GetProductDetailsTests.cs | 2 -- .../WarehouseTestWebHostBuilder.cs | 13 ++++--------- Sample/Warehouse/Warehouse.Api/Program.cs | 9 +++------ Sample/Warehouse/Warehouse/Configuration.cs | 16 +++++++++++++++- .../Warehouse/Core/Extensions/HttpExtensions.cs | 11 ++++++++--- .../Core/Primitives/MappingExtensions.cs | 2 +- .../Warehouse/Products/GettingProducts/Route.cs | 3 +-- Sample/Warehouse/Warehouse/Products/Product.cs | 1 - 13 files changed, 40 insertions(+), 34 deletions(-) diff --git a/Core.Marten/Config.cs b/Core.Marten/Config.cs index 5636cd7f8..1935438e9 100644 --- a/Core.Marten/Config.cs +++ b/Core.Marten/Config.cs @@ -53,8 +53,11 @@ private static void SetStoreOptions(StoreOptions options, Config config, { options.Connection(config.ConnectionString); options.AutoCreateSchemaObjects = AutoCreate.CreateOrUpdate; - options.Events.DatabaseSchemaName = config.WriteModelSchema; - options.DatabaseSchemaName = config.ReadModelSchema; + + var schemaName = Environment.GetEnvironmentVariable("SchemaName"); + options.Events.DatabaseSchemaName = schemaName ?? config.WriteModelSchema; + options.DatabaseSchemaName = schemaName ?? config.ReadModelSchema; + options.UseDefaultSerialization(nonPublicMembersStorage: NonPublicMembersStorage.NonPublicSetters, enumStorage: EnumStorage.AsString); options.PLV8Enabled = false; diff --git a/Core.Testing/ApiFixture.cs b/Core.Testing/ApiFixture.cs index b69738634..1ac0b8835 100644 --- a/Core.Testing/ApiFixture.cs +++ b/Core.Testing/ApiFixture.cs @@ -31,6 +31,8 @@ public abstract class ApiFixture: IAsyncLifetime protected ApiFixture() { + Environment.SetEnvironmentVariable("SchemaName", GetType().Name.ToLower()); + Sut = CreateTestContext(); } diff --git a/Core.Testing/TestWebHostBuilder.cs b/Core.Testing/TestWebHostBuilder.cs index b0acae89d..3a2f643d4 100644 --- a/Core.Testing/TestWebHostBuilder.cs +++ b/Core.Testing/TestWebHostBuilder.cs @@ -15,7 +15,7 @@ public static IWebHostBuilder Create(Dictionary configuration, A configureServices ??= _ => { }; return new WebHostBuilder() - .UseEnvironment("Tests") + .UseEnvironment("Development") .UseContentRoot(projectDir) .UseConfiguration(new ConfigurationBuilder() .SetBasePath(projectDir) diff --git a/Sample/MeetingsManagement/MeetingsManagement.IntegrationTests/Meetings/CreateMeetingTests.cs b/Sample/MeetingsManagement/MeetingsManagement.IntegrationTests/Meetings/CreateMeetingTests.cs index a9c09a011..05c6d4bae 100644 --- a/Sample/MeetingsManagement/MeetingsManagement.IntegrationTests/Meetings/CreateMeetingTests.cs +++ b/Sample/MeetingsManagement/MeetingsManagement.IntegrationTests/Meetings/CreateMeetingTests.cs @@ -45,7 +45,6 @@ public CreateMeetingTests(CreateMeetingFixture fixture) } [Fact] - [Trait("Category", "Exercise")] public async Task CreateCommand_ShouldReturn_CreatedStatus_With_MeetingId() { var commandResponse = fixture.CommandResponse; @@ -58,7 +57,6 @@ public async Task CreateCommand_ShouldReturn_CreatedStatus_With_MeetingId() } [Fact] - [Trait("Category", "Exercise")] public void CreateCommand_ShouldPublish_MeetingCreateEvent() { // assert MeetingCreated event was produced to external bus @@ -70,7 +68,6 @@ public void CreateCommand_ShouldPublish_MeetingCreateEvent() } [Fact] - [Trait("Category", "Exercise")] public async Task CreateCommand_ShouldUpdateReadModel() { // prepare query diff --git a/Sample/MeetingsManagement/MeetingsManagement.IntegrationTests/Meetings/ScheduleMeetingTests.cs b/Sample/MeetingsManagement/MeetingsManagement.IntegrationTests/Meetings/ScheduleMeetingTests.cs index 8b0f95412..d01fccfbd 100644 --- a/Sample/MeetingsManagement/MeetingsManagement.IntegrationTests/Meetings/ScheduleMeetingTests.cs +++ b/Sample/MeetingsManagement/MeetingsManagement.IntegrationTests/Meetings/ScheduleMeetingTests.cs @@ -53,7 +53,6 @@ public ScheduleMeetingTests(ScheduleMeetingFixture fixture) } [Fact] - [Trait("Category", "Exercise")] public async Task CreateMeeting_ShouldReturn_CreatedStatus_With_MeetingId() { var commandResponse = fixture.CreateMeetingCommandResponse.EnsureSuccessStatusCode(); @@ -64,7 +63,6 @@ public async Task CreateMeeting_ShouldReturn_CreatedStatus_With_MeetingId() } [Fact] - [Trait("Category", "Exercise")] public async Task ScheduleMeeting_ShouldSucceed() { var commandResponse = fixture.ScheduleMeetingCommandResponse.EnsureSuccessStatusCode(); @@ -75,7 +73,6 @@ public async Task ScheduleMeeting_ShouldSucceed() } [Fact] - [Trait("Category", "Exercise")] public async Task ScheduleMeeting_ShouldUpdateReadModel() { //send query diff --git a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs index 5a9cb1bf3..4d629146d 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs @@ -1,12 +1,10 @@ using System; -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using Core.Testing; using FluentAssertions; using Microsoft.AspNetCore.Hosting; using Warehouse.Products.GettingProductDetails; -using Warehouse.Products.GettingProducts; using Warehouse.Products.RegisteringProduct; using Xunit; diff --git a/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs index 4f3302874..d4616526b 100644 --- a/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs +++ b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs @@ -1,5 +1,4 @@ -using Castle.Core.Configuration; -using Core.WebApi.Middlewares.ExceptionHandling; +using Core.WebApi.Middlewares.ExceptionHandling; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; @@ -18,8 +17,6 @@ public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder, string s .ConfigureServices(services => { services.AddRouting() - .AddAuthorization() - .AddCors() .AddWarehouseServices() .AddTransient>(s => { @@ -33,15 +30,13 @@ public static IWebHostBuilder Configure(IWebHostBuilder webHostBuilder, string s }) .Configure(app => { - app.UseHttpsRedirection() - .UseMiddleware(typeof(ExceptionHandlingMiddleware)) + app.UseMiddleware(typeof(ExceptionHandlingMiddleware)) .UseRouting() - .UseAuthorization() - .UseEndpoints(endpoints => { endpoints.UseWarehouseEndpoints(); }); + .UseEndpoints(endpoints => { endpoints.UseWarehouseEndpoints(); }) + .ConfigureWarehouse(); // Kids, do not try this at home! var database = app.ApplicationServices.GetRequiredService().Database; - database.Migrate(); database.ExecuteSqlRaw("TRUNCATE TABLE \"Product\""); }); diff --git a/Sample/Warehouse/Warehouse.Api/Program.cs b/Sample/Warehouse/Warehouse.Api/Program.cs index 359850504..bbd7f59da 100644 --- a/Sample/Warehouse/Warehouse.Api/Program.cs +++ b/Sample/Warehouse/Warehouse.Api/Program.cs @@ -12,20 +12,17 @@ .ConfigureServices(services => { services.AddRouting() - .AddCors() - .AddAuthorization() .AddWarehouseServices(); }) .Configure(app => { - app.UseHttpsRedirection() - .UseMiddleware(typeof(ExceptionHandlingMiddleware)) + app.UseMiddleware(typeof(ExceptionHandlingMiddleware)) .UseRouting() - .UseAuthorization() .UseEndpoints(endpoints => { endpoints.UseWarehouseEndpoints(); - }); + }) + .ConfigureWarehouse(); }); }) .Build(); diff --git a/Sample/Warehouse/Warehouse/Configuration.cs b/Sample/Warehouse/Warehouse/Configuration.cs index 626363940..403c14c0a 100644 --- a/Sample/Warehouse/Warehouse/Configuration.cs +++ b/Sample/Warehouse/Warehouse/Configuration.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Routing; +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Warehouse.Products; @@ -16,5 +18,17 @@ public static IServiceCollection AddWarehouseServices(this IServiceCollection se public static IEndpointRouteBuilder UseWarehouseEndpoints(this IEndpointRouteBuilder endpoints) => endpoints.UseProductsEndpoints(); + + public static IApplicationBuilder ConfigureWarehouse(this IApplicationBuilder app) + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + if (environment == "Development") + { + app.ApplicationServices.GetRequiredService().Database.Migrate(); + } + + return app; + } } } diff --git a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs index 52125983a..c8dbf755f 100644 --- a/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs +++ b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs @@ -1,10 +1,11 @@ using System; using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Headers; using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; namespace Warehouse.Core.Extensions { @@ -79,8 +80,12 @@ public static async Task FromBody(this HttpContext context) public static Task OK(this HttpContext context, T result) => context.ReturnJSON(result); - public static Task Created(this HttpContext context, T result) - => context.ReturnJSON(result, HttpStatusCode.Created); + public static Task Created(this HttpContext context, T id, string? location = null) + { + context.Response.Headers[HeaderNames.Location] = location ?? $"{context.Request.Path}{id}"; + + return context.ReturnJSON(id, HttpStatusCode.Created); + } public static void NotFound(this HttpContext context) => context.Response.StatusCode = (int)HttpStatusCode.NotFound; diff --git a/Sample/Warehouse/Warehouse/Core/Primitives/MappingExtensions.cs b/Sample/Warehouse/Warehouse/Core/Primitives/MappingExtensions.cs index 89e89e358..53df141e3 100644 --- a/Sample/Warehouse/Warehouse/Core/Primitives/MappingExtensions.cs +++ b/Sample/Warehouse/Warehouse/Core/Primitives/MappingExtensions.cs @@ -14,7 +14,7 @@ public static T AssertNotNull(this T? value, string? paramName = null) } public static string AssertNotEmpty(this string? value, string? paramName = null) - => string.IsNullOrWhiteSpace(value) ? value! : throw new ArgumentOutOfRangeException(paramName); + => !string.IsNullOrWhiteSpace(value) ? value : throw new ArgumentOutOfRangeException(paramName); public static T AssertNotEmpty(this T value, string? paramName = null) where T : struct diff --git a/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs b/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs index d88eeb2e3..b1fc9df69 100644 --- a/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Warehouse.Core.Extensions; diff --git a/Sample/Warehouse/Warehouse/Products/Product.cs b/Sample/Warehouse/Warehouse/Products/Product.cs index 8aadee1a3..cfef2a39e 100644 --- a/Sample/Warehouse/Warehouse/Products/Product.cs +++ b/Sample/Warehouse/Warehouse/Products/Product.cs @@ -1,5 +1,4 @@ using System; -using System.Text.Json.Serialization; using Warehouse.Products.Primitives; namespace Warehouse.Products