diff --git a/C18/REPR/.gitignore b/C18/REPR/.gitignore new file mode 100644 index 0000000..d5f2961 --- /dev/null +++ b/C18/REPR/.gitignore @@ -0,0 +1 @@ +coveragereport \ No newline at end of file diff --git a/C18/REPR/README.md b/C18/REPR/README.md new file mode 100644 index 0000000..cee099e --- /dev/null +++ b/C18/REPR/README.md @@ -0,0 +1,22 @@ +# Test coverage + +The initial test coverage report indicates 97.2% Line Coverage and 63.1% Branch coverage for the Web assembly. +Most of the not tested branches are related to the constructor injection guards that we have no unit tests for. + +## How to collect code coverage + +```bash +# 1. Generage the coverage.cobertura.xml file +dotnet test --collect:"XPlat Code Coverage" + +# 2. Generate the repport based on the previous file (change the GUID by the GUID generated by the collector) +reportgenerator -reports:"Web.Tests\TestResults\b74d6e70-a4f3-49ff-bf3c-00e4abba742c\coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html +``` + +## Prerequisites + +Run once to install the `reportgenerator` global tool: + +```bash +dotnet tool install -g dotnet-reportgenerator-globaltool +``` diff --git a/C18/REPR/REPR.sln b/C18/REPR/REPR.sln new file mode 100644 index 0000000..c7a7593 --- /dev/null +++ b/C18/REPR/REPR.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Tests", "Web.Tests\Web.Tests.csproj", "{50D34992-16B0-4421-948F-9C7D6A0EEEB3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "Web\Web.csproj", "{0653C456-D190-43D1-98BC-74000960AE85}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {50D34992-16B0-4421-948F-9C7D6A0EEEB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50D34992-16B0-4421-948F-9C7D6A0EEEB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50D34992-16B0-4421-948F-9C7D6A0EEEB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50D34992-16B0-4421-948F-9C7D6A0EEEB3}.Release|Any CPU.Build.0 = Release|Any CPU + {0653C456-D190-43D1-98BC-74000960AE85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0653C456-D190-43D1-98BC-74000960AE85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0653C456-D190-43D1-98BC-74000960AE85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0653C456-D190-43D1-98BC-74000960AE85}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs new file mode 100644 index 0000000..cae11b7 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.AddItemTest.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Json; +using static Web.Features.Baskets; + +namespace Web.Features; +public partial class BasketsTest +{ + public class AddItemTest + { + [Fact] + public async Task Should_add_the_new_item_to_the_basket() + { + // Arrange + await using var application = new C18WebApplication(); + var client = application.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/baskets", + new AddItem.Command(4, 1, 22) + ); + + // Assert the response + Assert.NotNull(response); + Assert.True(response.IsSuccessStatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(1, result.ProductId); + Assert.Equal(22, result.Quantity); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 4 && x.ProductId == 1); + Assert.NotNull(dbItem); + Assert.Equal(22, dbItem.Quantity); + } + + [Fact] + public async Task Should_return_a_valid_product_url() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(async db => + { + db.Products.RemoveRange(db.Products); + db.Products.Add(new("A test product", 15.22m, 1)); + await db.SaveChangesAsync(); + }); + var client = application.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/baskets", + new AddItem.Command(4, 1, 22) + ); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(response.Headers.Location); + + var productResponse = await client.GetAsync(response.Headers.Location); + Assert.NotNull(productResponse); + Assert.True(productResponse.IsSuccessStatusCode); + } + + [Fact] + public async Task Should_return_a_ProblemDetails_with_a_Conflict_status_code() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(async db => + { + db.Items.RemoveRange(db.Items); + db.Items.Add(new( + CustomerId: 1, + ProductId: 1, + Quantity: 10 + )); + await db.SaveChangesAsync(); + }); + var client = application.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/baskets", + new AddItem.Command( + CustomerId: 1, + ProductId: 1, + Quantity: 20 + ) + ); + + // Assert the response + Assert.NotNull(response); + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + var problem = await response.Content + .ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal("The product \u00271\u0027 is already in your shopping cart.", problem.Title); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider + .GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 1 && x.ProductId == 1); + Assert.NotNull(dbItem); + Assert.Equal(10, dbItem.Quantity); + } + } +} diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs new file mode 100644 index 0000000..6a072ac --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.FetchItemsTest.cs @@ -0,0 +1,62 @@ +using System.Net.Http.Json; +using static Web.Features.Baskets; + +namespace Web.Features; +public partial class BasketsTest +{ + public class FetchItemsTest + { + [Fact] + public async Task Should_return_the_specified_customer_items() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(async (db) => + { + db.Items.RemoveRange(db.Items.ToArray()); + db.Items.Add(new BasketItem(2, 1, 5)); + db.Items.Add(new BasketItem(2, 3, 15)); + await db.SaveChangesAsync(); + }); + var client = application.CreateClient(); + + // Act + var response = await client + .GetFromJsonAsync>("/baskets/2"); + + // Assert + Assert.NotNull(response); + Assert.Collection(response, + i => { + Assert.Equal(1, i.ProductId); + Assert.Equal(5, i.Quantity); + }, + i => { + Assert.Equal(3, i.ProductId); + Assert.Equal(15, i.Quantity); + } + ); + } + + [Fact] + public async Task Should_return_an_empty_list_when_the_customer_have_no_item_in_its_cart() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(async (db) => + { + db.Items.RemoveRange(db.Items.ToArray()); + await db.SaveChangesAsync(); + }); + var client = application.CreateClient(); + + // Act + var response = await client + .GetFromJsonAsync>("/baskets/5"); + + // Assert + Assert.NotNull(response); + Assert.Empty(response); + } + } +} diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs new file mode 100644 index 0000000..96a921c --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.RemoveItemTest.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Json; +using static Web.Features.Baskets; + +namespace Web.Features; +public partial class BasketsTest +{ + public class RemoveItemTest + { + [Fact] + public async Task Should_remove_the_specified_item_from_the_customer_cart() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(async db => + { + db.Items.RemoveRange(db.Items.ToArray()); + db.Items.Add(new BasketItem(1, 3, 30)); + db.Items.Add(new BasketItem(2, 1, 5)); + db.Items.Add(new BasketItem(2, 3, 15)); + db.Items.Add(new BasketItem(3, 2, 18)); + await db.SaveChangesAsync(); + }); + var client = application.CreateClient(); + + // Act + var response = await client.DeleteAsync("/baskets/2/1"); + + // Assert the response + Assert.NotNull(response); + Assert.True(response.IsSuccessStatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(5, result.Quantity); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 2 && x.ProductId == 1); + Assert.Null(dbItem); + var remainingItems = db.Items.Count(); + Assert.Equal(3, remainingItems); + } + + [Fact] + public async Task Should_return_a_ProblemDetails_with_a_NotFound_status_code() + { + // Arrange + await using var application = new C18WebApplication(); + var client = application.CreateClient(); + + // Act + var response = await client.DeleteAsync("/baskets/99/99"); + + // Assert the response + Assert.NotNull(response); + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal("The product \u002799\u0027 is not in your shopping cart.", problem.Title); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 99); + Assert.Null(dbItem); + } + } +} diff --git a/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs new file mode 100644 index 0000000..9ce3699 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Baskets/BasketsTest.UpdateQuantityTest.cs @@ -0,0 +1,125 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Json; +using static Web.Features.Baskets; + +namespace Web.Features; +public partial class BasketsTest +{ + public class UpdateQuantityTest + { + [Fact] + public async Task Should_update_the_item_quantity() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(async db => + { + db.Items.RemoveRange(db.Items.ToArray()); + db.Items.Add(new BasketItem(1, 3, 30)); + db.Items.Add(new BasketItem(2, 1, 5)); + db.Items.Add(new BasketItem(2, 3, 15)); + await db.SaveChangesAsync(); + }); + var client = application.CreateClient(); + + // Act + var response = await client.PutAsJsonAsync( + "/baskets", + new UpdateQuantity.Command(2, 1, 25) + ); + + // Assert the response + Assert.NotNull(response); + Assert.True(response.IsSuccessStatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(25, result.Quantity); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + AssertProductQuantity(1, 3, 30); + AssertProductQuantity(2, 1, 25); + AssertProductQuantity(2, 3, 15); + + void AssertProductQuantity(int customerId, int productId, int expectedQuantity) + { + var dbItem = db.Items.FirstOrDefault( + x => x.CustomerId == customerId && + x.ProductId == productId + ); + Assert.NotNull(dbItem); + Assert.Equal(expectedQuantity, dbItem.Quantity); + } + } + + [Fact] + public async Task Should_return_a_ProblemDetails_with_a_NotFound_status_code() + { + // Arrange + await using var application = new C18WebApplication(); + var client = application.CreateClient(); + + // Act + var response = await client.PutAsJsonAsync( + "/baskets", + new UpdateQuantity.Command(99, 99, 25) + ); + + // Assert the response + Assert.NotNull(response); + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal("The product \u002799\u0027 is not in your shopping cart.", problem.Title); + + // Assert the database state + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 99); + Assert.Null(dbItem); + } + + [Fact] + public async Task Should_not_touch_the_database_when_the_quantity_is_the_same() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(async db => + { + db.Items.RemoveRange(db.Items.ToArray()); + db.Items.Add(new BasketItem(2, 1, 5)); + await db.SaveChangesAsync(); + }); + + using var seedScope = application.Services.CreateScope(); + var db = seedScope.ServiceProvider + .GetRequiredService(); + var mapper = seedScope.ServiceProvider + .GetRequiredService(); + db.SavedChanges += Db_SavedChanges; + var saved = false; + + var sut = new UpdateQuantity.Handler(db, mapper); + + // Act + var response = await sut.HandleAsync( + new UpdateQuantity.Command(2, 1, 5), + CancellationToken.None + ); + + // Assert + Assert.NotNull(response); + Assert.False(saved); + + void Db_SavedChanges(object? sender, SavedChangesEventArgs e) + { + saved = true; + } + } + } +} diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs new file mode 100644 index 0000000..a711704 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchAllTest.cs @@ -0,0 +1,36 @@ +using System.Net.Http.Json; +using static Web.Features.Products; + +namespace Web.Features; +public partial class ProductsTest +{ + public class FetchAllTest + { + [Fact] + public async Task Should_return_the_products() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.GetFromJsonAsync("/products"); + + // Assert + Assert.NotNull(response); + Assert.Collection(response.Products, + p => { + Assert.Equal(3, p.Id); + Assert.Equal(1, p.UnitPrice); + Assert.Equal("Habanero Pepper", p.Name); + }, + p => { + Assert.Equal(2, p.Id); + Assert.Equal(99, p.UnitPrice); + Assert.Equal("Scotch Bottle", p.Name); + } + ); + } + } +} \ No newline at end of file diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs new file mode 100644 index 0000000..0030925 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.FetchOneTest.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using System.Net; +using System.Net.Http.Json; +using static Web.Features.Products; + +namespace Web.Features; +public partial class ProductsTest +{ + public class FetchOneTest + { + [Fact] + public async Task Should_return_the_product() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.GetFromJsonAsync("/products/2"); + + // Assert + Assert.NotNull(response); + Assert.Equal(2, response.Id); + Assert.Equal(99, response.UnitPrice); + Assert.Equal("Scotch Bottle", response.Name); + } + + [Fact] + public async Task Should_return_a_ProblemDetails_with_a_NotFound_status_code() + { + // Arrange + await using var application = new C18WebApplication(); + await application.SeedAsync(SeederDelegateAsync); + var client = application.CreateClient(); + + // Act + var response = await client.GetAsync("/products/10"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal("The product \u002710\u0027 was not found.", problem.Title); + } + + } +} diff --git a/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs b/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs new file mode 100644 index 0000000..3079b14 --- /dev/null +++ b/C18/REPR/Web.Tests/Features/Products/ProductsTest.cs @@ -0,0 +1,22 @@ +using static Web.Features.Products; + +namespace Web.Features; + +public partial class ProductsTest +{ + private static async Task SeederDelegateAsync(ProductContext db) + { + db.Products.RemoveRange(db.Products.ToArray()); + db.Products.Add(new Product( + Name: "Scotch Bottle", + UnitPrice: 99, + Id: 2 + )); + db.Products.Add(new Product( + Name: "Habanero Pepper", + UnitPrice: 1, + Id: 3 + )); + await db.SaveChangesAsync(); + } +} diff --git a/C18/REPR/Web.Tests/GlobalUsings.cs b/C18/REPR/Web.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/C18/REPR/Web.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/C18/REPR/Web.Tests/Web.Tests.csproj b/C18/REPR/Web.Tests/Web.Tests.csproj new file mode 100644 index 0000000..8765050 --- /dev/null +++ b/C18/REPR/Web.Tests/Web.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + Web + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/C18/REPR/Web.Tests/WebApplication.cs b/C18/REPR/Web.Tests/WebApplication.cs new file mode 100644 index 0000000..9cb5421 --- /dev/null +++ b/C18/REPR/Web.Tests/WebApplication.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Runtime.CompilerServices; + +namespace Web; + +public class C18WebApplication : WebApplicationFactory +{ + private readonly Action? _afterConfigureServices; + private readonly string _databaseName; + public C18WebApplication([CallerMemberName] string? databaseName = null, Action? afterConfigureServices = null) + { + _databaseName = databaseName ?? nameof(C18WebApplication); + // Add some randomness to the database name to ensure uniqueness + // for test methods that have the same name. + _databaseName += Guid.NewGuid().ToString(); + _afterConfigureServices = afterConfigureServices; + } + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureServices(services => + { + // Override the default DbContext options to make + // a different InMemory database per test case so there is no + // seed conflicts. + services + .AddScoped(ConfigureContext) + .AddScoped(ConfigureContext) + ; + _afterConfigureServices?.Invoke(services); + }); + return base.CreateHost(builder); + } + + public DbContextOptions ConfigureContext(IServiceProvider sp) + where TDbContext : DbContext + { + return new DbContextOptionsBuilder() + .UseInMemoryDatabase(_databaseName + typeof(TDbContext).Name) + .UseApplicationServiceProvider(sp) + .Options; + } + + public Task SeedAsync(Func seeder) + where TDbContext : DbContext + { + using var seedScope = Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + return seeder(db); + } +} \ No newline at end of file diff --git a/C18/REPR/Web/Features/Baskets/BasketItemNotFoundException.cs b/C18/REPR/Web/Features/Baskets/BasketItemNotFoundException.cs new file mode 100644 index 0000000..d7a3368 --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/BasketItemNotFoundException.cs @@ -0,0 +1,11 @@ +using ForEvolve.ExceptionMapper; + +namespace Web.Features; + +public class BasketItemNotFoundException : NotFoundException +{ + public BasketItemNotFoundException(int productId) + : base($"The product '{productId}' is not in your shopping cart.") + { + } +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs b/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs new file mode 100644 index 0000000..7f0a415 --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.AddItem.cs @@ -0,0 +1,88 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; + +namespace Web.Features; + +public partial class Baskets +{ + public partial class AddItem + { + public record class Command( + int CustomerId, + int ProductId, + int Quantity + ); + public record class Response( + int ProductId, + int Quantity + ); + + [Mapper] + public partial class Mapper + { + public partial BasketItem Map(Command item); + public partial Response Map(BasketItem item); + } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.CustomerId).GreaterThan(0); + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + } + } + + public class Handler + { + private readonly BasketContext _db; + private readonly Mapper _mapper; + + public Handler(BasketContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Command command, CancellationToken cancellationToken) + { + var itemExists = await _db.Items.AnyAsync( + x => x.CustomerId == command.CustomerId && x.ProductId == command.ProductId, + cancellationToken: cancellationToken + ); + if (itemExists) + { + throw new DuplicateBasketItemException(command.ProductId); + } + var item = _mapper.Map(command); + _db.Add(item); + await _db.SaveChangesAsync(cancellationToken); + var result = _mapper.Map(item); + return result; + } + } + } + + public static IServiceCollection AddAddItem(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapAddItem(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost( + "/", + async (AddItem.Command command, AddItem.Handler handler, CancellationToken cancellationToken) => + { + var result = await handler.HandleAsync(command, cancellationToken); + return TypedResults.Created($"/products/{result.ProductId}", result); + } + ); + return endpoints; + } +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs b/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs new file mode 100644 index 0000000..4c11799 --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.FetchItems.cs @@ -0,0 +1,75 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; +using System.Collections; + +namespace Web.Features; + +public partial class Baskets +{ + public partial class FetchItems + { + public record class Query(int CustomerId); + public record class Response(IEnumerable Items) : IEnumerable + { + public IEnumerator GetEnumerator() + => Items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() + => ((IEnumerable)Items).GetEnumerator(); + } + + public record class Item(int ProductId, int Quantity); + + [Mapper] + public partial class Mapper + { + public partial Response Map(IQueryable items); + } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.CustomerId).GreaterThan(0); + } + } + + public class Handler + { + private readonly BasketContext _db; + private readonly Mapper _mapper; + + public Handler(BasketContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Query query, CancellationToken cancellationToken) + { + var items = _db.Items.Where(x => x.CustomerId == query.CustomerId); + await items.LoadAsync(cancellationToken); + var result = _mapper.Map(items); + return result; + } + } + } + + public static IServiceCollection AddFetchItems(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapFetchItems(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/{CustomerId}", + ([AsParameters] FetchItems.Query query, FetchItems.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(query, cancellationToken) + ); + return endpoints; + } +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs b/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs new file mode 100644 index 0000000..1d9061a --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.RemoveItem.cs @@ -0,0 +1,74 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; +namespace Web.Features; + +public partial class Baskets +{ + public partial class RemoveItem + { + public record class Command(int CustomerId, int ProductId); + public record class Response(int ProductId, int Quantity); + + [Mapper] + public partial class Mapper + { + public partial Response Map(BasketItem item); + } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.CustomerId).GreaterThan(0); + RuleFor(x => x.ProductId).GreaterThan(0); + } + } + + public class Handler + { + private readonly BasketContext _db; + private readonly Mapper _mapper; + + public Handler(BasketContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Command command, CancellationToken cancellationToken) + { + var item = await _db.Items.FirstOrDefaultAsync( + x => x.CustomerId == command.CustomerId && x.ProductId == command.ProductId, + cancellationToken: cancellationToken + ); + if (item is null) + { + throw new BasketItemNotFoundException(command.ProductId); + } + _db.Items.Remove(item); + await _db.SaveChangesAsync(cancellationToken); + var result = _mapper.Map(item); + return result; + } + } + } + + public static IServiceCollection AddRemoveItem(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapRemoveItem(this IEndpointRouteBuilder endpoints) + { + endpoints.MapDelete( + "/{customerId}/{productId}", + ([AsParameters] RemoveItem.Command command, RemoveItem.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(command, cancellationToken) + ); + return endpoints; + } +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs b/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs new file mode 100644 index 0000000..b16727b --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.UpdateQuantity.cs @@ -0,0 +1,80 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; +namespace Web.Features; + +public partial class Baskets +{ + public partial class UpdateQuantity + { + public record class Command(int CustomerId, int ProductId, int Quantity); + public record class Response(int ProductId, int Quantity); + + [Mapper] + public partial class Mapper + { + public partial BasketItem Map(Command item); + public partial Response Map(BasketItem item); + } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.CustomerId).GreaterThan(0); + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + } + } + + public class Handler + { + private readonly BasketContext _db; + private readonly Mapper _mapper; + + public Handler(BasketContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Command command, CancellationToken cancellationToken) + { + var item = await _db.Items.AsNoTracking().FirstOrDefaultAsync( + x => x.CustomerId == command.CustomerId && x.ProductId == command.ProductId, + cancellationToken: cancellationToken + ); + if (item is null) + { + throw new BasketItemNotFoundException(command.ProductId); + } + var itemToUpdate = item with { Quantity = command.Quantity }; + if (item.Quantity != command.Quantity) + { + _db.Items.Update(itemToUpdate); + await _db.SaveChangesAsync(cancellationToken); + } + var result = _mapper.Map(itemToUpdate); + return result; + } + } + } + + public static IServiceCollection AddUpdateQuantity(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapUpdateQuantity(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPut( + "/", + (UpdateQuantity.Command command, UpdateQuantity.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(command, cancellationToken) + ); + return endpoints; + } +} diff --git a/C18/REPR/Web/Features/Baskets/Baskets.cs b/C18/REPR/Web/Features/Baskets/Baskets.cs new file mode 100644 index 0000000..01bba26 --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/Baskets.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Web.Features; + +public static partial class Baskets +{ + public record class BasketItem(int CustomerId, int ProductId, int Quantity); + + public class BasketContext : DbContext + { + public BasketContext(DbContextOptions options) + : base(options) { } + + public DbSet Items => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder + .Entity() + .HasKey(x => new { x.CustomerId, x.ProductId }) + ; + } + } + + public static IServiceCollection AddBasketsFeature(this IServiceCollection services) + { + return services + .AddAddItem() + .AddFetchItems() + .AddRemoveItem() + .AddUpdateQuantity() + .AddDbContext(options => options + .UseInMemoryDatabase("BasketContextMemoryDB") + .ConfigureWarnings(builder => builder.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + ) + ; + } + + public static IEndpointRouteBuilder MapBasketsFeature(this IEndpointRouteBuilder endpoints) + { + var group = endpoints + .MapGroup(nameof(Baskets).ToLower()) + .WithTags(nameof(Baskets)) + ; + group + .MapFetchItems() + .MapAddItem() + .MapUpdateQuantity() + .MapRemoveItem() + ; + return endpoints; + } + + public static Task SeedBasketsAsync(this IServiceScope scope) + { + return Task.CompletedTask; + } +} diff --git a/C18/REPR/Web/Features/Baskets/DuplicateBasketItemException.cs b/C18/REPR/Web/Features/Baskets/DuplicateBasketItemException.cs new file mode 100644 index 0000000..74aa74e --- /dev/null +++ b/C18/REPR/Web/Features/Baskets/DuplicateBasketItemException.cs @@ -0,0 +1,11 @@ +using ForEvolve.ExceptionMapper; + +namespace Web.Features; + +public class DuplicateBasketItemException : ConflictException +{ + public DuplicateBasketItemException(int productId) + : base($"The product '{productId}' is already in your shopping cart.") + { + } +} diff --git a/C18/REPR/Web/Features/Features.cs b/C18/REPR/Web/Features/Features.cs new file mode 100644 index 0000000..1a3ec19 --- /dev/null +++ b/C18/REPR/Web/Features/Features.cs @@ -0,0 +1,43 @@ +using FluentValidation; +using FluentValidation.AspNetCore; +using System.Reflection; + +namespace Web.Features; + +public static class Features +{ + public static IServiceCollection AddFeatures(this WebApplicationBuilder builder) + { + // Register fluent validation + builder.AddFluentValidationEndpointFilter(); + return builder.Services + .AddFluentValidationAutoValidation() + .AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()) + + // Add features + .AddProductsFeature() + .AddBasketsFeature() + ; + } + + public static IEndpointRouteBuilder MapFeatures(this IEndpointRouteBuilder endpoints) + { + var group = endpoints + .MapGroup("/") + .AddFluentValidationFilter(); + ; + group + .MapProductsFeature() + .MapBasketsFeature() + ; + return endpoints; + } + + public static async Task SeedFeaturesAsync(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + + await scope.SeedProductsAsync(); + await scope.SeedBasketsAsync(); + } +} diff --git a/C18/REPR/Web/Features/Products/ProductNotFoundException.cs b/C18/REPR/Web/Features/Products/ProductNotFoundException.cs new file mode 100644 index 0000000..e9b7a2f --- /dev/null +++ b/C18/REPR/Web/Features/Products/ProductNotFoundException.cs @@ -0,0 +1,12 @@ +using ForEvolve.ExceptionMapper; + +namespace Web.Features; + +public class ProductNotFoundException : NotFoundException +{ + public ProductNotFoundException(int productId) + : base($"The product '{productId}' was not found.") + { + + } +} diff --git a/C18/REPR/Web/Features/Products/Products.FetchAll.cs b/C18/REPR/Web/Features/Products/Products.FetchAll.cs new file mode 100644 index 0000000..452879e --- /dev/null +++ b/C18/REPR/Web/Features/Products/Products.FetchAll.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; + +namespace Web.Features; + +public partial class Products +{ + public partial class FetchAll + { + public record class Query(); + public record class Response(IEnumerable Products); + public record class ResponseProduct(int Id, string Name, decimal UnitPrice); + + [Mapper] + public partial class Mapper + { + public partial IEnumerable Project(IQueryable products); + } + + public class Handler + { + private readonly ProductContext _db; + private readonly Mapper _mapper; + + public Handler(ProductContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Query query, CancellationToken cancellationToken) + { + await _db.Products.LoadAsync(cancellationToken); + var products = _mapper.Project(_db.Products.OrderBy(x => x.Name)); + return new Response(products); + } + } + } + + public static IServiceCollection AddFetchAll(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapFetchAll(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/", + (FetchAll.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(new FetchAll.Query(), cancellationToken) + ); + return endpoints; + } +} diff --git a/C18/REPR/Web/Features/Products/Products.FetchOne.cs b/C18/REPR/Web/Features/Products/Products.FetchOne.cs new file mode 100644 index 0000000..a1a500e --- /dev/null +++ b/C18/REPR/Web/Features/Products/Products.FetchOne.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Riok.Mapperly.Abstractions; + +namespace Web.Features; + +public partial class Products +{ + public partial class FetchOne + { + public record class Query(int ProductId); + public record class Response(int Id, string Name, decimal UnitPrice); + + [Mapper] + public partial class Mapper + { + public partial Response Map(Product product); + } + + public class Handler + { + private readonly ProductContext _db; + private readonly Mapper _mapper; + + public Handler(ProductContext db, Mapper mapper) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + public async Task HandleAsync(Query query, CancellationToken cancellationToken) + { + var product = await _db.Products.FirstOrDefaultAsync( + x => x.Id == query.ProductId, + cancellationToken: cancellationToken + ); + if (product is null) + { + throw new ProductNotFoundException(query.ProductId); + } + return _mapper.Map(product); + } + } + } + + public static IServiceCollection AddFetchOne(this IServiceCollection services) + { + return services + .AddScoped() + .AddSingleton() + ; + } + + public static IEndpointRouteBuilder MapFetchOne(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/{ProductId}", + ([AsParameters] FetchOne.Query query, FetchOne.Handler handler, CancellationToken cancellationToken) + => handler.HandleAsync(query, cancellationToken) + ); + return endpoints; + } +} diff --git a/C18/REPR/Web/Features/Products/Products.cs b/C18/REPR/Web/Features/Products/Products.cs new file mode 100644 index 0000000..5e88a1f --- /dev/null +++ b/C18/REPR/Web/Features/Products/Products.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Web.Features; + +public static partial class Products +{ + public record class Product(string Name, decimal UnitPrice, int? Id = null); + + public class ProductContext : DbContext + { + public ProductContext(DbContextOptions options) + : base(options) { } + + public DbSet Products => Set(); + } + + public static IServiceCollection AddProductsFeature(this IServiceCollection services) + { + return services + .AddFetchAll() + .AddFetchOne() + .AddDbContext(options => options + .UseInMemoryDatabase("ProductContextMemoryDB") + .ConfigureWarnings(builder => builder.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + ) + ; + } + + public static IEndpointRouteBuilder MapProductsFeature(this IEndpointRouteBuilder endpoints) + { + var group = endpoints + .MapGroup(nameof(Products).ToLower()) + .WithTags(nameof(Products)) + ; + group + .MapFetchAll() + .MapFetchOne() + ; + return endpoints; + } + + public static async Task SeedProductsAsync(this IServiceScope scope) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Products.Add(new Product( + Name: "Banana", + UnitPrice: 0.30m, + Id: 1 + )); + db.Products.Add(new Product( + Name: "Apple", + UnitPrice: 0.79m, + Id: 2 + )); + db.Products.Add(new Product( + Name: "Habanero Pepper", + UnitPrice: 0.99m, + Id: 3 + )); + await db.SaveChangesAsync(); + } +} diff --git a/C18/REPR/Web/Program.cs b/C18/REPR/Web/Program.cs new file mode 100644 index 0000000..2f5c51d --- /dev/null +++ b/C18/REPR/Web/Program.cs @@ -0,0 +1,79 @@ +#undef MY_EXCEPTION_MIDDLEWARE +#if MY_EXCEPTION_MIDDLEWARE +using Microsoft.AspNetCore.Diagnostics; +using Web; +#endif + +using Microsoft.EntityFrameworkCore; +using Web.Features; + +var builder = WebApplication.CreateBuilder(args); +builder.AddExceptionMapper(builder => +{ + builder + .Map() + .ToStatusCode(StatusCodes.Status409Conflict) + ; + builder + .Map() + .ToStatusCode(StatusCodes.Status409Conflict) + ; +}); +builder.AddFeatures(); + +#if MY_EXCEPTION_MIDDLEWARE +builder.Services.AddSingleton(); +#endif + +var app = builder.Build(); +app.UseExceptionMapper(); + +#if MY_EXCEPTION_MIDDLEWARE +app.UseExceptionHandler(errorApp => +{ + errorApp.Use(async (context, next) => + { + var exceptionHandlerPathFeature = context.Features + .Get() ?? throw new NotSupportedException(); + var logger = context.RequestServices + .GetRequiredService() + .CreateLogger("ExceptionHandler"); + var exception = exceptionHandlerPathFeature.Error; + logger.LogWarning( + "An exception occurred: {message}", + exception.Message + ); + await next(context); + }); + errorApp.UseMiddleware(); +}); +#endif + +app.MapFeatures(); + +await app.SeedFeaturesAsync(); + +app.Run(); + +// Workaround that makes the autogenerated program public so tests can +// access it without granting internal visibility. +#pragma warning disable CA1050 // Declare types in namespaces +public partial class Program { } + +#if MY_EXCEPTION_MIDDLEWARE +public class MyExceptionMiddleware : IMiddleware +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var exceptionHandlerPathFeature = context.Features + .Get() ?? throw new NotSupportedException(); + + var exception = exceptionHandlerPathFeature.Error; + await context.Response.WriteAsJsonAsync(new + { + Error = exception.Message + }); + await next(context); + } +} +#endif \ No newline at end of file diff --git a/C18/REPR/Web/Properties/launchSettings.json b/C18/REPR/Web/Properties/launchSettings.json new file mode 100644 index 0000000..5f2c386 --- /dev/null +++ b/C18/REPR/Web/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7252;http://localhost:5202", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/C18/REPR/Web/Web.csproj b/C18/REPR/Web/Web.csproj new file mode 100644 index 0000000..8740266 --- /dev/null +++ b/C18/REPR/Web/Web.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/C18/REPR/Web/Web.http b/C18/REPR/Web/Web.http new file mode 100644 index 0000000..73f9b18 --- /dev/null +++ b/C18/REPR/Web/Web.http @@ -0,0 +1,49 @@ +@Web_HostAddress = https://localhost:7252 +@ProductId = 3 +@NonExistingProductId = 4 +@CustomerId = 1 + +GET {{Web_HostAddress}}/products + +### + +GET {{Web_HostAddress}}/products/{{ProductId}} + +### + + +GET {{Web_HostAddress}}/products/{{NonExistingProductId}} + +### + + + +GET {{Web_HostAddress}}/baskets/{{CustomerId}} + +### + +POST {{Web_HostAddress}}/baskets +Content-Type: application/json + +{ + "customerId": {{CustomerId}}, + "productId": {{ProductId}}, + "quantity": 10 +} + +### + +PUT {{Web_HostAddress}}/baskets +Content-Type: application/json + +{ + "customerId": {{CustomerId}}, + "productId": {{ProductId}}, + "quantity": 15 +} + +### + +DELETE {{Web_HostAddress}}/baskets/{{CustomerId}}/{{ProductId}} + +### diff --git a/C18/REPR/Web/appsettings.Development.json b/C18/REPR/Web/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/C18/REPR/Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/C18/REPR/Web/appsettings.json b/C18/REPR/Web/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/C18/REPR/Web/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/C18/SimpleEndpoint/Program.cs b/C18/SimpleEndpoint/Program.cs new file mode 100644 index 0000000..0fb388e --- /dev/null +++ b/C18/SimpleEndpoint/Program.cs @@ -0,0 +1,17 @@ +using SimpleEndpoint; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddUpperCase(); + +var app = builder.Build(); + +app.MapGet("/shuffle-text/{text}", ([AsParameters] ShuffleText.Request query, ShuffleText.Endpoint endpoint) + => endpoint.Handle(query)); + +app.MapGet("/random-number/{Amount}/{Min}/{Max}", RandomNumber.Endpoint); + +app.MapUpperCase(); + +app.Run(); diff --git a/C18/SimpleEndpoint/Properties/launchSettings.json b/C18/SimpleEndpoint/Properties/launchSettings.json new file mode 100644 index 0000000..42d260b --- /dev/null +++ b/C18/SimpleEndpoint/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7289;http://localhost:5091", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/C18/SimpleEndpoint/RandomNumber.cs b/C18/SimpleEndpoint/RandomNumber.cs new file mode 100644 index 0000000..f26b17b --- /dev/null +++ b/C18/SimpleEndpoint/RandomNumber.cs @@ -0,0 +1,22 @@ +namespace SimpleEndpoint; + +public class RandomNumber +{ + public record class Request(int Amount, int Min, int Max); + public record class Response(IEnumerable Numbers); + public class Handler + { + public Response Handle(Request request) + { + var result = new int[request.Amount]; + for (var i = 0; i < request.Amount; i++) + { + result[i] = Random.Shared.Next(request.Min, request.Max); + } + return new Response(result); + } + } + + public static Response Endpoint([AsParameters] Request query, Handler handler) + => handler.Handle(query); +} diff --git a/C18/SimpleEndpoint/ShuffleText.cs b/C18/SimpleEndpoint/ShuffleText.cs new file mode 100644 index 0000000..e9de3ea --- /dev/null +++ b/C18/SimpleEndpoint/ShuffleText.cs @@ -0,0 +1,16 @@ +namespace SimpleEndpoint; + +public class ShuffleText +{ + public record class Request(string Text); + public record class Response(string Text); + public class Endpoint + { + public Response Handle(Request request) + { + var chars = request.Text.ToArray(); + Random.Shared.Shuffle(chars); + return new Response(new string(chars)); + } + } +} diff --git a/C18/SimpleEndpoint/SimpleEndpoint.csproj b/C18/SimpleEndpoint/SimpleEndpoint.csproj new file mode 100644 index 0000000..1b28a01 --- /dev/null +++ b/C18/SimpleEndpoint/SimpleEndpoint.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/C18/SimpleEndpoint/SimpleEndpoint.http b/C18/SimpleEndpoint/SimpleEndpoint.http new file mode 100644 index 0000000..7331861 --- /dev/null +++ b/C18/SimpleEndpoint/SimpleEndpoint.http @@ -0,0 +1,13 @@ +@SimpleEndpoint_HostAddress = https://localhost:7289 + +GET {{SimpleEndpoint_HostAddress}}/shuffle-text/I%20love%20ASP.NET%20Core + +### + +GET {{SimpleEndpoint_HostAddress}}/random-number/5/0/100 + +### + +GET {{SimpleEndpoint_HostAddress}}/upper-case/I%20love%20ASP.NET%20Core + +### diff --git a/C18/SimpleEndpoint/SimpleEndpoint.sln b/C18/SimpleEndpoint/SimpleEndpoint.sln new file mode 100644 index 0000000..180f424 --- /dev/null +++ b/C18/SimpleEndpoint/SimpleEndpoint.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEndpoint", "SimpleEndpoint.csproj", "{9358C6E7-9990-482E-968A-D93A7FEF6715}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9358C6E7-9990-482E-968A-D93A7FEF6715}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9358C6E7-9990-482E-968A-D93A7FEF6715}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9358C6E7-9990-482E-968A-D93A7FEF6715}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9358C6E7-9990-482E-968A-D93A7FEF6715}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/C18/SimpleEndpoint/UpperCase.cs b/C18/SimpleEndpoint/UpperCase.cs new file mode 100644 index 0000000..fbdf0fe --- /dev/null +++ b/C18/SimpleEndpoint/UpperCase.cs @@ -0,0 +1,29 @@ +namespace SimpleEndpoint; + +public static class UpperCase +{ + public record class Request(string Text); + public record class Response(string Text); + public class Handler + { + public Response Handle(Request request) + { + return new Response(request.Text.ToUpper()); + } + } + + public static IServiceCollection AddUpperCase(this IServiceCollection services) + { + return services.AddSingleton(); + } + + public static IEndpointRouteBuilder MapUpperCase(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/upper-case/{Text}", + ([AsParameters] Request query, Handler handler) + => handler.Handle(query) + ); + return endpoints; + } +} diff --git a/C18/SimpleEndpoint/appsettings.Development.json b/C18/SimpleEndpoint/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/C18/SimpleEndpoint/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/C18/SimpleEndpoint/appsettings.json b/C18/SimpleEndpoint/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/C18/SimpleEndpoint/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Diagrams/REPR.drawio b/Diagrams/REPR.drawio new file mode 100644 index 0000000..cde548c --- /dev/null +++ b/Diagrams/REPR.drawio @@ -0,0 +1,498 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Diagrams/REPR.svg b/Diagrams/REPR.svg new file mode 100644 index 0000000..b1be5f3 --- /dev/null +++ b/Diagrams/REPR.svg @@ -0,0 +1 @@ +
ExceptionMapper
Middleware
ExceptionMapper...
handle the error
handle the error
Request 
Request 
Input
Input
 Endpoint
 Endpoint
Logic
Logic
 Response
 Response
Output
Output
600 px MAX width
600 px MAX width
Root
Root
Areas
Areas
Features
Features
Features
Features
Products
Products
Baskets
Baskets
AddItem
AddItem
RemoveItem
RemoveItem
FetchItems
FetchItems
UpdateQuantity
UpdateQuantity
FetchAll
FetchAll
FetchOne
FetchOne
Program
Program
AddFeatures
AddFeatures
MapFeatures
MapFeatures
SeedFeaturesAsync
SeedFeaturesAsync
AddProductsFeature
AddProductsFeature
AddBasketsFeature
AddBasketsFeature
AddFetchAll
AddFetchAll
AddFetchOne
AddFetchOne
AddDbContext
AddDbContext
AddScoped<FetchAll.Handler>
AddScoped<FetchAll.Handler>
AddSingleton<FetchAll.Mapper>
AddSingleton<FetchAll.Mapper>
600 px MAX width
600 px MAX width
Program
Program
AddFeatures
AddFeatures
MapFeatures
MapFeatures
SeedFeaturesAsync
SeedFeaturesAsync
AddProductsFeature
AddProductsFeature
AddBasketsFeature
AddBasketsFeature
AddFetchAll
AddFetchAll
AddFetchOne
AddFetchOne
AddDbContext
AddDbContext
Program.cs
Program.cs
Features.cs
AddFeatures
Features.cs...
Features.cs
MapFeatures
Features.cs...
Features.cs
SeedFeaturesAsync
Features.cs...
Products.cs
AddProductsFeature
Products.cs...
Baskets.cs
AddBasketsFeature
Baskets.cs...
Products.FetchAll.cs
AddFetchAll
Products.FetchAll.cs...
Products.FetchOne.cs
AddFetchOne
Products.FetchOne.cs...
Products.cs
ProductContext
Products.cs...
Baskets.AddItem.cs
AddAddItem
Baskets.AddItem.cs...
Baskets.FetchItems.cs
AddFetchItems
Baskets.FetchItems.cs...
Baskets.RemoveItem.cs
AddRemoveItem
Baskets.RemoveItem.cs...
Baskets.cs
BasketContext
Baskets.cs...
Baskets.UpdateQuantity.cs
AddUpdateQuantity
Baskets.UpdateQuantity.cs...
Products.cs
MapProductsFeature
Products.cs...
Products.cs
SeedProductsAsync
Products.cs...
Program.cs
Program.cs
Program.cs
Program.cs
Baskets.cs
MapBasketsFeature
Baskets.cs...
Products.FetchAll.cs
MapFetchAll
Products.FetchAll.cs...
Products.FetchOne.cs
MapFetchOne
Products.FetchOne.cs...
Baskets.FetchItems.cs
MapFetchItems
Baskets.FetchItems.cs...
Baskets.AddItem.cs
MapAddItem
Baskets.AddItem.cs...
Baskets.UpdateQuantity.cs
MapUpdateQuantity
Baskets.UpdateQuantity.cs...
Baskets.RemoveItem.cs
MapRemoveItem
Baskets.RemoveItem.cs...
Baskets.cs
SeedBasketsAsync
Baskets.cs...
ASP.NET Core Pipeline
ASP.NET Core Pip...
 AddItem Endpoint
 AddItem Endpoint
DuplicateBasketItemException
DuplicateBasketItemException
Baskets.AddItem.Handler.HandleAsync(command)
Baskets.AddItem...
throw DuplicateBasketItemException
throw DuplicateBasketItemException
InvokeAsync
InvokeAsync
return IResult
return IResult
Response 409
Response 409
POST /baskets
POST /baskets
Text is not SVG - cannot display
\ No newline at end of file