diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index 901dd3b46..50319a8a8 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -325,6 +325,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "14-Projections.EventualCons 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 {FB16C206-942C-44F9-8E97-1EFA32A345BC} = {14C7B928-9D6C-441A-8A1F-0C49173E73EB} {4F1EA25C-44ED-40E7-8BFB-8B6C083B2A8C} = {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/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..2ac8420c8 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs @@ -1,101 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; using Core.WebApi.Middlewares.ExceptionHandling; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Warehouse; - -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()); - -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; +builder.Services + .AddEndpointsApiExplorer() + .AddSwaggerGen() + .AddDbContext( + options => options.UseNpgsql("name=ConnectionStrings:WarehouseDB")) + .AddProductServices(); - await db.SaveChangesAsync(); +var app = builder.Build(); - return Results.NoContent(); -}); +app.UseExceptionHandlingMiddleware(); -app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) => +if (app.Environment.IsDevelopment()) { - if (await db.Todos.FindAsync(id) is Todo todo) - { - db.Todos.Remove(todo); - await db.SaveChangesAsync(); - return Results.Ok(todo); - } - - return Results.NotFound(); -}); + app.UseSwagger() + .UseSwaggerUI(); -app.Run(); - -class Todo -{ - public int Id { get; set; } - public string? Name { get; set; } - public bool IsComplete { get; set; } + using var scope = app.Services.CreateScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); } -class TodoDb : DbContext +// 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 +) { - public TodoDb(DbContextOptions options) - : base(options) { } + var productId = Guid.NewGuid(); + var (sku, name, description) = request; - public DbSet Todos => Set(); + await registerProduct( + RegisterProduct.With(productId, sku, name, description), + ct); + + return Results.Created($"/api/products/{productId}", productId); } +app.Run(); -// 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 record RegisterProductRequest( + string? SKU, + string? Name, + string? Description +); 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..3648b2ab8 100644 --- a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj @@ -14,11 +14,12 @@ 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: