diff --git a/Core.Marten/Config.cs b/Core.Marten/Config.cs index e696b6cfe..d3856f2a4 100644 --- a/Core.Marten/Config.cs +++ b/Core.Marten/Config.cs @@ -54,8 +54,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.Events.Daemon.Mode = config.DaemonMode; 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/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/GettingProductDetails/GetProductDetailsTests.cs b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs new file mode 100644 index 000000000..4d629146d --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Core.Testing; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Warehouse.Products.GettingProductDetails; +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 new file mode 100644 index 000000000..beb125560 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Net; +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 RegisteringProductTests + { + public class RegisterProductFixture: ApiFixture + { + protected override string ApiUrl => "/api/products"; + + protected override Func SetupWebHostBuilder => + whb => WarehouseTestWebHostBuilder.Configure(whb, nameof(RegisterProductFixture)); + } + + public class RegisterProductTests: IClassFixture + { + private readonly RegisterProductFixture fixture; + + public RegisterProductTests(RegisterProductFixture fixture) + { + this.fixture = fixture; + } + + [Theory] + [MemberData(nameof(ValidRequests))] + public async Task ValidRequest_ShouldReturn_201(RegisterProductRequest validRequest) + { + // Given + + // When + 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.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 new file mode 100644 index 000000000..fde345ac8 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj @@ -0,0 +1,48 @@ + + + + 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..d4616526b --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs @@ -0,0 +1,46 @@ +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, string schemaName) + { + webHostBuilder + .ConfigureServices(services => + { + services.AddRouting() + .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 => + { + app.UseMiddleware(typeof(ExceptionHandlingMiddleware)) + .UseRouting() + .UseEndpoints(endpoints => { endpoints.UseWarehouseEndpoints(); }) + .ConfigureWarehouse(); + + // Kids, do not try this at home! + var database = app.ApplicationServices.GetRequiredService().Database; + database.ExecuteSqlRaw("TRUNCATE TABLE \"Product\""); + }); + + 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..bbd7f59da --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api/Program.cs @@ -0,0 +1,29 @@ +using Core.WebApi.Middlewares.ExceptionHandling; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Warehouse; + +var builder = Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder + .ConfigureServices(services => + { + services.AddRouting() + .AddWarehouseServices(); + }) + .Configure(app => + { + app.UseMiddleware(typeof(ExceptionHandlingMiddleware)) + .UseRouting() + .UseEndpoints(endpoints => + { + endpoints.UseWarehouseEndpoints(); + }) + .ConfigureWarehouse(); + }); + }) + .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..e5c92db59 --- /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" + } + }, + "Products.Api": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "launchUrl": "api/products", + "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..0d3177930 --- /dev/null +++ b/Sample/Warehouse/Warehouse.Api/Warehouse.Api.csproj @@ -0,0 +1,24 @@ + + + + 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..95f16f75c --- /dev/null +++ b/Sample/Warehouse/Warehouse.sln @@ -0,0 +1,53 @@ + +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 +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 + 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} + {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 + {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 + {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/Configuration.cs b/Sample/Warehouse/Warehouse/Configuration.cs new file mode 100644 index 000000000..403c14c0a --- /dev/null +++ b/Sample/Warehouse/Warehouse/Configuration.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.AspNetCore.Builder; +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(); + + 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/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..c8dbf755f --- /dev/null +++ b/Sample/Warehouse/Warehouse/Core/Extensions/HttpExtensions.cs @@ -0,0 +1,104 @@ +using System; +using System.ComponentModel; +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 +{ + public static class HttpExtensions + { + public static string FromRoute(this HttpContext context, string name) + { + var routeValue = context.Request.RouteValues[name]; + + if (routeValue == null) + throw new ArgumentNullException(name); + + if (routeValue is not string stringValue) + throw new ArgumentOutOfRangeException(name); + + return stringValue; + } + + public static T FromRoute(this HttpContext context, string name) + where T: struct + { + var routeValue = context.Request.RouteValues[name]; + + return ConvertTo(routeValue, name) ?? throw new ArgumentNullException(name); + } + + 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"); + } + + 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) + => context.ReturnJSON(result); + + 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; + + 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..53df141e3 --- /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..abbe5a1c7 --- /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..3862fbe53 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/GetProductDetails.cs @@ -0,0 +1,57 @@ +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 + 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 + ); + } + } + + public record GetProductDetails + { + public Guid ProductId { get;} + + private GetProductDetails(Guid productId) + { + ProductId = 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 new file mode 100644 index 000000000..5dfd31646 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/GettingProductDetails/Route.cs @@ -0,0 +1,35 @@ +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); + + if (result == null) + { + context.NotFound(); + return; + } + + 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..1802b27e1 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/GetProducts.cs @@ -0,0 +1,87 @@ +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 readonly IQueryable products; + + public HandleGetProducts(IQueryable products) + { + this.products = products; + } + + public async ValueTask> Handle(GetProducts query, CancellationToken ct) + { + var (filter, page, pageSize) = 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) + .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; } + + 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, int? pageSize) + { + page ??= DefaultPage; + pageSize ??= DefaultPageSize; + + if (page <= 0) + throw new ArgumentOutOfRangeException(nameof(page)); + + if (pageSize <= 0) + throw new ArgumentOutOfRangeException(nameof(pageSize)); + + return new (filter, page.Value, pageSize.Value); + } + + 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 new file mode 100644 index 000000000..b1fc9df69 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/GettingProducts/Route.cs @@ -0,0 +1,32 @@ +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 pageSize = context.FromQuery("pageSize"); + + var query = GetProducts.Create(filter, page, pageSize); + + var result = await 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..9d1e1e4e0 --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/Primitives/SKU.cs @@ -0,0 +1,27 @@ +using System; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Warehouse.Products.Primitives +{ + public record SKU + { + public string Value { get; init; } + + [JsonConstructor] + 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,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 new file mode 100644 index 000000000..cfef2a39e --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/Product.cs @@ -0,0 +1,39 @@ +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; } + + // 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) + { + 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..3f29a122b --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/RegisterProduct.cs @@ -0,0 +1,68 @@ +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 InvalidOperationException( + $"Product with SKU `{command.SKU} already exists."); + + await addProduct(product, ct); + } + } + + public record RegisterProduct + { + public Guid ProductId { get;} + + public SKU SKU { get; } + + public string Name { get; } + + 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 || id == Guid.Empty) throw new ArgumentOutOfRangeException(nameof(id)); + if (string.IsNullOrEmpty(sku)) throw new ArgumentOutOfRangeException(nameof(sku)); + if (string.IsNullOrEmpty(name)) throw new ArgumentOutOfRangeException(nameof(name)); + if (description is "") throw new ArgumentOutOfRangeException(nameof(name)); + + return new RegisterProduct(id.Value, SKU.Create(sku), name, description); + } + } +} diff --git a/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs new file mode 100644 index 000000000..7f34d680e --- /dev/null +++ b/Sample/Warehouse/Warehouse/Products/RegisteringProduct/Route.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Warehouse.Core.Commands; +using Warehouse.Core.Extensions; + +namespace Warehouse.Products.RegisteringProduct +{ + public record RegisterProductRequest( + string? SKU, + string? Name, + string? Description + ); + + internal static class Route + { + internal static IEndpointRouteBuilder UseRegisterProductEndpoint(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost("api/products/", async context => + { + var (sku, name, description) = await context.FromBody(); + var productId = Guid.NewGuid(); + + var command = RegisterProduct.Create(productId, sku, name, description); + + await context.SendCommand(command); + + await context.Created(productId); + }); + + return endpoints; + } + } +} + + 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 + + + + +