From 3de1b397e6d1fed4480c0cf6f03a60c32c21458d Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 14 Dec 2021 15:15:32 +0100 Subject: [PATCH 1/4] Added ESDB project for Event Pipelines samples --- EventSourcing.NetCore.sln | 7 +++++ .../EventPipelines.EventStoreDB.csproj | 27 +++++++++++++++++++ .../SubscriptionsTests.cs | 6 +++++ 3 files changed, 40 insertions(+) create mode 100644 Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj create mode 100644 Sample/EventPipelines/EventPipelines.EventStoreDB/SubscriptionsTests.cs diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index dccf32d74..009feb2d3 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -323,6 +323,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "13-Projections.SingleStream EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "14-Projections.SingleStream.EventualConsistency", "Workshops\IntroductionToEventSourcing\Solved\14-Projections.SingleStream.EventualConsistency\14-Projections.SingleStream.EventualConsistency.csproj", "{A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventPipelines.EventStoreDB", "Sample\EventPipelines\EventPipelines.EventStoreDB\EventPipelines.EventStoreDB.csproj", "{9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -737,6 +739,10 @@ Global {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}.Debug|Any CPU.Build.0 = Debug|Any CPU {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}.Release|Any CPU.ActiveCfg = Release|Any CPU {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}.Release|Any CPU.Build.0 = Release|Any CPU + {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -867,6 +873,7 @@ Global {2CA4B0B3-AF8B-4BD8-A18D-18EE1F93DE04} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} {7A990FD1-4935-4F85-912C-D4B870232856} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} + {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0} = {D9799DB3-9D11-4909-8133-64CD96F6E1AC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B} diff --git a/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj b/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj new file mode 100644 index 000000000..de205fcc7 --- /dev/null +++ b/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj @@ -0,0 +1,27 @@ + + + + net6.0 + enable + true + trx%3bLogFileName=$(MSBuildProjectName).trx + $(MSBuildThisFileDirectory)/bin/TestResults/$(TargetFramework) + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + diff --git a/Sample/EventPipelines/EventPipelines.EventStoreDB/SubscriptionsTests.cs b/Sample/EventPipelines/EventPipelines.EventStoreDB/SubscriptionsTests.cs new file mode 100644 index 000000000..525362bee --- /dev/null +++ b/Sample/EventPipelines/EventPipelines.EventStoreDB/SubscriptionsTests.cs @@ -0,0 +1,6 @@ +namespace EventPipelines.EventStoreDB; + +public class SubscriptionsTests +{ + +} From 8e39c2e4c787120f3060d93a51e6737c0b0c296a Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 14 Dec 2021 15:35:31 +0100 Subject: [PATCH 2/4] Added Initial structured for CQRS with minimal APIs as slimmed version of endpoints sample --- Sample/Warehouse.MinimalAPI/README.md | 5 + .../GetProductDetailsTests.cs | 91 +++++++++++ .../GettingProducts/GetProductsTests.cs | 143 ++++++++++++++++++ .../RegisterProductTests.cs | 94 ++++++++++++ .../Warehouse.Api.Tests.csproj | 46 ++++++ .../WarehouseTestWebHostBuilder.cs | 45 ++++++ .../appsettings.Development.json | 9 ++ .../Warehouse.Api.Tests/appsettings.json | 13 ++ .../Warehouse.Api/Program.cs | 101 +++++++++++++ .../Properties/launchSettings.json | 31 ++++ .../Warehouse.Api/Warehouse.Api.csproj | 24 +++ .../appsettings.Development.json | 9 ++ .../Warehouse.Api/appsettings.json | 13 ++ Sample/Warehouse.MinimalAPI/Warehouse.sln | 46 ++++++ .../Warehouse/Configuration.cs | 33 ++++ .../Core/Commands/ICommandHandler.cs | 43 ++++++ .../Core/Entities/EntitiesExtensions.cs | 19 +++ .../Core/Extensions/HttpExtensions.cs | 102 +++++++++++++ .../Core/Primitives/MappingExtensions.cs | 33 ++++ .../Warehouse/Core/Queries/IQueryHandler.cs | 42 +++++ .../20210512081922_InitialSetup.Designer.cs | 67 ++++++++ .../Migrations/20210512081922_InitialSetup.cs | 31 ++++ .../WarehouseDBContextModelSnapshot.cs | 65 ++++++++ .../Warehouse/Products/Configuration.cs | 45 ++++++ .../GetProductDetails.cs | 56 +++++++ .../Products/GettingProductDetails/Route.cs | 34 +++++ .../Products/GettingProducts/GetProducts.cs | 86 +++++++++++ .../Products/GettingProducts/Route.cs | 31 ++++ .../Warehouse/Products/Primitives/SKU.cs | 26 ++++ .../Warehouse/Products/Product.cs | 38 +++++ .../Warehouse/Products/ProductsRepository.cs | 13 ++ .../RegisteringProduct/RegisterProduct.cs | 67 ++++++++ .../Products/RegisteringProduct/Route.cs | 33 ++++ .../Warehouse/Storage/WarehouseDBContext.cs | 51 +++++++ .../Warehouse/Warehouse.csproj | 31 ++++ 35 files changed, 1616 insertions(+) create mode 100644 Sample/Warehouse.MinimalAPI/README.md create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.Development.json create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.json create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.Development.json create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.json create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.sln create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Configuration.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Core/Commands/ICommandHandler.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Core/Entities/EntitiesExtensions.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Core/Extensions/HttpExtensions.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Core/Primitives/MappingExtensions.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Core/Queries/IQueryHandler.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/Configuration.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/GetProductDetails.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/Route.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/GetProducts.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/Route.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/Primitives/SKU.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/Product.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/ProductsRepository.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/RegisterProduct.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/Route.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Storage/WarehouseDBContext.cs create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Warehouse.csproj diff --git a/Sample/Warehouse.MinimalAPI/README.md b/Sample/Warehouse.MinimalAPI/README.md new file mode 100644 index 000000000..9f53614c0 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/README.md @@ -0,0 +1,5 @@ +# Warehouse +- simplest CQRS flow using .NET 5 Endpoints, +- example of how and where to use C# Records, Nullable Reference Types, etc, +- No Event Sourcing! Using Entity Framework to show that CQRS is not bounded to Event Sourcing or any type of storage, +- No Aggregates! CQRS do not need DDD. Business logic can be handled in handlers. \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs new file mode 100644 index 000000000..e63dd6845 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Core.Api.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); + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs new file mode 100644 index 000000000..73020c8aa --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Core.Api.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); + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs new file mode 100644 index 000000000..1c3e7152b --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Core.Api.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), + }; + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj new file mode 100644 index 000000000..ef67d48ac --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj @@ -0,0 +1,46 @@ + + + + net6.0 + enable + true + trx%3bLogFileName=$(MSBuildProjectName).trx + $(MSBuildThisFileDirectory)/bin/TestResults/$(TargetFramework) + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + true + PreserveNewest + PreserveNewest + + + true + PreserveNewest + PreserveNewest + + + + diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs new file mode 100644 index 000000000..09b3b5677 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs @@ -0,0 +1,45 @@ +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.UseExceptionHandlingMiddleware() + .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.MinimalAPI/Warehouse.Api.Tests/appsettings.Development.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.Development.json new file mode 100644 index 000000000..8983e0fc1 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/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.MinimalAPI/Warehouse.Api.Tests/appsettings.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.json new file mode 100644 index 000000000..521450942 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/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.MinimalAPI/Warehouse.Api/Program.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs new file mode 100644 index 000000000..037b4deda --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs @@ -0,0 +1,101 @@ +using Core.WebApi.Middlewares.ExceptionHandling; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Warehouse; + +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); +builder.Services +var app = builder.Build(); + +app.MapGet("/", () => "Hello World!"); + +app.MapGet("/todoitems", async (TodoDb db) => + await db.Todos.ToListAsync()); + +app.MapGet("/todoitems/complete", async (TodoDb db) => + await db.Todos.Where(t => t.IsComplete).ToListAsync()); + +app.MapGet("/todoitems/{id}", async (int id, TodoDb db) => + await db.Todos.FindAsync(id) + is Todo todo + ? Results.Ok(todo) + : Results.NotFound()); + +app.MapPost("/todoitems", async (Todo todo, TodoDb db) => +{ + db.Todos.Add(todo); + await db.SaveChangesAsync(); + + return Results.Created($"/todoitems/{todo.Id}", todo); +}); + +app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) => +{ + var todo = await db.Todos.FindAsync(id); + + if (todo is null) return Results.NotFound(); + + todo.Name = inputTodo.Name; + todo.IsComplete = inputTodo.IsComplete; + + await db.SaveChangesAsync(); + + return Results.NoContent(); +}); + +app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) => +{ + if (await db.Todos.FindAsync(id) is Todo todo) + { + db.Todos.Remove(todo); + await db.SaveChangesAsync(); + return Results.Ok(todo); + } + + return Results.NotFound(); +}); + +app.Run(); + +class Todo +{ + public int Id { get; set; } + public string? Name { get; set; } + public bool IsComplete { get; set; } +} + +class TodoDb : DbContext +{ + public TodoDb(DbContextOptions options) + : base(options) { } + + public DbSet Todos => Set(); +} + + +// var builder = Host.CreateDefaultBuilder(args) +// .ConfigureWebHostDefaults(webBuilder => +// { +// webBuilder +// .ConfigureServices(services => +// { +// services.AddRouting() +// .AddWarehouseServices(); +// }) +// .Configure(app => +// { +// app.UseExceptionHandlingMiddleware() +// .UseRouting() +// .UseEndpoints(endpoints => +// { +// endpoints.UseWarehouseEndpoints(); +// }) +// .ConfigureWarehouse(); +// }); +// }) +// .Build(); +// builder.Run(); diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json new file mode 100644 index 000000000..e5c92db59 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/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.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj new file mode 100644 index 000000000..cdab03793 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.Development.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.Development.json new file mode 100644 index 000000000..8983e0fc1 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.json new file mode 100644 index 000000000..521450942 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/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.MinimalAPI/Warehouse.sln b/Sample/Warehouse.MinimalAPI/Warehouse.sln new file mode 100644 index 000000000..66cf6431b --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/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}") = "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 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Api.Testing", "..\..\Core.Api.Testing\Core.Api.Testing.csproj", "{49B8A573-5F71-4087-B30D-D72459ABA0E2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A7A09EBA-0B66-4402-A063-69B47D43A66D} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} + {49B8A573-5F71-4087-B30D-D72459ABA0E2} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {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 + {49B8A573-5F71-4087-B30D-D72459ABA0E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49B8A573-5F71-4087-B30D-D72459ABA0E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49B8A573-5F71-4087-B30D-D72459ABA0E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49B8A573-5F71-4087-B30D-D72459ABA0E2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Configuration.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Configuration.cs new file mode 100644 index 000000000..5a58c59a5 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Configuration.cs @@ -0,0 +1,33 @@ +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") ?? "Development"; + + if (environment == "Development") + { + app.ApplicationServices.GetRequiredService().Database.Migrate(); + } + + return app; + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Commands/ICommandHandler.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Commands/ICommandHandler.cs new file mode 100644 index 000000000..ed303e9af --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Commands/ICommandHandler.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.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); +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Entities/EntitiesExtensions.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Entities/EntitiesExtensions.cs new file mode 100644 index 000000000..af097dc27 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Entities/EntitiesExtensions.cs @@ -0,0 +1,19 @@ +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); +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Extensions/HttpExtensions.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Extensions/HttpExtensions.cs new file mode 100644 index 000000000..e53e776f1 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Extensions/HttpExtensions.cs @@ -0,0 +1,102 @@ +using System; +using System.ComponentModel; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +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); + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Primitives/MappingExtensions.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Primitives/MappingExtensions.cs new file mode 100644 index 000000000..219d5d69a --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Primitives/MappingExtensions.cs @@ -0,0 +1,33 @@ +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; + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Queries/IQueryHandler.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Queries/IQueryHandler.cs new file mode 100644 index 000000000..66175bedb --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Queries/IQueryHandler.cs @@ -0,0 +1,42 @@ +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); +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs new file mode 100644 index 000000000..bf8e29d14 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/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.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.cs new file mode 100644 index 000000000..e921a219d --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/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.MinimalAPI/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs new file mode 100644 index 000000000..a53632ce1 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/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.MinimalAPI/Warehouse/Products/Configuration.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/Configuration.cs new file mode 100644 index 000000000..addb3ad09 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/Configuration.cs @@ -0,0 +1,45 @@ +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); +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/GetProductDetails.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/GetProductDetails.cs new file mode 100644 index 000000000..0a40197cf --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/GetProductDetails.cs @@ -0,0 +1,56 @@ +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 +); \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/Route.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/Route.cs new file mode 100644 index 000000000..878d7d92d --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/Route.cs @@ -0,0 +1,34 @@ +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; + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/GetProducts.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/GetProducts.cs new file mode 100644 index 000000000..a479f18d6 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/GetProducts.cs @@ -0,0 +1,86 @@ +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 +); \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/Route.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/Route.cs new file mode 100644 index 000000000..94f9a549b --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/Route.cs @@ -0,0 +1,31 @@ +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; + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/Primitives/SKU.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/Primitives/SKU.cs new file mode 100644 index 000000000..3bb975ac5 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/Primitives/SKU.cs @@ -0,0 +1,26 @@ +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); + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/Product.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/Product.cs new file mode 100644 index 000000000..28956158d --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/Product.cs @@ -0,0 +1,38 @@ +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; + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/ProductsRepository.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/ProductsRepository.cs new file mode 100644 index 000000000..3683333de --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/ProductsRepository.cs @@ -0,0 +1,13 @@ +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)); +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/RegisterProduct.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/RegisterProduct.cs new file mode 100644 index 000000000..f319e12af --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/RegisterProduct.cs @@ -0,0 +1,67 @@ +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); + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/Route.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/Route.cs new file mode 100644 index 000000000..41ee1a3c9 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/Route.cs @@ -0,0 +1,33 @@ +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; + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Storage/WarehouseDBContext.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Storage/WarehouseDBContext.cs new file mode 100644 index 000000000..f768a5ed6 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Storage/WarehouseDBContext.cs @@ -0,0 +1,51 @@ +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(); +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Warehouse.csproj b/Sample/Warehouse.MinimalAPI/Warehouse/Warehouse.csproj new file mode 100644 index 000000000..287aecdc8 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse/Warehouse.csproj @@ -0,0 +1,31 @@ + + + + net6.0 + enable + true + + + + + + + + + <_Parameter1>$(MSBuildProjectName).Tests + + + <_Parameter1>$(MSBuildProjectName).Api.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + From 9eb89a976ac039cacc20fd98de6ada7cafb3fb17 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 14 Dec 2021 17:01:01 +0100 Subject: [PATCH 3/4] Performed migration from endpoints to Minimal API --- EventSourcing.NetCore.sln | 14 ++ .../EventPipelines.EventStoreDB.csproj | 6 +- .../GetProductDetailsTests.cs | 91 ---------- .../GettingProducts/GetProductsTests.cs | 143 ---------------- .../RegisterProductTests.cs | 94 ----------- .../Warehouse.Api.Tests.csproj | 46 ------ .../WarehouseTestWebHostBuilder.cs | 45 ----- .../appsettings.Development.json | 9 - .../Warehouse.Api.Tests/appsettings.json | 13 -- .../Core/Commands/ICommandHandler.cs | 26 +-- .../Core/Entities/EntitiesExtensions.cs | 10 +- .../Core/Primitives/MappingExtensions.cs | 2 +- .../Core/Queries/IQueryHandler.cs | 45 +++++ .../20211214161109_Initial.Designer.cs} | 23 +-- .../Migrations/20211214161109_Initial.cs} | 6 +- .../WarehouseDBContextModelSnapshot.cs | 19 ++- .../Warehouse.Api/Products/Configuration.cs | 32 ++++ .../Products}/GetProductDetails.cs | 22 +-- .../Products}/GetProducts.cs | 35 +--- .../Products/Product.cs | 31 +++- .../Products}/RegisterProduct.cs | 32 ++-- .../Warehouse.Api/Program.cs | 156 ++++++++---------- .../Properties/launchSettings.json | 18 +- .../Storage/WarehouseDBContext.cs | 6 +- .../Warehouse.Api/Warehouse.Api.csproj | 11 +- Sample/Warehouse.MinimalAPI/Warehouse.sln | 22 ++- .../Warehouse/Configuration.cs | 33 ---- .../Core/Extensions/HttpExtensions.cs | 102 ------------ .../Warehouse/Core/Queries/IQueryHandler.cs | 42 ----- .../Warehouse/Products/Configuration.cs | 45 ----- .../Products/GettingProductDetails/Route.cs | 34 ---- .../Products/GettingProducts/Route.cs | 31 ---- .../Warehouse/Products/Primitives/SKU.cs | 26 --- .../Warehouse/Products/ProductsRepository.cs | 13 -- .../Products/RegisteringProduct/Route.cs | 33 ---- .../Warehouse/Warehouse.csproj | 31 ---- .../Warehouse.MinimalAPI/docker-compose.yml | 33 ++++ 37 files changed, 322 insertions(+), 1058 deletions(-) delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.Development.json delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.json rename Sample/Warehouse.MinimalAPI/{Warehouse => Warehouse.Api}/Core/Commands/ICommandHandler.cs (64%) rename Sample/Warehouse.MinimalAPI/{Warehouse => Warehouse.Api}/Core/Entities/EntitiesExtensions.cs (62%) rename Sample/Warehouse.MinimalAPI/{Warehouse => Warehouse.Api}/Core/Primitives/MappingExtensions.cs (95%) create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Queries/IQueryHandler.cs rename Sample/Warehouse.MinimalAPI/{Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs => Warehouse.Api/Migrations/20211214161109_Initial.Designer.cs} (76%) rename Sample/Warehouse.MinimalAPI/{Warehouse/Migrations/20210512081922_InitialSetup.cs => Warehouse.Api/Migrations/20211214161109_Initial.cs} (90%) rename Sample/Warehouse.MinimalAPI/{Warehouse => Warehouse.Api}/Migrations/WarehouseDBContextModelSnapshot.cs (79%) create mode 100644 Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Configuration.cs rename Sample/Warehouse.MinimalAPI/{Warehouse/Products/GettingProductDetails => Warehouse.Api/Products}/GetProductDetails.cs (69%) rename Sample/Warehouse.MinimalAPI/{Warehouse/Products/GettingProducts => Warehouse.Api/Products}/GetProducts.cs (69%) rename Sample/Warehouse.MinimalAPI/{Warehouse => Warehouse.Api}/Products/Product.cs (56%) rename Sample/Warehouse.MinimalAPI/{Warehouse/Products/RegisteringProduct => Warehouse.Api/Products}/RegisterProduct.cs (72%) rename Sample/Warehouse.MinimalAPI/{Warehouse => Warehouse.Api}/Storage/WarehouseDBContext.cs (96%) delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Configuration.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Core/Extensions/HttpExtensions.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Core/Queries/IQueryHandler.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/Configuration.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/Route.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/Route.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/Primitives/SKU.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/ProductsRepository.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/Route.cs delete mode 100644 Sample/Warehouse.MinimalAPI/Warehouse/Warehouse.csproj create mode 100644 Sample/Warehouse.MinimalAPI/docker-compose.yml diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index 009feb2d3..7cc5d996e 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -325,6 +325,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "14-Projections.SingleStream EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventPipelines.EventStoreDB", "Sample\EventPipelines\EventPipelines.EventStoreDB\EventPipelines.EventStoreDB.csproj", "{9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Warehouse.MinimalAPI", "Warehouse.MinimalAPI", "{005FC7CE-97BA-47F7-982E-63C46327F78C}" + ProjectSection(SolutionItems) = preProject + Sample\Warehouse.MinimalAPI\docker-compose.yml = Sample\Warehouse.MinimalAPI\docker-compose.yml + Sample\Warehouse.MinimalAPI\README.md = Sample\Warehouse.MinimalAPI\README.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "Sample\Warehouse.MinimalAPI\Warehouse.Api\Warehouse.Api.csproj", "{723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -743,6 +751,10 @@ Global {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Release|Any CPU.Build.0 = Release|Any CPU + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -874,6 +886,8 @@ Global {7A990FD1-4935-4F85-912C-D4B870232856} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0} = {D9799DB3-9D11-4909-8133-64CD96F6E1AC} + {005FC7CE-97BA-47F7-982E-63C46327F78C} = {A7186B6B-D56D-4AEF-B6B7-FAA827764C34} + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E} = {005FC7CE-97BA-47F7-982E-63C46327F78C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B} diff --git a/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj b/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj index de205fcc7..d0214f4a7 100644 --- a/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj +++ b/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj @@ -9,9 +9,9 @@ - - - + + + all diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs deleted file mode 100644 index e63dd6845..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System; -using System.Net; -using System.Threading.Tasks; -using Core.Api.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); - } -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs deleted file mode 100644 index 73020c8aa..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Core.Api.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); - } -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs deleted file mode 100644 index 1c3e7152b..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Net; -using System.Threading.Tasks; -using Core.Api.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), - }; - } -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj deleted file mode 100644 index ef67d48ac..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj +++ /dev/null @@ -1,46 +0,0 @@ - - - - net6.0 - enable - true - trx%3bLogFileName=$(MSBuildProjectName).trx - $(MSBuildThisFileDirectory)/bin/TestResults/$(TargetFramework) - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - - true - PreserveNewest - PreserveNewest - - - true - PreserveNewest - PreserveNewest - - - - diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs deleted file mode 100644 index 09b3b5677..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/WarehouseTestWebHostBuilder.cs +++ /dev/null @@ -1,45 +0,0 @@ -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.UseExceptionHandlingMiddleware() - .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.MinimalAPI/Warehouse.Api.Tests/appsettings.Development.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.Development.json deleted file mode 100644 index 8983e0fc1..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.json deleted file mode 100644 index 521450942..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api.Tests/appsettings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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.MinimalAPI/Warehouse/Core/Commands/ICommandHandler.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Commands/ICommandHandler.cs similarity index 64% rename from Sample/Warehouse.MinimalAPI/Warehouse/Core/Commands/ICommandHandler.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Commands/ICommandHandler.cs index ed303e9af..7577f39bd 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Commands/ICommandHandler.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Commands/ICommandHandler.cs @@ -1,24 +1,24 @@ using System; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -namespace Warehouse.Core.Commands; +namespace Warehouse.Api.Core.Commands; public interface ICommandHandler { ValueTask Handle(T command, CancellationToken token); } +public delegate ValueTask CommandHandler(T query, CancellationToken ct); + public static class CommandHandlerConfiguration { public static IServiceCollection AddCommandHandler( this IServiceCollection services, Func? configure = null - ) where TCommandHandler: class, ICommandHandler + ) where TCommandHandler : class, ICommandHandler { - if (configure == null) { services.AddTransient(); @@ -30,14 +30,14 @@ public static IServiceCollection AddCommandHandler( services.AddTransient, TCommandHandler>(configure); } + services + .AddTransient>( + sp => sp.GetRequiredService>().Handle + ) + .AddTransient>( + sp => sp.GetRequiredService>().Handle + ); + 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); -} \ No newline at end of file +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Entities/EntitiesExtensions.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Entities/EntitiesExtensions.cs similarity index 62% rename from Sample/Warehouse.MinimalAPI/Warehouse/Core/Entities/EntitiesExtensions.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Entities/EntitiesExtensions.cs index af097dc27..e09d838ff 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Entities/EntitiesExtensions.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Entities/EntitiesExtensions.cs @@ -1,8 +1,9 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; -namespace Warehouse.Core.Entities; +namespace Warehouse.Api.Core.Entities; public static class EntitiesExtensions { @@ -16,4 +17,9 @@ public static async ValueTask AddAndSave(this DbContext dbContext, T entity, public static ValueTask Find(this DbContext dbContext, TId id, CancellationToken ct) where T : class where TId : notnull => dbContext.FindAsync(new object[] {id}, ct); -} \ No newline at end of file + + public static IServiceCollection AddQueryable(this IServiceCollection services) + where TDbContext : DbContext + where T : class => + services.AddTransient(sp => sp.GetRequiredService().Set().AsNoTracking()); +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Primitives/MappingExtensions.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Primitives/MappingExtensions.cs similarity index 95% rename from Sample/Warehouse.MinimalAPI/Warehouse/Core/Primitives/MappingExtensions.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Primitives/MappingExtensions.cs index 219d5d69a..274e5ba5f 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Primitives/MappingExtensions.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Primitives/MappingExtensions.cs @@ -1,6 +1,6 @@ using System; -namespace Warehouse.Core.Primitives; +namespace Warehouse.Api.Core.Primitives; internal static class MappingExtensions { diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Queries/IQueryHandler.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Queries/IQueryHandler.cs new file mode 100644 index 000000000..58bd71034 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Queries/IQueryHandler.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Warehouse.Api.Core.Queries; + +public interface IQueryHandler +{ + ValueTask Handle(T query, CancellationToken ct); +} + +public delegate ValueTask QueryHandler(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() + .AddTransient, TQueryHandler>(); + } + else + { + services + .AddTransient(configure) + .AddTransient, TQueryHandler>(configure); + } + + services + .AddTransient>>( + sp => sp.GetRequiredService>().Handle + ) + .AddTransient>( + sp => sp.GetRequiredService>().Handle + ); + + return services; + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.Designer.cs similarity index 76% rename from Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.Designer.cs index bf8e29d14..129848246 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.Designer.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.Designer.cs @@ -5,23 +5,26 @@ using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Warehouse.Storage; +using Warehouse.Api.Storage; -namespace Warehouse.Migrations +#nullable disable + +namespace Warehouse.Api.Migrations { [DbContext(typeof(WarehouseDBContext))] - [Migration("20210512081922_InitialSetup")] - partial class InitialSetup + [Migration("20211214161109_Initial")] + partial class Initial { 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); + .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Warehouse.Products.Product", b => + modelBuilder.Entity("Warehouse.Api.Products.Product", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -39,9 +42,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Product"); }); - modelBuilder.Entity("Warehouse.Products.Product", b => + modelBuilder.Entity("Warehouse.Api.Products.Product", b => { - b.OwnsOne("Warehouse.Products.Primitives.SKU", "Sku", b1 => + b.OwnsOne("Warehouse.Api.Products.Primitives.SKU", "Sku", b1 => { b1.Property("ProductId") .HasColumnType("uuid"); diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.cs similarity index 90% rename from Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.cs index e921a219d..3412c314d 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/20210512081922_InitialSetup.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.cs @@ -1,9 +1,11 @@ using System; using Microsoft.EntityFrameworkCore.Migrations; -namespace Warehouse.Migrations +#nullable disable + +namespace Warehouse.Api.Migrations { - public partial class InitialSetup : Migration + public partial class Initial : Migration { protected override void Up(MigrationBuilder migrationBuilder) { diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/WarehouseDBContextModelSnapshot.cs similarity index 79% rename from Sample/Warehouse.MinimalAPI/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/WarehouseDBContextModelSnapshot.cs index a53632ce1..206b91584 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/WarehouseDBContextModelSnapshot.cs @@ -4,9 +4,11 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Warehouse.Storage; +using Warehouse.Api.Storage; -namespace Warehouse.Migrations +#nullable disable + +namespace Warehouse.Api.Migrations { [DbContext(typeof(WarehouseDBContext))] partial class WarehouseDBContextModelSnapshot : ModelSnapshot @@ -15,11 +17,12 @@ 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); + .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Warehouse.Products.Product", b => + modelBuilder.Entity("Warehouse.Api.Products.Product", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -37,9 +40,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Product"); }); - modelBuilder.Entity("Warehouse.Products.Product", b => + modelBuilder.Entity("Warehouse.Api.Products.Product", b => { - b.OwnsOne("Warehouse.Products.Primitives.SKU", "Sku", b1 => + b.OwnsOne("Warehouse.Api.Products.Primitives.SKU", "Sku", b1 => { b1.Property("ProductId") .HasColumnType("uuid"); diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Configuration.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Configuration.cs new file mode 100644 index 000000000..2b91d1eb5 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Configuration.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Warehouse.Api.Core.Commands; +using Warehouse.Api.Core.Entities; +using Warehouse.Api.Core.Queries; +using WarehouseDBContext = Warehouse.Api.Storage.WarehouseDBContext; + +namespace Warehouse.Api.Products; + +internal static class Configuration +{ + public static IServiceCollection AddProductServices(this IServiceCollection services) + => services + .AddQueryable() + .AddCommandHandler(s => + { + var dbContext = s.GetRequiredService(); + return new HandleRegisterProduct(dbContext.AddAndSave, dbContext.ProductWithSKUExists); + }) + .AddQueryHandler, HandleGetProducts>() + .AddQueryHandler(); + + public static void SetupProductsModel(this ModelBuilder modelBuilder) + => modelBuilder.Entity() + .OwnsOne(p => p.Sku); + + 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.MinimalAPI/Warehouse/Products/GettingProductDetails/GetProductDetails.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProductDetails.cs similarity index 69% rename from Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/GetProductDetails.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProductDetails.cs index 0a40197cf..bcaa90023 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/GetProductDetails.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProductDetails.cs @@ -3,10 +3,10 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; -using Warehouse.Core.Primitives; -using Warehouse.Core.Queries; +using Warehouse.Api.Core.Primitives; +using Warehouse.Api.Core.Queries; -namespace Warehouse.Products.GettingProductDetails; +namespace Warehouse.Api.Products; internal class HandleGetProductDetails: IQueryHandler { @@ -19,7 +19,6 @@ public HandleGetProductDetails(IQueryable products) public async ValueTask Handle(GetProductDetails query, CancellationToken ct) { - // await is needed because of https://github.com/dotnet/efcore/issues/21793#issuecomment-667096367 var product = await products .SingleOrDefaultAsync(p => p.Id == query.ProductId, ct); @@ -35,16 +34,11 @@ public HandleGetProductDetails(IQueryable products) } } -public record GetProductDetails +public record GetProductDetails( + Guid ProductId +) { - public Guid ProductId { get;} - - private GetProductDetails(Guid productId) - { - ProductId = productId; - } - - public static GetProductDetails Create(Guid productId) + public static GetProductDetails With(Guid? productId) => new(productId.AssertNotEmpty(nameof(productId))); } @@ -53,4 +47,4 @@ public record ProductDetails( string Sku, string Name, string? Description -); \ No newline at end of file +); diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/GetProducts.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProducts.cs similarity index 69% rename from Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/GetProducts.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProducts.cs index a479f18d6..9309f5464 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/GetProducts.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProducts.cs @@ -4,9 +4,9 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; -using Warehouse.Core.Queries; +using Warehouse.Api.Core.Queries; -namespace Warehouse.Products.GettingProducts; +namespace Warehouse.Api.Products; internal class HandleGetProducts : IQueryHandler> { @@ -30,7 +30,6 @@ public async ValueTask> Handle(GetProducts query, 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) @@ -39,25 +38,16 @@ public async ValueTask> Handle(GetProducts query, } } -public record GetProducts +public record GetProducts( + string? Filter, + int Page, + int PageSize +) { 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) + public static GetProducts With(string? filter, int? page, int? pageSize) { page ??= DefaultPage; pageSize ??= DefaultPageSize; @@ -70,17 +60,10 @@ public static GetProducts Create(string? filter, int? page, int? 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 -); \ No newline at end of file +); diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/Product.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Product.cs similarity index 56% rename from Sample/Warehouse.MinimalAPI/Warehouse/Products/Product.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Product.cs index 28956158d..145434fc0 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/Product.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Product.cs @@ -1,7 +1,8 @@ using System; -using Warehouse.Products.Primitives; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; -namespace Warehouse.Products; +namespace Warehouse.Api.Products; internal class Product { @@ -23,9 +24,6 @@ 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) @@ -35,4 +33,25 @@ public Product(Guid id, SKU sku, string name, string? description) Name = name; Description = description; } -} \ No newline at end of file +} + +public record SKU +{ + public string Value { get; init; } + + [JsonConstructor] + public 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.MinimalAPI/Warehouse/Products/RegisteringProduct/RegisterProduct.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/RegisterProduct.cs similarity index 72% rename from Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/RegisterProduct.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/RegisterProduct.cs index f319e12af..6dab2c79a 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/RegisterProduct.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/RegisterProduct.cs @@ -1,10 +1,9 @@ using System; using System.Threading; using System.Threading.Tasks; -using Warehouse.Core.Commands; -using Warehouse.Products.Primitives; +using Warehouse.Api.Core.Commands; -namespace Warehouse.Products.RegisteringProduct; +namespace Warehouse.Api.Products; internal class HandleRegisterProduct : ICommandHandler { @@ -37,25 +36,14 @@ public async ValueTask Handle(RegisterProduct command, CancellationToken ct) } } -public record RegisterProduct +public record RegisterProduct( + Guid ProductId, + SKU SKU, + string Name, + string? Description +) { - 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) + public static RegisterProduct With(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)); @@ -64,4 +52,4 @@ public static RegisterProduct Create(Guid? id, string? sku, string? name, string return new RegisterProduct(id.Value, SKU.Create(sku), name, description); } -} \ No newline at end of file +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs index 037b4deda..723d8d65a 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs @@ -1,101 +1,91 @@ +using System.Net; using Core.WebApi.Middlewares.ExceptionHandling; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Warehouse; - +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Warehouse.Api.Core.Commands; +using Warehouse.Api.Core.Queries; +using Warehouse.Api.Products; +using Warehouse.Api.Storage; var builder = WebApplication.CreateBuilder(args); -builder.Services -var app = builder.Build(); - -app.MapGet("/", () => "Hello World!"); - -app.MapGet("/todoitems", async (TodoDb db) => - await db.Todos.ToListAsync()); -app.MapGet("/todoitems/complete", async (TodoDb db) => - await db.Todos.Where(t => t.IsComplete).ToListAsync()); - -app.MapGet("/todoitems/{id}", async (int id, TodoDb db) => - await db.Todos.FindAsync(id) - is Todo todo - ? Results.Ok(todo) - : Results.NotFound()); +builder.Services + .AddEndpointsApiExplorer() + .AddSwaggerGen() + .AddDbContext( + options => options.UseNpgsql("name=ConnectionStrings:WarehouseDB")) + .AddProductServices(); -app.MapPost("/todoitems", async (Todo todo, TodoDb db) => -{ - db.Todos.Add(todo); - await db.SaveChangesAsync(); +var app = builder.Build(); - return Results.Created($"/todoitems/{todo.Id}", todo); -}); +app.UseExceptionHandlingMiddleware(); -app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) => +if (app.Environment.IsDevelopment()) { - var todo = await db.Todos.FindAsync(id); - - if (todo is null) return Results.NotFound(); + app.UseSwagger() + .UseSwaggerUI(); - todo.Name = inputTodo.Name; - todo.IsComplete = inputTodo.IsComplete; - - await db.SaveChangesAsync(); - - return Results.NoContent(); -}); + using var scope = app.Services.CreateScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); +} -app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) => +// Get Products +app.MapGet("/api/products", HandleGetProducts) + .Produces((int)HttpStatusCode.BadRequest); + +ValueTask> HandleGetProducts( + [FromServices] QueryHandler> getProducts, + string? filter, + int? page, + int? pageSize, + CancellationToken ct +) => + getProducts(GetProducts.With(filter, page, pageSize), ct); + + +// Get Product Details by Id +app.MapGet("/api/products/{id}", HandleGetProductDetails) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + +async Task HandleGetProductDetails( + [FromServices] QueryHandler getProductById, + Guid productId, + CancellationToken ct +) => + await getProductById(GetProductDetails.With(productId), ct) + is { } product + ? Results.Ok(product) + : Results.NotFound(); + +// Register new product +app.MapPost("api/products/",HandleRegisterProduct) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + +async Task HandleRegisterProduct( + [FromServices] CommandHandler registerProduct, + RegisterProductRequest request, + CancellationToken ct +) { - if (await db.Todos.FindAsync(id) is Todo todo) - { - db.Todos.Remove(todo); - await db.SaveChangesAsync(); - return Results.Ok(todo); - } + var productId = Guid.NewGuid(); + var (sku, name, description) = request; - return Results.NotFound(); -}); - -app.Run(); + await registerProduct( + RegisterProduct.With(productId, sku, name, description), + ct); -class Todo -{ - public int Id { get; set; } - public string? Name { get; set; } - public bool IsComplete { get; set; } + return Results.Created($"/api/products/{productId}", productId); } -class TodoDb : DbContext -{ - public TodoDb(DbContextOptions options) - : base(options) { } - - public DbSet Todos => Set(); -} +app.Run(); +public record RegisterProductRequest( + string? SKU, + string? Name, + string? Description +); -// var builder = Host.CreateDefaultBuilder(args) -// .ConfigureWebHostDefaults(webBuilder => -// { -// webBuilder -// .ConfigureServices(services => -// { -// services.AddRouting() -// .AddWarehouseServices(); -// }) -// .Configure(app => -// { -// app.UseExceptionHandlingMiddleware() -// .UseRouting() -// .UseEndpoints(endpoints => -// { -// endpoints.UseWarehouseEndpoints(); -// }) -// .ConfigureWarehouse(); -// }); -// }) -// .Build(); -// builder.Run(); +public partial class Program { } diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json index e5c92db59..96d95946b 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json @@ -1,27 +1,11 @@ { "$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", + "launchUrl": "swagger/index.html", "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Storage/WarehouseDBContext.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Storage/WarehouseDBContext.cs similarity index 96% rename from Sample/Warehouse.MinimalAPI/Warehouse/Storage/WarehouseDBContext.cs rename to Sample/Warehouse.MinimalAPI/Warehouse.Api/Storage/WarehouseDBContext.cs index f768a5ed6..b463eac08 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Storage/WarehouseDBContext.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Storage/WarehouseDBContext.cs @@ -2,9 +2,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; -using Warehouse.Products; +using Warehouse.Api.Products; -namespace Warehouse.Storage; +namespace Warehouse.Api.Storage; public class WarehouseDBContext: DbContext { @@ -48,4 +48,4 @@ public WarehouseDBContext CreateDbContext(params string[] args) public static WarehouseDBContext Create() => new WarehouseDBContextFactory().CreateDbContext(); -} \ No newline at end of file +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj index cdab03793..96dca3f02 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj @@ -7,18 +7,19 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + - diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.sln b/Sample/Warehouse.MinimalAPI/Warehouse.sln index 66cf6431b..8c4b3b3d9 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse.sln +++ b/Sample/Warehouse.MinimalAPI/Warehouse.sln @@ -2,16 +2,22 @@ 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}") = "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 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Api.Testing", "..\..\Core.Api.Testing\Core.Api.Testing.csproj", "{49B8A573-5F71-4087-B30D-D72459ABA0E2}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{A50C04D5-DDAD-4415-8C42-BAA28EEC360C}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CI", "CI", "{01E4F2B8-04B8-4C44-9089-33155E297779}" + ProjectSection(SolutionItems) = preProject + docker-compose.yml = docker-compose.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -22,18 +28,10 @@ Global {49B8A573-5F71-4087-B30D-D72459ABA0E2} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {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 diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Configuration.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Configuration.cs deleted file mode 100644 index 5a58c59a5..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Configuration.cs +++ /dev/null @@ -1,33 +0,0 @@ -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") ?? "Development"; - - if (environment == "Development") - { - app.ApplicationServices.GetRequiredService().Database.Migrate(); - } - - return app; - } -} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Extensions/HttpExtensions.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Extensions/HttpExtensions.cs deleted file mode 100644 index e53e776f1..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Extensions/HttpExtensions.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.ComponentModel; -using System.Net; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -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); - } -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Queries/IQueryHandler.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Core/Queries/IQueryHandler.cs deleted file mode 100644 index 66175bedb..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Core/Queries/IQueryHandler.cs +++ /dev/null @@ -1,42 +0,0 @@ -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); -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/Configuration.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/Configuration.cs deleted file mode 100644 index addb3ad09..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/Configuration.cs +++ /dev/null @@ -1,45 +0,0 @@ -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); -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/Route.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/Route.cs deleted file mode 100644 index 878d7d92d..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProductDetails/Route.cs +++ /dev/null @@ -1,34 +0,0 @@ -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; - } -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/Route.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/Route.cs deleted file mode 100644 index 94f9a549b..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/GettingProducts/Route.cs +++ /dev/null @@ -1,31 +0,0 @@ -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; - } -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/Primitives/SKU.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/Primitives/SKU.cs deleted file mode 100644 index 3bb975ac5..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/Primitives/SKU.cs +++ /dev/null @@ -1,26 +0,0 @@ -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); - } -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/ProductsRepository.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/ProductsRepository.cs deleted file mode 100644 index 3683333de..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/ProductsRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -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)); -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/Route.cs b/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/Route.cs deleted file mode 100644 index 41ee1a3c9..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Products/RegisteringProduct/Route.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using Warehouse.Core.Commands; -using Warehouse.Core.Extensions; - -namespace Warehouse.Products.RegisteringProduct; - -public record RegisterProductRequest( - string? SKU, - string? Name, - string? Description -); - -internal static class Route -{ - internal static IEndpointRouteBuilder UseRegisterProductEndpoint(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost("api/products/", async context => - { - var (sku, name, description) = await context.FromBody(); - var productId = Guid.NewGuid(); - - var command = RegisterProduct.Create(productId, sku, name, description); - - await context.SendCommand(command); - - await context.Created(productId); - }); - - return endpoints; - } -} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse/Warehouse.csproj b/Sample/Warehouse.MinimalAPI/Warehouse/Warehouse.csproj deleted file mode 100644 index 287aecdc8..000000000 --- a/Sample/Warehouse.MinimalAPI/Warehouse/Warehouse.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - net6.0 - enable - true - - - - - - - - - <_Parameter1>$(MSBuildProjectName).Tests - - - <_Parameter1>$(MSBuildProjectName).Api.Tests - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - diff --git a/Sample/Warehouse.MinimalAPI/docker-compose.yml b/Sample/Warehouse.MinimalAPI/docker-compose.yml new file mode 100644 index 000000000..bd7611411 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3" +services: + ####################################################### + # Postgres + ####################################################### + postgres: + image: clkao/postgres-plv8 + container_name: postgres + environment: + POSTGRES_PASSWORD: Password12! + ports: + - "5432:5432" + networks: + - pg_network + + pgadmin: + image: dpage/pgadmin4 + container_name: pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@pgadmin.org} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + ports: + - "${PGADMIN_PORT:-5050}:80" + networks: + - pg_network + +networks: + pg_network: + driver: bridge + +volumes: + postgres: + pgadmin: From af26f925a37c798c5f013d7c95fe40556afb51f9 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Sat, 14 May 2022 11:07:21 +0200 Subject: [PATCH 4/4] Updated packages --- EventSourcing.NetCore.sln | 7 -- README.md | 33 +++++--- .../EventPipelines.EventStoreDB.csproj | 27 ------- .../SubscriptionsTests.cs | 6 -- Sample/EventPipelines/README.md | 1 + Sample/Warehouse.MinimalAPI/README.md | 8 +- .../Warehouse.Api/Program.cs | 80 ++++++++++--------- .../Warehouse.Api/Warehouse.Api.csproj | 12 +-- 8 files changed, 75 insertions(+), 99 deletions(-) delete mode 100644 Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj delete mode 100644 Sample/EventPipelines/EventPipelines.EventStoreDB/SubscriptionsTests.cs diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index 7cc5d996e..05f73d9e9 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -323,8 +323,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "13-Projections.SingleStream EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "14-Projections.SingleStream.EventualConsistency", "Workshops\IntroductionToEventSourcing\Solved\14-Projections.SingleStream.EventualConsistency\14-Projections.SingleStream.EventualConsistency.csproj", "{A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventPipelines.EventStoreDB", "Sample\EventPipelines\EventPipelines.EventStoreDB\EventPipelines.EventStoreDB.csproj", "{9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Warehouse.MinimalAPI", "Warehouse.MinimalAPI", "{005FC7CE-97BA-47F7-982E-63C46327F78C}" ProjectSection(SolutionItems) = preProject Sample\Warehouse.MinimalAPI\docker-compose.yml = Sample\Warehouse.MinimalAPI\docker-compose.yml @@ -747,10 +745,6 @@ Global {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}.Debug|Any CPU.Build.0 = Debug|Any CPU {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}.Release|Any CPU.ActiveCfg = Release|Any CPU {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}.Release|Any CPU.Build.0 = Release|Any CPU - {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0}.Release|Any CPU.Build.0 = Release|Any CPU {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Debug|Any CPU.Build.0 = Debug|Any CPU {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -885,7 +879,6 @@ Global {2CA4B0B3-AF8B-4BD8-A18D-18EE1F93DE04} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} {7A990FD1-4935-4F85-912C-D4B870232856} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} - {9B7FBEE7-99AE-4A94-8E36-FBE1A62BEEE0} = {D9799DB3-9D11-4909-8133-64CD96F6E1AC} {005FC7CE-97BA-47F7-982E-63C46327F78C} = {A7186B6B-D56D-4AEF-B6B7-FAA827764C34} {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E} = {005FC7CE-97BA-47F7-982E-63C46327F78C} EndGlobalSection diff --git a/README.md b/README.md index f8ac1ee35..fba7492ad 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,12 @@ Tutorial, practical samples and other resources about Event Sourcing in .NET. Se - [6.2 Simple EventSourcing with EventStoreDB](#62-simple-eventsourcing-with-eventstoredb) - [6.3 ECommerce with EventStoreDB](#63-ecommerce-with-eventstoredb) - [6.5 Warehouse](#65-warehouse) - - [6.6 Event Versioning](#66-event-versioning) - - [6.7 Event Pipelines](#67-event-pipelines) - - [6.8 Meetings Management with Marten](#68-meetings-management-with-marten) - - [6.9 Cinema Tickets Reservations with Marten](#69-cinema-tickets-reservations-with-marten) - - [6.10 SmartHome IoT with Marten](#610-smarthome-iot-with-marten) + - [6.6 Warehouse Minimal API](#66-warehouse-minimal-api) + - [6.7 Event Versioning](#67-event-versioning) + - [6.8 Event Pipelines](#68-event-pipelines) + - [6.9 Meetings Management with Marten](#69-meetings-management-with-marten) + - [6.10 Cinema Tickets Reservations with Marten](#610-cinema-tickets-reservations-with-marten) + - [6.11 SmartHome IoT with Marten](#611-smarthome-iot-with-marten) - [7. Self-paced training Kits](#7-self-paced-training-kits) - [7.1 Introduction to Event Sourcing](#71-introduction-to-event-sourcing) - [7.2 Build your own Event Store](#72-build-your-own-event-store) @@ -652,7 +653,13 @@ Samples are using CQRS architecture. They're sliced based on the business module - No Event Sourcing! Using Entity Framework to show that CQRS is not bounded to Event Sourcing or any type of storage, - No Aggregates! CQRS do not need DDD. Business logic can be handled in handlers. -### 6.6 [Event Versioning](./Sample/EventsVersioning) +### 6.6 [Warehouse Minimal API](./Sample/Warehouse.MinimalAPI/) +Variation of the previous example, but: +- using Minimal API, +- example how to inject handlers in MediatR like style to decouple API from handlers. +- 📝 Read more [CQRS is simpler than you think with .NET 6 and C# 10](https://event-driven.io/en/cqrs_is_simpler_than_you_think_with_net6/?utm_source=event_sourcing_net) + +### 6.7 [Event Versioning](./Sample/EventsVersioning) Shows how to handle basic event schema versioning scenarios using event and stream transformations (e.g. upcasting): - [Simple mapping](./Sample/EventsVersioning/#simple-mapping) - [New not required property](./Sample/EventsVersioning/#new-not-required-property) @@ -665,8 +672,9 @@ Shows how to handle basic event schema versioning scenarios using event and stre - [Events Transformations](./Sample/EventsVersioning/#events-transformations) - [Stream Transformation](./Sample/EventsVersioning/#stream-transformation) - [Summary](./Sample/EventsVersioning/#summary) +- 📝 [Simple patterns for events schema versioning](https://event-driven.io/en/simple_events_versioning_patterns/?utm_source=event_sourcing_net) -### 6.7 [Event Pipelines](./Sample/EventPipelines) +### 6.8 [Event Pipelines](./Sample/EventPipelines) Shows how to compose event handlers in the processing pipelines to: - filter events, - transform them, @@ -676,8 +684,9 @@ Shows how to compose event handlers in the processing pipelines to: - allows using interfaces and classes if you want to, - can be used with Dependency Injection, but also without through builder, - integrates with MediatR if you want to. +- 📝 Read more [How to build a simple event pipeline](https://event-driven.io/en/how_to_build_simple_event_pipeline/?utm_source=event_sourcing_net) -### 6.8 [Meetings Management with Marten](./Sample/MeetingsManagement/) +### 6.9 [Meetings Management with Marten](./Sample/MeetingsManagement/) - typical Event Sourcing and CQRS flow, - DDD using Aggregates, - microservices example, @@ -685,12 +694,12 @@ Shows how to compose event handlers in the processing pipelines to: - Kafka as a messaging platform to integrate microservices, - read models handled in separate microservice and stored to other database (ElasticSearch) -### 6.9 [Cinema Tickets Reservations with Marten](./Sample/Tickets/) +### 6.10 [Cinema Tickets Reservations with Marten](./Sample/Tickets/) - typical Event Sourcing and CQRS flow, - DDD using Aggregates, - stores events to Marten. -### 6.10 [SmartHome IoT with Marten](./Sample/AsyncProjections/) +### 6.11 [SmartHome IoT with Marten](./Sample/AsyncProjections/) - typical Event Sourcing and CQRS flow, - DDD using Aggregates, - stores events to Marten, @@ -762,6 +771,7 @@ Read also more on the **Event Sourcing** and **CQRS** topics in my [blog](https: - 📝 [How to slice the codebase effectively?](https://event-driven.io/en/how_to_slice_the_codebase_effectively/?utm_source=event_sourcing_net) - 📝 [Generic does not mean Simple?](https://event-driven.io/en/generic_does_not_mean_simple/?utm_source=event_sourcing_net) - 📝 [Can command return a value?](https://event-driven.io/en/can_command_return_a_value/?utm_source=event_sourcing_net) +- 📝 [CQRS is simpler than you think with .NET 6 and C# 10](https://event-driven.io/en/cqrs_is_simpler_than_you_think_with_net6/?utm_source=event_sourcing_net) - 📝 [How to register all CQRS handlers by convention](https://event-driven.io/en/how_to_register_all_mediatr_handlers_by_convention/?utm_source=event_sourcing_net) - 📝 [How to use ETag header for optimistic concurrency](https://event-driven.io/en/how_to_use_etag_header_for_optimistic_concurrency/?utm_source=event_sourcing_net) - 📝 [Dealing with Eventual Consistency and Idempotency in MongoDB projections](https://event-driven.io/en/dealing_with_eventual_consistency_and_idempotency_in_mongodb_projections/?utm_source=event_sourcing_net) @@ -770,7 +780,8 @@ Read also more on the **Event Sourcing** and **CQRS** topics in my [blog](https: - 📝 [How to do snapshots in Marten?](https://event-driven.io/en/how_to_do_snapshots_in_Marten/?utm_source=event_sourcing_net) - 📝 [Integrating Marten with other systems](https://event-driven.io/en/integrating_Marten/?utm_source=event_sourcing_net) - 📝 [How to (not) do the events versioning?](https://event-driven.io/en/how_to_do_event_versioning/?utm_source=event_sourcing_net) -- 📝 [Simple patterns for events schema versioning](https://event-driven.io/en/simple_events_versioning_patterns/?utm_source=event_sourcing_net) +- 📝 [Simple patterns for events schema versioning](https://event-driven.io/en/simple_events_versioning_patterns/?utm_source=event_sourcing_net) +- 📝 [How to build a simple event pipeline](https://event-driven.io/en/how_to_build_simple_event_pipeline/?utm_source=event_sourcing_net) - 📝 [How to create projections of events for nested object structures?](https://event-driven.io/en/how_to_create_projections_of_events_for_nested_object_structures/?utm_source=event_sourcing_net) - 📝 [How to scale projections in the event-driven systems?](https://event-driven.io/en/how_to_scale_projections_in_the_event_driven_systems/?utm_source=event_sourcing_net) - 📝 [Immutable Value Objects are simpler and more useful than you think!](https://event-driven.io/en/immutable_value_objects/?utm_source=event_sourcing_net) diff --git a/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj b/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj deleted file mode 100644 index d0214f4a7..000000000 --- a/Sample/EventPipelines/EventPipelines.EventStoreDB/EventPipelines.EventStoreDB.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net6.0 - enable - true - trx%3bLogFileName=$(MSBuildProjectName).trx - $(MSBuildThisFileDirectory)/bin/TestResults/$(TargetFramework) - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - - diff --git a/Sample/EventPipelines/EventPipelines.EventStoreDB/SubscriptionsTests.cs b/Sample/EventPipelines/EventPipelines.EventStoreDB/SubscriptionsTests.cs deleted file mode 100644 index 525362bee..000000000 --- a/Sample/EventPipelines/EventPipelines.EventStoreDB/SubscriptionsTests.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace EventPipelines.EventStoreDB; - -public class SubscriptionsTests -{ - -} diff --git a/Sample/EventPipelines/README.md b/Sample/EventPipelines/README.md index c0117a569..28862ba6b 100644 --- a/Sample/EventPipelines/README.md +++ b/Sample/EventPipelines/README.md @@ -9,6 +9,7 @@ Shows how to compose event handlers in the processing pipelines to: - allows using interfaces and classes if you want to, - can be used with Dependency Injection, but also without through builder, - integrates with MediatR if you want to. +- 📝 Read more [How to build a simple event pipeline](https://event-driven.io/en/how_to_build_simple_event_pipeline/?utm_source=event_sourcing_net) ## Overview diff --git a/Sample/Warehouse.MinimalAPI/README.md b/Sample/Warehouse.MinimalAPI/README.md index 9f53614c0..17ac996d4 100644 --- a/Sample/Warehouse.MinimalAPI/README.md +++ b/Sample/Warehouse.MinimalAPI/README.md @@ -1,5 +1,7 @@ -# Warehouse -- simplest CQRS flow using .NET 5 Endpoints, +# Warehouse Minimal API +- CQRS flow using Minimal API, +- example how to inject handlers in MediatR like style to decouple API from handling, - example of how and where to use C# Records, Nullable Reference Types, etc, - No Event Sourcing! Using Entity Framework to show that CQRS is not bounded to Event Sourcing or any type of storage, -- No Aggregates! CQRS do not need DDD. Business logic can be handled in handlers. \ No newline at end of file +- No Aggregates! CQRS do not need DDD. Business logic can be handled in handlers. +- 📝 Read more [CQRS is simpler than you think with .NET 6 and C# 10](https://event-driven.io/en/cqrs_is_simpler_than_you_think_with_net6/?utm_source=event_sourcing_net) diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs index 723d8d65a..897646a7f 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs @@ -31,55 +31,59 @@ } // Get Products -app.MapGet("/api/products", HandleGetProducts) +app.MapGet( + "/api/products", + ( + [FromServices] QueryHandler> getProducts, + string? filter, + int? page, + int? pageSize, + CancellationToken ct + ) => + getProducts(GetProducts.With(filter, page, pageSize), ct) + ) .Produces((int)HttpStatusCode.BadRequest); -ValueTask> HandleGetProducts( - [FromServices] QueryHandler> getProducts, - string? filter, - int? page, - int? pageSize, - CancellationToken ct -) => - getProducts(GetProducts.With(filter, page, pageSize), ct); - // Get Product Details by Id -app.MapGet("/api/products/{id}", HandleGetProductDetails) +app.MapGet( + "/api/products/{id:guid}", + async ( + [FromServices] QueryHandler getProductById, + Guid id, + CancellationToken ct + ) => + await getProductById(GetProductDetails.With(id), ct) + is { } product + ? Results.Ok(product) + : Results.NotFound() + ) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); -async Task HandleGetProductDetails( - [FromServices] QueryHandler getProductById, - Guid productId, - CancellationToken ct -) => - await getProductById(GetProductDetails.With(productId), ct) - is { } product - ? Results.Ok(product) - : Results.NotFound(); // Register new product -app.MapPost("api/products/",HandleRegisterProduct) +app.MapPost( + "api/products/", + async ( + [FromServices] CommandHandler registerProduct, + RegisterProductRequest request, + CancellationToken ct + ) => + { + var productId = Guid.NewGuid(); + var (sku, name, description) = request; + + await registerProduct( + RegisterProduct.With(productId, sku, name, description), + ct); + + return Results.Created($"/api/products/{productId}", productId); + } + ) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); -async Task HandleRegisterProduct( - [FromServices] CommandHandler registerProduct, - RegisterProductRequest request, - CancellationToken ct -) -{ - var productId = Guid.NewGuid(); - var (sku, name, description) = request; - - await registerProduct( - RegisterProduct.With(productId, sku, name, description), - ct); - - return Results.Created($"/api/products/{productId}", productId); -} - app.Run(); public record RegisterProductRequest( @@ -87,5 +91,3 @@ public record RegisterProductRequest( string? Name, string? Description ); - -public partial class Program { } diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj index 96dca3f02..5767e7cf9 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj @@ -7,15 +7,15 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + +