From d2a44460ba6bd58224a116b0ee1473ddb99fc1f1 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 13:09:58 +0300 Subject: [PATCH 1/6] refactor(http): use IHttpClientFactory for ProductsService and fix DI config - Replace per-instance HttpClient with typed HttpClient via IHttpClientFactory - Remove BuildServiceProvider anti-pattern; accept IConfiguration in DefaultConfiguration - Inject HttpClient into ProductsService and make JSON deserialization null-safe with logging - Pass builder.Configuration into registration calls in Program.cs --- src/CSharpApp.Api/Program.cs | 4 +-- .../Products/ProductsService.cs | 36 +++++++++++++------ .../Configuration/DefaultConfiguration.cs | 11 ++---- .../Configuration/HttpConfiguration.cs | 24 +++++++++++-- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/CSharpApp.Api/Program.cs b/src/CSharpApp.Api/Program.cs index b0eb6a01..8d1e35f1 100644 --- a/src/CSharpApp.Api/Program.cs +++ b/src/CSharpApp.Api/Program.cs @@ -6,8 +6,8 @@ // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); -builder.Services.AddDefaultConfiguration(); -builder.Services.AddHttpConfiguration(); +builder.Services.AddDefaultConfiguration(builder.Configuration); +builder.Services.AddHttpConfiguration(builder.Configuration); builder.Services.AddProblemDetails(); builder.Services.AddApiVersioning(); diff --git a/src/CSharpApp.Application/Products/ProductsService.cs b/src/CSharpApp.Application/Products/ProductsService.cs index 0f1d367e..91b1cb61 100644 --- a/src/CSharpApp.Application/Products/ProductsService.cs +++ b/src/CSharpApp.Application/Products/ProductsService.cs @@ -6,22 +6,38 @@ public class ProductsService : IProductsService private readonly RestApiSettings _restApiSettings; private readonly ILogger _logger; - public ProductsService(IOptions restApiSettings, + public ProductsService(HttpClient httpClient, IOptions restApiSettings, ILogger logger) { - _httpClient = new HttpClient(); + _httpClient = httpClient; _restApiSettings = restApiSettings.Value; _logger = logger; } public async Task> GetProducts() { - _httpClient.BaseAddress = new Uri(_restApiSettings.BaseUrl!); - var response = await _httpClient.GetAsync(_restApiSettings.Products); - response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStringAsync(); - var res = JsonSerializer.Deserialize>(content); - - return res.AsReadOnly(); + try + { + // If Products path is set, request it; otherwise request root + var path = string.IsNullOrWhiteSpace(_restApiSettings.Products) ? string.Empty : _restApiSettings.Products; + + var response = await _httpClient.GetAsync(path); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var res = JsonSerializer.Deserialize>(content, options) ?? new List(); + + return res.AsReadOnly(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching products from remote API"); + throw; + } } -} \ No newline at end of file +} diff --git a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs index 376d7cd3..f4602d97 100755 --- a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs @@ -2,16 +2,11 @@ namespace CSharpApp.Infrastructure.Configuration; public static class DefaultConfiguration { - public static IServiceCollection AddDefaultConfiguration(this IServiceCollection services) + public static IServiceCollection AddDefaultConfiguration(this IServiceCollection services, IConfiguration configuration) { - var serviceProvider = services.BuildServiceProvider(); - var configuration = serviceProvider.GetService(); - - services.Configure(configuration!.GetSection(nameof(RestApiSettings))); + services.Configure(configuration.GetSection(nameof(RestApiSettings))); services.Configure(configuration.GetSection(nameof(HttpClientSettings))); - services.AddSingleton(); - return services; } -} \ No newline at end of file +} diff --git a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs index b4c5c6ee..b5fd89d4 100755 --- a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs @@ -1,9 +1,29 @@ +using Microsoft.Extensions.Options; + namespace CSharpApp.Infrastructure.Configuration; public static class HttpConfiguration { - public static IServiceCollection AddHttpConfiguration(this IServiceCollection services) + public static IServiceCollection AddHttpConfiguration(this IServiceCollection services, IConfiguration configuration) { + // Register a typed HttpClient for ProductsService using IHttpClientFactory + services.AddHttpClient((sp, client) => + { + var rest = sp.GetRequiredService>().Value; + var httpSettings = sp.GetRequiredService>().Value; + + if (!string.IsNullOrWhiteSpace(rest.BaseUrl)) + { + client.BaseAddress = new Uri(rest.BaseUrl); + } + + // Configure timeout from settings (LifeTime is seconds) + if (httpSettings.LifeTime > 0) + { + client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); + } + }); + return services; } -} \ No newline at end of file +} From 7fdde6dc86174ff68a915f21e383968b63023f47 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 14:05:47 +0300 Subject: [PATCH 2/6] - Add get-by-id and create endpoints + unit tests - Add GetProductById/CreateProduct to IProductsService and ProductsService, exttending API endpoints, and include unit tests - Test project added + ProductService tests added --- src/CSharpApp.Api/GlobalUsings.cs | 4 +- src/CSharpApp.Api/Program.cs | 31 +++++- .../Products/ProductsService.cs | 62 ++++++++++++ .../Interfaces/IProductsService.cs | 2 + src/CSharpApp.sln | 29 +++--- .../CSharpApp.Tests/CSharpApp.Tests.csproj | 26 +++++ .../CSharpApp.Tests/ProductsServiceTests.cs | 97 +++++++++++++++++++ 7 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 src/test/CSharpApp.Tests/CSharpApp.Tests.csproj create mode 100644 src/test/CSharpApp.Tests/ProductsServiceTests.cs diff --git a/src/CSharpApp.Api/GlobalUsings.cs b/src/CSharpApp.Api/GlobalUsings.cs index 4fb0d713..65608b04 100644 --- a/src/CSharpApp.Api/GlobalUsings.cs +++ b/src/CSharpApp.Api/GlobalUsings.cs @@ -2,4 +2,6 @@ global using CSharpApp.Core.Interfaces; global using CSharpApp.Infrastructure.Configuration; -global using Serilog; \ No newline at end of file +global using Serilog; +global using CSharpApp.Core.Dtos; +global using Microsoft.AspNetCore.Http; diff --git a/src/CSharpApp.Api/Program.cs b/src/CSharpApp.Api/Program.cs index 8d1e35f1..3bc1da7d 100644 --- a/src/CSharpApp.Api/Program.cs +++ b/src/CSharpApp.Api/Program.cs @@ -23,12 +23,39 @@ var versionedEndpointRouteBuilder = app.NewVersionedApi(); -versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/getproducts", async (IProductsService productsService) => +versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/products", async (IProductsService productsService) => { var products = await productsService.GetProducts(); - return products; + return Results.Ok(products); }) .WithName("GetProducts") .HasApiVersion(1.0); +versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/products/{id}", async (IProductsService productsService, int id, CancellationToken ct) => + { + var product = await productsService.GetProductById(id, ct); + return product is null ? Results.NotFound() : Results.Ok(product); + }) + .WithName("GetProductById") + .HasApiVersion(1.0); + +versionedEndpointRouteBuilder.MapPost("api/v{version:apiVersion}/products", async (IProductsService productsService, Product product, HttpContext http, CancellationToken ct) => + { + if (product == null) + return Results.BadRequest(); + + // Basic validation + if (string.IsNullOrWhiteSpace(product.Title) || (product.Price.HasValue && product.Price <= 0)) + return Results.BadRequest("Invalid product payload"); + + var created = await productsService.CreateProduct(product, ct); + if (created == null) + return Results.StatusCode(StatusCodes.Status502BadGateway); + + var location = $"/api/v1/products/{created.Id}"; + return Results.Created(location, created); + }) + .WithName("CreateProduct") + .HasApiVersion(1.0); + app.Run(); \ No newline at end of file diff --git a/src/CSharpApp.Application/Products/ProductsService.cs b/src/CSharpApp.Application/Products/ProductsService.cs index 91b1cb61..57a8e9de 100644 --- a/src/CSharpApp.Application/Products/ProductsService.cs +++ b/src/CSharpApp.Application/Products/ProductsService.cs @@ -14,6 +14,68 @@ public ProductsService(HttpClient httpClient, IOptions restApiS _logger = logger; } + public async Task GetProductById(int id, CancellationToken cancellationToken = default) + { + try + { + var path = string.IsNullOrWhiteSpace(_restApiSettings.Products) ? $"{id}" : $"{_restApiSettings.Products}/{id}"; + + var response = await _httpClient.GetAsync(path, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var product = JsonSerializer.Deserialize(content, options); + + return product; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching product {ProductId} from remote API", id); + throw; + } + } + + public async Task CreateProduct(Product product, CancellationToken cancellationToken = default) + { + try + { + // Basic validation + if (product is null) + throw new ArgumentNullException(nameof(product)); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var json = JsonSerializer.Serialize(product, options); + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + var path = string.IsNullOrWhiteSpace(_restApiSettings.Products) ? string.Empty : _restApiSettings.Products; + + var response = await _httpClient.PostAsync(path, content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to create product. StatusCode: {StatusCode}", response.StatusCode); + return null; + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var created = JsonSerializer.Deserialize(responseContent, options); + + return created ?? product; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating product"); + throw; + } + } + public async Task> GetProducts() { try diff --git a/src/CSharpApp.Core/Interfaces/IProductsService.cs b/src/CSharpApp.Core/Interfaces/IProductsService.cs index 5f321f2f..715f8e56 100644 --- a/src/CSharpApp.Core/Interfaces/IProductsService.cs +++ b/src/CSharpApp.Core/Interfaces/IProductsService.cs @@ -3,4 +3,6 @@ namespace CSharpApp.Core.Interfaces; public interface IProductsService { Task> GetProducts(); + Task GetProductById(int id, CancellationToken cancellationToken = default); + Task CreateProduct(Product product, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/CSharpApp.sln b/src/CSharpApp.sln index 62efd413..6382d09b 100644 --- a/src/CSharpApp.sln +++ b/src/CSharpApp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11822.322 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{09E4065D-8EA9-43F4-AB9C-5E70ACC35F88}" EndProject @@ -15,20 +15,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpApp.Core", "CSharpApp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpApp.Infrastructure", "CSharpApp.Infrastructure\CSharpApp.Infrastructure.csproj", "{1D24449A-3896-48C5-B007-A33F8479456C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpApp.Tests", "test\CSharpApp.Tests\CSharpApp.Tests.csproj", "{FA0E527F-0190-B11D-0A5E-4007C794BEA2}" +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(NestedProjects) = preSolution - {E3BB3FDA-5F80-48B0-B0A5-B29F89814132} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} - {5AEEC8AB-02B6-4051-A6EF-B785FC773E87} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} - {75D8AC89-79D6-4C05-88C5-E2BC6445F202} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} - {1D24449A-3896-48C5-B007-A33F8479456C} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {E3BB3FDA-5F80-48B0-B0A5-B29F89814132}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E3BB3FDA-5F80-48B0-B0A5-B29F89814132}.Debug|Any CPU.Build.0 = Debug|Any CPU @@ -46,5 +39,19 @@ Global {1D24449A-3896-48C5-B007-A33F8479456C}.Debug|Any CPU.Build.0 = Debug|Any CPU {1D24449A-3896-48C5-B007-A33F8479456C}.Release|Any CPU.ActiveCfg = Release|Any CPU {1D24449A-3896-48C5-B007-A33F8479456C}.Release|Any CPU.Build.0 = Release|Any CPU + {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {E3BB3FDA-5F80-48B0-B0A5-B29F89814132} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} + {5AEEC8AB-02B6-4051-A6EF-B785FC773E87} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} + {75D8AC89-79D6-4C05-88C5-E2BC6445F202} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} + {1D24449A-3896-48C5-B007-A33F8479456C} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} + {FA0E527F-0190-B11D-0A5E-4007C794BEA2} = {AEEC3AD8-B5EE-4590-8714-024364B7557A} EndGlobalSection EndGlobal diff --git a/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj new file mode 100644 index 00000000..faa214f1 --- /dev/null +++ b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + false + + + + + + all + + + + + + + + + + + + + + + diff --git a/src/test/CSharpApp.Tests/ProductsServiceTests.cs b/src/test/CSharpApp.Tests/ProductsServiceTests.cs new file mode 100644 index 00000000..91301b3c --- /dev/null +++ b/src/test/CSharpApp.Tests/ProductsServiceTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CSharpApp.Tests; + +public class ProductsServiceTests +{ + [Fact] + public async Task GetProductById_ReturnsProduct_WhenFound() + { + // Arrange + var productJson = "{ \"id\": 1, \"title\": \"Test\", \"price\": 100 }"; + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(productJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new System.Uri("https://api.test/") + }; + + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Products = "products" }); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Application.Products.ProductsService(httpClient, options, logger); + + // Act + var result = await svc.GetProductById(1, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result!.Id); + Assert.Equal("Test", result.Title); + } + + [Fact] + public async Task CreateProduct_ReturnsCreatedProduct_WhenSuccess() + { + // Arrange + var product = new CSharpApp.Core.Dtos.Product { Title = "New", Price = 50 }; + var responseJson = "{ \"id\": 10, \"title\": \"New\", \"price\": 50 }"; + + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent(responseJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new System.Uri("https://api.test/") + }; + + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Products = "products" }); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Application.Products.ProductsService(httpClient, options, logger); + + // Act + var created = await svc.CreateProduct(product, CancellationToken.None); + + // Assert + Assert.NotNull(created); + Assert.Equal(10, created!.Id); + Assert.Equal("New", created.Title); + } +} + +// Helper stub handler +internal class DelegatingHandlerStub : DelegatingHandler +{ + private readonly Func> _responder; + + public DelegatingHandlerStub(Func> responder) + { + _responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _responder.Invoke(request, cancellationToken); + } +} From d178c8811390e2d4982b88f7082859ee9f2064e2 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 14:57:45 +0300 Subject: [PATCH 3/6] - Add categories service, endpoints and unit tests --- src/CSharpApp.Api/Program.cs | 35 +++++++ .../Categories/CategoriesService.cs | 99 +++++++++++++++++++ .../Products/ProductsService.cs | 2 +- src/CSharpApp.Core/CSharpApp.Core.csproj | 1 - .../Interfaces/ICategoriesService.cs | 8 ++ .../Configuration/DefaultConfiguration.cs | 1 + .../Configuration/HttpConfiguration.cs | 22 +++++ .../CSharpApp.Tests/CategoriesServiceTests.cs | 81 +++++++++++++++ 8 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/CSharpApp.Application/Categories/CategoriesService.cs create mode 100644 src/CSharpApp.Core/Interfaces/ICategoriesService.cs create mode 100644 src/test/CSharpApp.Tests/CategoriesServiceTests.cs diff --git a/src/CSharpApp.Api/Program.cs b/src/CSharpApp.Api/Program.cs index 3bc1da7d..2d13953d 100644 --- a/src/CSharpApp.Api/Program.cs +++ b/src/CSharpApp.Api/Program.cs @@ -58,4 +58,39 @@ .WithName("CreateProduct") .HasApiVersion(1.0); +versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/categories", async (ICategoriesService categoriesService) => + { + var categories = await categoriesService.GetCategories(); + return Results.Ok(categories); + }) + .WithName("GetCategories") + .HasApiVersion(1.0); + +versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/categories/{id}", async (ICategoriesService categoriesService, int id, CancellationToken ct) => + { + var category = await categoriesService.GetCategoryById(id, ct); + return category is null ? Results.NotFound() : Results.Ok(category); + }) + .WithName("GetCategoryById") + .HasApiVersion(1.0); + +versionedEndpointRouteBuilder.MapPost("api/v{version:apiVersion}/categories", async (ICategoriesService categoriesService, Category category, HttpContext http, CancellationToken ct) => + { + if (category == null) + return Results.BadRequest(); + + // Basic validation + if (string.IsNullOrWhiteSpace(category.Name)) + return Results.BadRequest("Invalid category payload"); + + var created = await categoriesService.CreateCategory(category, ct); + if (created == null) + return Results.StatusCode(StatusCodes.Status502BadGateway); + + var location = $"/api/v1/categories/{created.Id}"; + return Results.Created(location, created); + }) + .WithName("CreateCategory") + .HasApiVersion(1.0); + app.Run(); \ No newline at end of file diff --git a/src/CSharpApp.Application/Categories/CategoriesService.cs b/src/CSharpApp.Application/Categories/CategoriesService.cs new file mode 100644 index 00000000..f76a7aeb --- /dev/null +++ b/src/CSharpApp.Application/Categories/CategoriesService.cs @@ -0,0 +1,99 @@ +namespace CSharpApp.Application.Categories; + +public class CategoriesService : ICategoriesService +{ + private readonly HttpClient _httpClient; + private readonly RestApiSettings _restApiSettings; + private readonly ILogger _logger; + + public CategoriesService(HttpClient httpClient, IOptions restApiSettings, + ILogger logger) + { + _httpClient = httpClient; + _restApiSettings = restApiSettings.Value; + _logger = logger; + } + + public async Task> GetCategories(CancellationToken cancellationToken = default) + { + try + { + var path = string.IsNullOrWhiteSpace(_restApiSettings.Categories) ? string.Empty : _restApiSettings.Categories; + + var response = await _httpClient.GetAsync(path, cancellationToken); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var res = JsonSerializer.Deserialize>(content, options) ?? new List(); + + return res.AsReadOnly(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching categories from remote API"); + throw; + } + } + + public async Task GetCategoryById(int id, CancellationToken cancellationToken = default) + { + try + { + var path = string.IsNullOrWhiteSpace(_restApiSettings.Categories) ? $"{id}" : $"{_restApiSettings.Categories}/{id}"; + + var response = await _httpClient.GetAsync(path, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var category = JsonSerializer.Deserialize(content, options); + + return category; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching category {CategoryId} from remote API", id); + throw; + } + } + + public async Task CreateCategory(Category category, CancellationToken cancellationToken = default) + { + try + { + if (category is null) + throw new ArgumentNullException(nameof(category)); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var json = JsonSerializer.Serialize(category, options); + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + var path = string.IsNullOrWhiteSpace(_restApiSettings.Categories) ? string.Empty : _restApiSettings.Categories; + + var response = await _httpClient.PostAsync(path, content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to create category. StatusCode: {StatusCode}", response.StatusCode); + return null; + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var created = JsonSerializer.Deserialize(responseContent, options); + + return created ?? category; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating category"); + throw; + } + } +} diff --git a/src/CSharpApp.Application/Products/ProductsService.cs b/src/CSharpApp.Application/Products/ProductsService.cs index 57a8e9de..e33fc444 100644 --- a/src/CSharpApp.Application/Products/ProductsService.cs +++ b/src/CSharpApp.Application/Products/ProductsService.cs @@ -102,4 +102,4 @@ public async Task> GetProducts() throw; } } -} +} \ No newline at end of file diff --git a/src/CSharpApp.Core/CSharpApp.Core.csproj b/src/CSharpApp.Core/CSharpApp.Core.csproj index f362f27e..916e1405 100644 --- a/src/CSharpApp.Core/CSharpApp.Core.csproj +++ b/src/CSharpApp.Core/CSharpApp.Core.csproj @@ -8,7 +8,6 @@ - diff --git a/src/CSharpApp.Core/Interfaces/ICategoriesService.cs b/src/CSharpApp.Core/Interfaces/ICategoriesService.cs new file mode 100644 index 00000000..d661c7b2 --- /dev/null +++ b/src/CSharpApp.Core/Interfaces/ICategoriesService.cs @@ -0,0 +1,8 @@ +namespace CSharpApp.Core.Interfaces; + +public interface ICategoriesService +{ + Task> GetCategories(CancellationToken cancellationToken = default); + Task GetCategoryById(int id, CancellationToken cancellationToken = default); + Task CreateCategory(Category category, CancellationToken cancellationToken = default); +} diff --git a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs index f4602d97..6d53238f 100755 --- a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs @@ -4,6 +4,7 @@ public static class DefaultConfiguration { public static IServiceCollection AddDefaultConfiguration(this IServiceCollection services, IConfiguration configuration) { + // Bind configuration sections to options without building a temporary provider services.Configure(configuration.GetSection(nameof(RestApiSettings))); services.Configure(configuration.GetSection(nameof(HttpClientSettings))); diff --git a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs index b5fd89d4..c201b6fc 100755 --- a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs @@ -1,4 +1,9 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; +using CSharpApp.Application.Categories; +using CSharpApp.Application.Products; namespace CSharpApp.Infrastructure.Configuration; @@ -24,6 +29,23 @@ public static IServiceCollection AddHttpConfiguration(this IServiceCollection se } }); + // Register typed client for categories + services.AddHttpClient((sp, client) => + { + var rest = sp.GetRequiredService>().Value; + var httpSettings = sp.GetRequiredService>().Value; + + if (!string.IsNullOrWhiteSpace(rest.BaseUrl)) + { + client.BaseAddress = new Uri(rest.BaseUrl); + } + + if (httpSettings.LifeTime > 0) + { + client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); + } + }); + return services; } } diff --git a/src/test/CSharpApp.Tests/CategoriesServiceTests.cs b/src/test/CSharpApp.Tests/CategoriesServiceTests.cs new file mode 100644 index 00000000..e271bf29 --- /dev/null +++ b/src/test/CSharpApp.Tests/CategoriesServiceTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CSharpApp.Tests; + +public class CategoriesServiceTests +{ + [Fact] + public async Task GetCategoryById_ReturnsCategory_WhenFound() + { + // Arrange + var categoryJson = "{ \"id\": 2, \"name\": \"Electronics\", \"image\": \"/img.png\" }"; + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(categoryJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.test/") + }; + + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Categories = "categories" }); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Application.Categories.CategoriesService(httpClient, options, logger); + + // Act + var result = await svc.GetCategoryById(2, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result!.Id); + Assert.Equal("Electronics", result.Name); + } + + [Fact] + public async Task CreateCategory_ReturnsCreatedCategory_WhenSuccess() + { + // Arrange + var category = new CSharpApp.Core.Dtos.Category { Name = "Books", Image = "/books.png" }; + var responseJson = "{ \"id\": 20, \"name\": \"Books\", \"image\": \"/books.png\" }"; + + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent(responseJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.test/") + }; + + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Categories = "categories" }); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Application.Categories.CategoriesService(httpClient, options, logger); + + // Act + var created = await svc.CreateCategory(category, CancellationToken.None); + + // Assert + Assert.NotNull(created); + Assert.Equal(20, created!.Id); + Assert.Equal("Books", created.Name); + } +} From 6b4ed2e15f28e403455a7b1221b03790ac4a2c77 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 15:46:44 +0300 Subject: [PATCH 4/6] Add JWT token support for third-party API - Add TokenService, AuthenticationDelegatingHandler, DI wiring and memory cache - Attach Bearer token to typed HttpClients and add unit tests (token + handler) --- src/CSharpApp.Core/CSharpApp.Core.csproj | 1 - src/CSharpApp.Core/Dtos/TokenResponse.cs | 8 ++ .../Interfaces/ITokenService.cs | 6 + .../AuthenticationDelegatingHandler.cs | 57 +++++++++ .../Authentication/TokenService.cs | 76 ++++++++++++ .../CSharpApp.Infrastructure.csproj | 3 + .../Configuration/HttpConfiguration.cs | 13 +- .../AuthenticationDelegatingHandlerTests.cs | 112 ++++++++++++++++++ .../CSharpApp.Tests/CSharpApp.Tests.csproj | 1 + src/test/CSharpApp.Tests/TokenServiceTests.cs | 68 +++++++++++ 10 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 src/CSharpApp.Core/Dtos/TokenResponse.cs create mode 100644 src/CSharpApp.Core/Interfaces/ITokenService.cs create mode 100644 src/CSharpApp.Infrastructure/Authentication/AuthenticationDelegatingHandler.cs create mode 100644 src/CSharpApp.Infrastructure/Authentication/TokenService.cs create mode 100644 src/test/CSharpApp.Tests/AuthenticationDelegatingHandlerTests.cs create mode 100644 src/test/CSharpApp.Tests/TokenServiceTests.cs diff --git a/src/CSharpApp.Core/CSharpApp.Core.csproj b/src/CSharpApp.Core/CSharpApp.Core.csproj index 916e1405..b37ec902 100644 --- a/src/CSharpApp.Core/CSharpApp.Core.csproj +++ b/src/CSharpApp.Core/CSharpApp.Core.csproj @@ -7,7 +7,6 @@ - diff --git a/src/CSharpApp.Core/Dtos/TokenResponse.cs b/src/CSharpApp.Core/Dtos/TokenResponse.cs new file mode 100644 index 00000000..7e2431f0 --- /dev/null +++ b/src/CSharpApp.Core/Dtos/TokenResponse.cs @@ -0,0 +1,8 @@ +namespace CSharpApp.Core.Dtos; + +public sealed class TokenResponse +{ + public string? access_token { get; set; } + public int? expires_in { get; set; } + public string? token_type { get; set; } +} diff --git a/src/CSharpApp.Core/Interfaces/ITokenService.cs b/src/CSharpApp.Core/Interfaces/ITokenService.cs new file mode 100644 index 00000000..d777c69a --- /dev/null +++ b/src/CSharpApp.Core/Interfaces/ITokenService.cs @@ -0,0 +1,6 @@ +namespace CSharpApp.Core.Interfaces; + +public interface ITokenService +{ + Task GetTokenAsync(CancellationToken cancellationToken = default); +} diff --git a/src/CSharpApp.Infrastructure/Authentication/AuthenticationDelegatingHandler.cs b/src/CSharpApp.Infrastructure/Authentication/AuthenticationDelegatingHandler.cs new file mode 100644 index 00000000..05fa5c0e --- /dev/null +++ b/src/CSharpApp.Infrastructure/Authentication/AuthenticationDelegatingHandler.cs @@ -0,0 +1,57 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using CSharpApp.Core.Interfaces; + +namespace CSharpApp.Infrastructure.Authentication; + +public class AuthenticationDelegatingHandler : DelegatingHandler +{ + private readonly ITokenService _tokenService; + private readonly ILogger _logger; + + public AuthenticationDelegatingHandler(ITokenService tokenService, ILogger logger) + { + _tokenService = tokenService; + _logger = logger; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string token; + try + { + token = await _tokenService.GetTokenAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire token"); + throw; + } + + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await base.SendAsync(request, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + // try refresh once + _logger.LogInformation("Received 401, attempting token refresh and retry"); + try + { + token = await _tokenService.GetTokenAsync(cancellationToken); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + response = await base.SendAsync(request, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Token refresh failed"); + throw; + } + } + + return response; + } +} diff --git a/src/CSharpApp.Infrastructure/Authentication/TokenService.cs b/src/CSharpApp.Infrastructure/Authentication/TokenService.cs new file mode 100644 index 00000000..8a40f7e8 --- /dev/null +++ b/src/CSharpApp.Infrastructure/Authentication/TokenService.cs @@ -0,0 +1,76 @@ +using System; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using CSharpApp.Core.Dtos; +using CSharpApp.Core.Interfaces; + +namespace CSharpApp.Infrastructure.Authentication; + +public class TokenService : ITokenService +{ + private readonly HttpClient _httpClient; + private readonly RestApiSettings _restApiSettings; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + private const string CacheKey = "ThirdPartyAccessToken"; + + public TokenService(HttpClient httpClient, IOptions restApiSettings, + IMemoryCache cache, ILogger logger) + { + _httpClient = httpClient; + _restApiSettings = restApiSettings.Value; + _cache = cache; + _logger = logger; + } + + public async Task GetTokenAsync(CancellationToken cancellationToken = default) + { + if (_cache.TryGetValue(CacheKey, out var token) && !string.IsNullOrWhiteSpace(token)) + { + return token; + } + + // Acquire token + if (string.IsNullOrWhiteSpace(_restApiSettings.Auth) || string.IsNullOrWhiteSpace(_restApiSettings.Username) || string.IsNullOrWhiteSpace(_restApiSettings.Password)) + { + _logger.LogError("Auth settings are not configured properly"); + throw new InvalidOperationException("Auth settings are not configured"); + } + + var payload = new { username = _restApiSettings.Username, password = _restApiSettings.Password }; + var json = JsonSerializer.Serialize(payload); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(_restApiSettings.Auth, content, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to acquire token from auth endpoint. StatusCode: {StatusCode}", response.StatusCode); + throw new InvalidOperationException("Failed to acquire token from auth endpoint"); + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (tokenResponse == null || string.IsNullOrWhiteSpace(tokenResponse.access_token)) + { + _logger.LogError("Auth endpoint returned invalid token response: {Response}", responseContent); + throw new InvalidOperationException("Invalid token response from auth endpoint"); + } + + var expiresIn = tokenResponse.expires_in ?? 3600; + var cacheEntryOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Math.Max(60, expiresIn - 60)) + }; + + _cache.Set(CacheKey, tokenResponse.access_token, cacheEntryOptions); + + return tokenResponse.access_token; + } +} diff --git a/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj b/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj index be99053f..f8207d25 100644 --- a/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj +++ b/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj @@ -12,6 +12,9 @@ + + + diff --git a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs index c201b6fc..84450d37 100755 --- a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs @@ -4,6 +4,8 @@ using Microsoft.Extensions.Options; using CSharpApp.Application.Categories; using CSharpApp.Application.Products; +using CSharpApp.Infrastructure.Authentication; +using CSharpApp.Core.Interfaces; namespace CSharpApp.Infrastructure.Configuration; @@ -11,6 +13,11 @@ public static class HttpConfiguration { public static IServiceCollection AddHttpConfiguration(this IServiceCollection services, IConfiguration configuration) { + // Register token service, memory cache and authentication handler + services.AddMemoryCache(); + services.AddTransient(); + services.AddTransient(); + // Register a typed HttpClient for ProductsService using IHttpClientFactory services.AddHttpClient((sp, client) => { @@ -27,7 +34,8 @@ public static IServiceCollection AddHttpConfiguration(this IServiceCollection se { client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); } - }); + }) + .AddHttpMessageHandler(); // Register typed client for categories services.AddHttpClient((sp, client) => @@ -44,7 +52,8 @@ public static IServiceCollection AddHttpConfiguration(this IServiceCollection se { client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); } - }); + }) + .AddHttpMessageHandler(); return services; } diff --git a/src/test/CSharpApp.Tests/AuthenticationDelegatingHandlerTests.cs b/src/test/CSharpApp.Tests/AuthenticationDelegatingHandlerTests.cs new file mode 100644 index 00000000..4bc876f3 --- /dev/null +++ b/src/test/CSharpApp.Tests/AuthenticationDelegatingHandlerTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using System.Collections.Generic; +using Xunit; + +namespace CSharpApp.Tests; + +public class AuthenticationDelegatingHandlerTests +{ + [Fact] + public async Task AddsAuthorizationHeader_WhenTokenAvailable() + { + // Arrange + var fakeTokenService = new FakeTokenService(() => Task.FromResult("token1")); + + var innerHandler = new DelegatingHandlerStub((request, ct) => + { + Assert.True(request.Headers.Authorization != null); + Assert.Equal("Bearer", request.Headers.Authorization.Scheme); + Assert.Equal("token1", request.Headers.Authorization.Parameter); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var authHandler = new CSharpApp.Infrastructure.Authentication.AuthenticationDelegatingHandler(fakeTokenService, NullLogger.Instance) + { + InnerHandler = innerHandler + }; + + var invoker = new HttpMessageInvoker(authHandler); + + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.test/"); + + // Act + var response = await invoker.SendAsync(request, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task RetriesOn401_RefreshesToken() + { + // Arrange + var tokens = new Queue(new[] { "token1", "token2" }); + var fakeTokenService = new FakeTokenService(() => Task.FromResult(tokens.Dequeue())); + + var call = 0; + var innerHandler = new DelegatingHandlerStub((request, ct) => + { + call++; + if (call == 1) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + Assert.Equal("token2", request.Headers.Authorization.Parameter); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var authHandler = new CSharpApp.Infrastructure.Authentication.AuthenticationDelegatingHandler(fakeTokenService, NullLogger.Instance) + { + InnerHandler = innerHandler + }; + + var invoker = new HttpMessageInvoker(authHandler); + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.test/"); + + // Act + var response = await invoker.SendAsync(request, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, call); + } + + [Fact] + public async Task PropagatesException_WhenTokenServiceFails() + { + // Arrange + var fakeTokenService = new FakeTokenService(() => throw new InvalidOperationException("fail")); + + var innerHandler = new DelegatingHandlerStub((request, ct) => + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var authHandler = new CSharpApp.Infrastructure.Authentication.AuthenticationDelegatingHandler(fakeTokenService, NullLogger.Instance) + { + InnerHandler = innerHandler + }; + + var invoker = new HttpMessageInvoker(authHandler); + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.test/"); + + // Act & Assert + await Assert.ThrowsAsync(() => invoker.SendAsync(request, CancellationToken.None)); + } +} + +internal class FakeTokenService : CSharpApp.Core.Interfaces.ITokenService +{ + private readonly Func> _responder; + + public FakeTokenService(Func> responder) + { + _responder = responder; + } + + public Task GetTokenAsync(CancellationToken cancellationToken = default) => _responder(); +} diff --git a/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj index faa214f1..52233992 100644 --- a/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj +++ b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/src/test/CSharpApp.Tests/TokenServiceTests.cs b/src/test/CSharpApp.Tests/TokenServiceTests.cs new file mode 100644 index 00000000..0eabd7c6 --- /dev/null +++ b/src/test/CSharpApp.Tests/TokenServiceTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CSharpApp.Tests; + +public class TokenServiceTests +{ + [Fact] + public async Task AcquireToken_ReturnsTokenAndCaches_WhenAuthSuccess() + { + // Arrange + var tokenJson = "{ \"access_token\": \"abc123\", \"expires_in\": 3600 }"; + var callCount = 0; + var handler = new DelegatingHandlerStub((request, ct) => + { + callCount++; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(tokenJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://auth.test/") }; + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Auth = "/auth/login", Username = "u", Password = "p" }); + var cache = new MemoryCache(new MemoryCacheOptions()); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Infrastructure.Authentication.TokenService(httpClient, options, cache, logger); + + // Act + var token1 = await svc.GetTokenAsync(CancellationToken.None); + var token2 = await svc.GetTokenAsync(CancellationToken.None); + + // Assert + Assert.Equal("abc123", token1); + Assert.Equal("abc123", token2); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AcquireToken_Throws_OnNonSuccessStatusCode() + { + // Arrange + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.BadRequest); + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://auth.test/") }; + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Auth = "/auth/login", Username = "u", Password = "p" }); + var cache = new MemoryCache(new MemoryCacheOptions()); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Infrastructure.Authentication.TokenService(httpClient, options, cache, logger); + + // Act & Assert + await Assert.ThrowsAsync(() => svc.GetTokenAsync(CancellationToken.None)); + } +} From e85d44d473f8469cd925e934f1244b13d84bd672 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 16:40:34 +0300 Subject: [PATCH 5/6] Performance and logging Middleware : - add PerformanceLoggingMiddleware Measure incoming request duration and log warnings when requests exceed configured threshold; support excluded paths. - add HttpClientMetricsHandler Measure outgoing HttpClient call durations and emit structured metrics/warnings based on performance settings. - register metrics handler and middleware Register HttpClientMetricsHandler and AuthenticationDelegatingHandler with typed HttpClients and add PerformanceLoggingMiddleware to pipeline. - add unit tests for performance logging and metrics Add tests for slow incoming requests and slow outgoing HttpClient calls; add TestLogger helpers and console output for visibility. --- .../PerformanceLoggingMiddleware.cs | 56 +++++++++++ src/CSharpApp.Api/Program.cs | 3 + src/CSharpApp.Core/CSharpApp.Core.csproj | 4 - .../Settings/PerformanceSettings.cs | 10 ++ .../Configuration/HttpConfiguration.cs | 8 +- .../Http/HttpClientMetricsHandler.cs | 39 ++++++++ .../CSharpApp.Tests/CSharpApp.Tests.csproj | 5 + .../Common/TestLoggingHelpers.cs | 46 +++++++++ .../Middlewares/PerformanceMiddlewareTests.cs | 99 +++++++++++++++++++ 9 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 src/CSharpApp.Api/Middleware/PerformanceLoggingMiddleware.cs create mode 100644 src/CSharpApp.Core/Settings/PerformanceSettings.cs create mode 100644 src/CSharpApp.Infrastructure/Http/HttpClientMetricsHandler.cs create mode 100644 src/test/CSharpApp.Tests/Common/TestLoggingHelpers.cs create mode 100644 src/test/CSharpApp.Tests/Middlewares/PerformanceMiddlewareTests.cs diff --git a/src/CSharpApp.Api/Middleware/PerformanceLoggingMiddleware.cs b/src/CSharpApp.Api/Middleware/PerformanceLoggingMiddleware.cs new file mode 100644 index 00000000..ac1112b4 --- /dev/null +++ b/src/CSharpApp.Api/Middleware/PerformanceLoggingMiddleware.cs @@ -0,0 +1,56 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using CSharpApp.Core.Settings; + +namespace CSharpApp.Api.Middleware; + +public class PerformanceLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly PerformanceSettings _settings; + + public PerformanceLoggingMiddleware(RequestDelegate next, ILogger logger, IOptions options) + { + _next = next; + _logger = logger; + _settings = options.Value; + } + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value ?? string.Empty; + if (_settings.ExcludePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + { + await _next(context); + return; + } + + var sw = Stopwatch.StartNew(); + var traceId = Activity.Current?.Id ?? context.TraceIdentifier; + + try + { + await _next(context); + sw.Stop(); + + var status = context.Response?.StatusCode ?? 0; + var ms = sw.ElapsedMilliseconds; + if (ms >= _settings.WarningThresholdMs) + { + _logger.LogWarning("SLOW REQUEST {Method} {Path} responded {StatusCode} in {Duration}ms | TraceId={TraceId}", context.Request.Method, path, status, ms, traceId); + } + else + { + _logger.LogInformation("HTTP {Method} {Path} responded {StatusCode} in {Duration}ms | TraceId={TraceId}", context.Request.Method, path, status, ms, traceId); + } + } + catch (Exception ex) + { + sw.Stop(); + _logger.LogError(ex, "ERROR REQUEST {Method} {Path} in {Duration}ms | TraceId={TraceId}", context.Request.Method, path, sw.ElapsedMilliseconds, traceId); + throw; + } + } +} diff --git a/src/CSharpApp.Api/Program.cs b/src/CSharpApp.Api/Program.cs index 2d13953d..7d1ffca1 100644 --- a/src/CSharpApp.Api/Program.cs +++ b/src/CSharpApp.Api/Program.cs @@ -21,6 +21,9 @@ //app.UseHttpsRedirection(); +// Performance logging middleware +app.UseMiddleware(); + var versionedEndpointRouteBuilder = app.NewVersionedApi(); versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/products", async (IProductsService productsService) => diff --git a/src/CSharpApp.Core/CSharpApp.Core.csproj b/src/CSharpApp.Core/CSharpApp.Core.csproj index b37ec902..17b910f6 100644 --- a/src/CSharpApp.Core/CSharpApp.Core.csproj +++ b/src/CSharpApp.Core/CSharpApp.Core.csproj @@ -6,8 +6,4 @@ enable - - - - diff --git a/src/CSharpApp.Core/Settings/PerformanceSettings.cs b/src/CSharpApp.Core/Settings/PerformanceSettings.cs new file mode 100644 index 00000000..23f262d4 --- /dev/null +++ b/src/CSharpApp.Core/Settings/PerformanceSettings.cs @@ -0,0 +1,10 @@ +namespace CSharpApp.Core.Settings; + +public sealed class PerformanceSettings +{ + // threshold in milliseconds to consider a request slow + public int WarningThresholdMs { get; set; } = 500; + + // paths to exclude from performance logging + public List ExcludePaths { get; set; } = new List { "/health", "/swagger", "/openapi", "/docs" }; +} diff --git a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs index 84450d37..2ba7dc8c 100755 --- a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs @@ -5,6 +5,7 @@ using CSharpApp.Application.Categories; using CSharpApp.Application.Products; using CSharpApp.Infrastructure.Authentication; +using CSharpApp.Infrastructure.Http; using CSharpApp.Core.Interfaces; namespace CSharpApp.Infrastructure.Configuration; @@ -17,6 +18,7 @@ public static IServiceCollection AddHttpConfiguration(this IServiceCollection se services.AddMemoryCache(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Register a typed HttpClient for ProductsService using IHttpClientFactory services.AddHttpClient((sp, client) => @@ -35,7 +37,8 @@ public static IServiceCollection AddHttpConfiguration(this IServiceCollection se client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); } }) - .AddHttpMessageHandler(); + .AddHttpMessageHandler() + .AddHttpMessageHandler(); // Register typed client for categories services.AddHttpClient((sp, client) => @@ -53,7 +56,8 @@ public static IServiceCollection AddHttpConfiguration(this IServiceCollection se client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); } }) - .AddHttpMessageHandler(); + .AddHttpMessageHandler() + .AddHttpMessageHandler(); return services; } diff --git a/src/CSharpApp.Infrastructure/Http/HttpClientMetricsHandler.cs b/src/CSharpApp.Infrastructure/Http/HttpClientMetricsHandler.cs new file mode 100644 index 00000000..cb52c07a --- /dev/null +++ b/src/CSharpApp.Infrastructure/Http/HttpClientMetricsHandler.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using CSharpApp.Core.Settings; + +namespace CSharpApp.Infrastructure.Http; + +public class HttpClientMetricsHandler : DelegatingHandler +{ + private readonly ILogger _logger; + private readonly PerformanceSettings _settings; + + public HttpClientMetricsHandler(ILogger logger, IOptions options) + { + _logger = logger; + _settings = options.Value; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var sw = Stopwatch.StartNew(); + var traceId = Activity.Current?.Id ?? string.Empty; + + var response = await base.SendAsync(request, cancellationToken); + sw.Stop(); + + var ms = sw.ElapsedMilliseconds; + if (ms >= _settings.WarningThresholdMs) + { + _logger.LogWarning("OUTGOING {Method} {Uri} responded {StatusCode} in {Duration}ms | TraceId={TraceId}", request.Method, request.RequestUri, response.StatusCode, ms, traceId); + } + else + { + _logger.LogInformation("OUTGOING {Method} {Uri} responded {StatusCode} in {Duration}ms | TraceId={TraceId}", request.Method, request.RequestUri, response.StatusCode, ms, traceId); + } + + return response; + } +} diff --git a/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj index 52233992..60b8d022 100644 --- a/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj +++ b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj @@ -17,6 +17,7 @@ + @@ -24,4 +25,8 @@ + + + + diff --git a/src/test/CSharpApp.Tests/Common/TestLoggingHelpers.cs b/src/test/CSharpApp.Tests/Common/TestLoggingHelpers.cs new file mode 100644 index 00000000..8a4a5ea1 --- /dev/null +++ b/src/test/CSharpApp.Tests/Common/TestLoggingHelpers.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace CSharpApp.Tests.Common; + +// Simple test logger that captures log entries +internal class TestLogger : ILogger +{ + public System.Collections.Generic.List Entries { get; } = new System.Collections.Generic.List(); + + public IDisposable BeginScope(TState state) => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = formatter(state, exception); + Entries.Add(new LogEntry { LogLevel = logLevel, Message = message, Exception = exception }); + // Also write to console to make logs visible when running tests + try + { + Console.WriteLine($"[{typeof(T).Name}] {logLevel}: {message}"); + if (exception != null) + { + Console.WriteLine(exception.ToString()); + } + } + catch + { + // ignore any console errors during tests + } + } +} + +internal class LogEntry +{ + public LogLevel LogLevel { get; set; } + public string Message { get; set; } = string.Empty; + public Exception? Exception { get; set; } +} + +internal class NullScope : IDisposable +{ + public static NullScope Instance { get; } = new NullScope(); + public void Dispose() { } +} diff --git a/src/test/CSharpApp.Tests/Middlewares/PerformanceMiddlewareTests.cs b/src/test/CSharpApp.Tests/Middlewares/PerformanceMiddlewareTests.cs new file mode 100644 index 00000000..28430c36 --- /dev/null +++ b/src/test/CSharpApp.Tests/Middlewares/PerformanceMiddlewareTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Xunit; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Microsoft.AspNetCore.Http; +using CSharpApp.Api.Middleware; +using CSharpApp.Tests.Common; + +namespace CSharpApp.Tests.Middlewares; + +public class PerformanceMiddlewareTests +{ + [Fact] + public async Task Middleware_LogsWarning_ForSlowRequest() + { + // Arrange + var settings = Options.Create(new CSharpApp.Core.Settings.PerformanceSettings { WarningThresholdMs = 10, ExcludePaths = new System.Collections.Generic.List() }); + var logger = new TestLogger(); + + RequestDelegate next = async ctx => + { + // simulate work + await Task.Delay(50); + ctx.Response.StatusCode = 200; + }; + + var middleware = new CSharpApp.Api.Middleware.PerformanceLoggingMiddleware(next, logger, settings); + var context = new DefaultHttpContext(); + context.Request.Path = "/slow"; + + // Act + await middleware.InvokeAsync(context); + + // Assert - expecting at least one warning log + Assert.Contains(logger.Entries, e => e.LogLevel == LogLevel.Warning && e.Message.Contains("SLOW REQUEST")); + } + + [Fact] + public async Task Middleware_DoesNotLog_WhenPathExcluded() + { + // Arrange + var settings = Options.Create(new CSharpApp.Core.Settings.PerformanceSettings { WarningThresholdMs = 1, ExcludePaths = new System.Collections.Generic.List { "/health" } }); + var logger = new TestLogger(); + + RequestDelegate next = async ctx => + { + await Task.Delay(20); + ctx.Response.StatusCode = 200; + }; + + var middleware = new CSharpApp.Api.Middleware.PerformanceLoggingMiddleware(next, logger, settings); + var context = new DefaultHttpContext(); + context.Request.Path = "/health/check"; + + // Act + await middleware.InvokeAsync(context); + + // Assert - no logs recorded + Assert.Empty(logger.Entries); + } + + [Fact] + public async Task HttpClientMetricsHandler_LogsWarning_ForSlowOutgoing() + { + // Arrange + var settings = Options.Create(new CSharpApp.Core.Settings.PerformanceSettings { WarningThresholdMs = 10 }); + var logger = new TestLogger(); + + var inner = new DelegatingHandlerStub((req, ct) => + { + // simulate slow outgoing call + Thread.Sleep(50); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var handler = new CSharpApp.Infrastructure.Http.HttpClientMetricsHandler(logger, settings) + { + InnerHandler = inner + }; + + var invoker = new HttpMessageInvoker(handler); + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.test/"); + + // Act + var response = await invoker.SendAsync(request, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(logger.Entries, e => e.LogLevel == LogLevel.Warning && e.Message.Contains("OUTGOING")); + } +} + + From 07104f17c4271d16f10cb14783385107c86a12ca Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Mon, 6 Jul 2026 23:57:53 +0300 Subject: [PATCH 6/6] Mainly house keeping , moving code around - move validators/mappers to Mapping/Validation - add upstream category mapper - DI helpers, injectable API validators - more unit tests. --- src/CSharpApp.Api/CSharpApp.Api.csproj | 1 + src/CSharpApp.Api/GlobalUsings.cs | 1 + src/CSharpApp.Api/Program.cs | 50 ++++++++++---- .../ServiceCollectionExtensions.cs | 16 +++++ .../Validation/CreateCategoryValidator.cs | 26 ++++++++ .../Validation/CreateProductValidator.cs | 32 +++++++++ .../CSharpApp.Application.csproj | 1 + .../Categories/IProductValidator.cs | 12 ++++ .../Categories/IUpstreamCategoryMapper.cs | 6 ++ .../Categories/Mapping/CategoryMapper.cs | 13 ++++ .../Categories/Mapping/ICategoryMapper.cs | 6 ++ .../Categories/UpstreamCreateCategory.cs | 13 ++++ .../Validation/CategoryValidator.cs | 24 +++++++ .../Validation/ICategoryValidator.cs | 12 ++++ src/CSharpApp.Application/GlobalUsings.cs | 1 + .../Products/Mapping/IProductMapper.cs | 7 ++ .../Mapping/IUpstreamProductMapper.cs | 6 ++ .../Products/Mapping/ProductMapper.cs | 37 +++++++++++ .../Products/ProductsService.cs | 34 +++++++++- .../Products/UpstreamCreateProduct.cs | 22 +++++++ .../Products/Validation/IProductValidator.cs | 12 ++++ .../Validation/ProductBusinessValidator.cs | 36 ++++++++++ .../Products/Validation/ProductValidator.cs | 36 ++++++++++ .../ServiceCollectionExtensions.cs | 22 +++++++ src/CSharpApp.Core/CSharpApp.Core.csproj | 4 ++ src/CSharpApp.Core/GlobalUsings.cs | 4 +- .../Interfaces/ICategoriesService.cs | 2 + .../Interfaces/IProductsService.cs | 2 +- .../Adapters/UpstreamCategoryMapper.cs | 15 +++++ .../Adapters/UpstreamProductMapper.cs | 18 +++++ .../CSharpApp.Infrastructure.csproj | 1 + .../Configuration/DefaultConfiguration.cs | 6 ++ src/CSharpApp.Infrastructure/GlobalUsings.cs | 2 + .../ServiceCollectionExtensions.cs | 19 ++++++ src/CSharpApp.Models/CSharpApp.Models.csproj | 9 +++ .../Dtos/Category.cs} | 4 +- .../Dtos/CreateCategoryRequestDto.cs | 12 ++++ .../Dtos/CreateProductRequestDto.cs | 22 +++++++ .../Dtos/Product.cs} | 9 ++- src/CSharpApp.sln | 12 +++- .../CSharpApp.Tests/CategoryMapperTests.cs | 22 +++++++ .../CSharpApp.Tests/CategoryValidatorTests.cs | 50 ++++++++++++++ .../CreateCategoryValidatorTests.cs | 50 ++++++++++++++ .../CSharpApp.Tests/ProductMapperTests.cs | 32 +++++++++ .../CSharpApp.Tests/ProductValidatorTests.cs | 65 +++++++++++++++++++ .../CSharpApp.Tests/ProductsServiceTests.cs | 2 +- 46 files changed, 765 insertions(+), 23 deletions(-) create mode 100644 src/CSharpApp.Api/ServiceCollectionExtensions.cs create mode 100644 src/CSharpApp.Api/Validation/CreateCategoryValidator.cs create mode 100644 src/CSharpApp.Api/Validation/CreateProductValidator.cs create mode 100644 src/CSharpApp.Application/Categories/IProductValidator.cs create mode 100644 src/CSharpApp.Application/Categories/IUpstreamCategoryMapper.cs create mode 100644 src/CSharpApp.Application/Categories/Mapping/CategoryMapper.cs create mode 100644 src/CSharpApp.Application/Categories/Mapping/ICategoryMapper.cs create mode 100644 src/CSharpApp.Application/Categories/UpstreamCreateCategory.cs create mode 100644 src/CSharpApp.Application/Categories/Validation/CategoryValidator.cs create mode 100644 src/CSharpApp.Application/Categories/Validation/ICategoryValidator.cs create mode 100644 src/CSharpApp.Application/Products/Mapping/IProductMapper.cs create mode 100644 src/CSharpApp.Application/Products/Mapping/IUpstreamProductMapper.cs create mode 100644 src/CSharpApp.Application/Products/Mapping/ProductMapper.cs create mode 100644 src/CSharpApp.Application/Products/UpstreamCreateProduct.cs create mode 100644 src/CSharpApp.Application/Products/Validation/IProductValidator.cs create mode 100644 src/CSharpApp.Application/Products/Validation/ProductBusinessValidator.cs create mode 100644 src/CSharpApp.Application/Products/Validation/ProductValidator.cs create mode 100644 src/CSharpApp.Application/ServiceCollectionExtensions.cs create mode 100644 src/CSharpApp.Infrastructure/Adapters/UpstreamCategoryMapper.cs create mode 100644 src/CSharpApp.Infrastructure/Adapters/UpstreamProductMapper.cs create mode 100644 src/CSharpApp.Infrastructure/ServiceCollectionExtensions.cs create mode 100644 src/CSharpApp.Models/CSharpApp.Models.csproj rename src/{CSharpApp.Core/Dtos/CategoryDto.cs => CSharpApp.Models/Dtos/Category.cs} (91%) create mode 100644 src/CSharpApp.Models/Dtos/CreateCategoryRequestDto.cs create mode 100644 src/CSharpApp.Models/Dtos/CreateProductRequestDto.cs rename src/{CSharpApp.Core/Dtos/ProductDto.cs => CSharpApp.Models/Dtos/Product.cs} (77%) create mode 100644 src/test/CSharpApp.Tests/CategoryMapperTests.cs create mode 100644 src/test/CSharpApp.Tests/CategoryValidatorTests.cs create mode 100644 src/test/CSharpApp.Tests/CreateCategoryValidatorTests.cs create mode 100644 src/test/CSharpApp.Tests/ProductMapperTests.cs create mode 100644 src/test/CSharpApp.Tests/ProductValidatorTests.cs diff --git a/src/CSharpApp.Api/CSharpApp.Api.csproj b/src/CSharpApp.Api/CSharpApp.Api.csproj index 91bb934e..73dd458c 100644 --- a/src/CSharpApp.Api/CSharpApp.Api.csproj +++ b/src/CSharpApp.Api/CSharpApp.Api.csproj @@ -19,6 +19,7 @@ + diff --git a/src/CSharpApp.Api/GlobalUsings.cs b/src/CSharpApp.Api/GlobalUsings.cs index 65608b04..fd944f1d 100644 --- a/src/CSharpApp.Api/GlobalUsings.cs +++ b/src/CSharpApp.Api/GlobalUsings.cs @@ -3,5 +3,6 @@ global using CSharpApp.Core.Interfaces; global using CSharpApp.Infrastructure.Configuration; global using Serilog; +// DTOs are provided by CSharpApp.Dtos but keep namespace CSharpApp.Core.Dtos for compatibility global using CSharpApp.Core.Dtos; global using Microsoft.AspNetCore.Http; diff --git a/src/CSharpApp.Api/Program.cs b/src/CSharpApp.Api/Program.cs index 7d1ffca1..392a4f08 100644 --- a/src/CSharpApp.Api/Program.cs +++ b/src/CSharpApp.Api/Program.cs @@ -1,3 +1,9 @@ +using CSharpApp.Core.Dtos; +using CSharpApp.Application; +using CSharpApp.Infrastructure; +using CSharpApp.Api; +using CSharpApp.Api.Validation; + var builder = WebApplication.CreateBuilder(args); var logger = new LoggerConfiguration().ReadFrom.Configuration(builder.Configuration).CreateLogger(); @@ -11,6 +17,11 @@ builder.Services.AddProblemDetails(); builder.Services.AddApiVersioning(); +// Register application and infrastructure services via extension helpers +builder.Services.AddApiServices(); +builder.Services.AddApplicationServices(); +builder.Services.AddInfrastructureServices(builder.Configuration); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -26,9 +37,9 @@ var versionedEndpointRouteBuilder = app.NewVersionedApi(); -versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/products", async (IProductsService productsService) => +versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/products", async (IProductsService productsService, int? offset, int? limit) => { - var products = await productsService.GetProducts(); + var products = await productsService.GetProducts(offset, limit); return Results.Ok(products); }) .WithName("GetProducts") @@ -42,14 +53,22 @@ .WithName("GetProductById") .HasApiVersion(1.0); -versionedEndpointRouteBuilder.MapPost("api/v{version:apiVersion}/products", async (IProductsService productsService, Product product, HttpContext http, CancellationToken ct) => +versionedEndpointRouteBuilder.MapPost("api/v{version:apiVersion}/products", async (IProductsService productsService, CSharpApp.Api.Validation.ICreateProductValidator apiValidator, CSharpApp.Application.Products.IProductValidator validator, CSharpApp.Application.Products.IProductMapper mapper, CreateProductRequestDto request, HttpContext http, CancellationToken ct) => { - if (product == null) + if (request == null) return Results.BadRequest(); - // Basic validation - if (string.IsNullOrWhiteSpace(product.Title) || (product.Price.HasValue && product.Price <= 0)) - return Results.BadRequest("Invalid product payload"); + // API-level validation (shape/format) + if (!apiValidator.Validate(request, out var apiErrors)) + return Results.BadRequest(new { errors = apiErrors }); + + // Application/business validation + var validation = validator.ValidateForCreate(request); + if (!validation.IsValid) + return Results.BadRequest(new { errors = validation.Errors }); + + // Map request to domain product using mapper + var product = mapper.MapFromCreateRequest(request); var created = await productsService.CreateProduct(product, ct); if (created == null) @@ -77,14 +96,21 @@ .WithName("GetCategoryById") .HasApiVersion(1.0); -versionedEndpointRouteBuilder.MapPost("api/v{version:apiVersion}/categories", async (ICategoriesService categoriesService, Category category, HttpContext http, CancellationToken ct) => +versionedEndpointRouteBuilder.MapPost("api/v{version:apiVersion}/categories", async (ICategoriesService categoriesService, CSharpApp.Api.Validation.ICreateCategoryValidator apiCategoryValidator, CSharpApp.Application.Categories.Validation.ICategoryValidator categoryValidator, CSharpApp.Application.Categories.Mapping.ICategoryMapper mapper, CSharpApp.Core.Dtos.CreateCategoryRequestDto request, CancellationToken ct) => { - if (category == null) + if (request == null) return Results.BadRequest(); - // Basic validation - if (string.IsNullOrWhiteSpace(category.Name)) - return Results.BadRequest("Invalid category payload"); + // API-level validation + if (!apiCategoryValidator.Validate(request, out var apiErrors)) + return Results.BadRequest(new { errors = apiErrors }); + + // Business validation + var businessValidation = categoryValidator.ValidateForCreate(request); + if (!businessValidation.IsValid) + return Results.BadRequest(new { errors = businessValidation.Errors }); + + var category = mapper.MapFromCreateRequest(request); var created = await categoriesService.CreateCategory(category, ct); if (created == null) diff --git a/src/CSharpApp.Api/ServiceCollectionExtensions.cs b/src/CSharpApp.Api/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..ceb29f72 --- /dev/null +++ b/src/CSharpApp.Api/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpApp.Api +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddApiServices(this IServiceCollection services) + { + // API-level validators and request/adapters + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + } +} diff --git a/src/CSharpApp.Api/Validation/CreateCategoryValidator.cs b/src/CSharpApp.Api/Validation/CreateCategoryValidator.cs new file mode 100644 index 00000000..aeba0793 --- /dev/null +++ b/src/CSharpApp.Api/Validation/CreateCategoryValidator.cs @@ -0,0 +1,26 @@ +namespace CSharpApp.Api.Validation; + +using CSharpApp.Core.Dtos; + +public interface ICreateCategoryValidator +{ + bool Validate(CreateCategoryRequestDto request, out System.Collections.Generic.List errors); +} + +public class CreateCategoryValidator : ICreateCategoryValidator +{ + public bool Validate(CreateCategoryRequestDto request, out System.Collections.Generic.List errors) + { + errors = new System.Collections.Generic.List(); + if (request == null) + { + errors.Add("Request cannot be null."); + return false; + } + + if (string.IsNullOrWhiteSpace(request.Name)) + errors.Add("Name is required."); + + return errors.Count == 0; + } +} diff --git a/src/CSharpApp.Api/Validation/CreateProductValidator.cs b/src/CSharpApp.Api/Validation/CreateProductValidator.cs new file mode 100644 index 00000000..e3253816 --- /dev/null +++ b/src/CSharpApp.Api/Validation/CreateProductValidator.cs @@ -0,0 +1,32 @@ +namespace CSharpApp.Api.Validation; + +using CSharpApp.Core.Dtos; + +public interface ICreateProductValidator +{ + bool Validate(CreateProductRequestDto request, out System.Collections.Generic.List errors); +} + +public class CreateProductValidator : ICreateProductValidator +{ + public bool Validate(CreateProductRequestDto request, out System.Collections.Generic.List errors) + { + errors = new System.Collections.Generic.List(); + if (request == null) + { + errors.Add("Request cannot be null."); + return false; + } + + if (string.IsNullOrWhiteSpace(request.Title)) + errors.Add("Title is required."); + + if (request.Price.HasValue && request.Price <= 0) + errors.Add("Price must be greater than zero when provided."); + + if (request.CategoryId.HasValue && request.CategoryId <= 0) + errors.Add("CategoryId, when provided, must be a positive integer."); + + return errors.Count == 0; + } +} diff --git a/src/CSharpApp.Application/CSharpApp.Application.csproj b/src/CSharpApp.Application/CSharpApp.Application.csproj index 4f0ef445..e456eaf0 100644 --- a/src/CSharpApp.Application/CSharpApp.Application.csproj +++ b/src/CSharpApp.Application/CSharpApp.Application.csproj @@ -15,6 +15,7 @@ + diff --git a/src/CSharpApp.Application/Categories/IProductValidator.cs b/src/CSharpApp.Application/Categories/IProductValidator.cs new file mode 100644 index 00000000..6a777ac4 --- /dev/null +++ b/src/CSharpApp.Application/Categories/IProductValidator.cs @@ -0,0 +1,12 @@ +namespace CSharpApp.Application.Categories; + +public interface ICategoryValidator +{ + ValidationResult ValidateForCreate(CSharpApp.Core.Dtos.CreateCategoryRequestDto request); +} + +public sealed class ValidationResult +{ + public bool IsValid { get; set; } + public System.Collections.Generic.List Errors { get; } = new System.Collections.Generic.List(); +} diff --git a/src/CSharpApp.Application/Categories/IUpstreamCategoryMapper.cs b/src/CSharpApp.Application/Categories/IUpstreamCategoryMapper.cs new file mode 100644 index 00000000..5c8bec69 --- /dev/null +++ b/src/CSharpApp.Application/Categories/IUpstreamCategoryMapper.cs @@ -0,0 +1,6 @@ +namespace CSharpApp.Application.Categories; + +public interface IUpstreamCategoryMapper +{ + UpstreamCreateCategory Map(CSharpApp.Core.Dtos.Category category); +} diff --git a/src/CSharpApp.Application/Categories/Mapping/CategoryMapper.cs b/src/CSharpApp.Application/Categories/Mapping/CategoryMapper.cs new file mode 100644 index 00000000..0602ff34 --- /dev/null +++ b/src/CSharpApp.Application/Categories/Mapping/CategoryMapper.cs @@ -0,0 +1,13 @@ +namespace CSharpApp.Application.Categories.Mapping; + +public class CategoryMapper : ICategoryMapper +{ + public CSharpApp.Core.Dtos.Category MapFromCreateRequest(CSharpApp.Core.Dtos.CreateCategoryRequestDto request) + { + return new CSharpApp.Core.Dtos.Category + { + Name = request.Name, + Image = request.Image + }; + } +} diff --git a/src/CSharpApp.Application/Categories/Mapping/ICategoryMapper.cs b/src/CSharpApp.Application/Categories/Mapping/ICategoryMapper.cs new file mode 100644 index 00000000..56d7253e --- /dev/null +++ b/src/CSharpApp.Application/Categories/Mapping/ICategoryMapper.cs @@ -0,0 +1,6 @@ +namespace CSharpApp.Application.Categories.Mapping; + +public interface ICategoryMapper +{ + CSharpApp.Core.Dtos.Category MapFromCreateRequest(CSharpApp.Core.Dtos.CreateCategoryRequestDto request); +} diff --git a/src/CSharpApp.Application/Categories/UpstreamCreateCategory.cs b/src/CSharpApp.Application/Categories/UpstreamCreateCategory.cs new file mode 100644 index 00000000..758340c9 --- /dev/null +++ b/src/CSharpApp.Application/Categories/UpstreamCreateCategory.cs @@ -0,0 +1,13 @@ +namespace CSharpApp.Application.Categories; + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +public sealed class UpstreamCreateCategory +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("image")] + public string? Image { get; set; } +} diff --git a/src/CSharpApp.Application/Categories/Validation/CategoryValidator.cs b/src/CSharpApp.Application/Categories/Validation/CategoryValidator.cs new file mode 100644 index 00000000..97bfdcf2 --- /dev/null +++ b/src/CSharpApp.Application/Categories/Validation/CategoryValidator.cs @@ -0,0 +1,24 @@ +namespace CSharpApp.Application.Categories.Validation; + +public class CategoryValidator : ICategoryValidator +{ + public ValidationResult ValidateForCreate(CSharpApp.Core.Dtos.CreateCategoryRequestDto request) + { + var result = new ValidationResult { IsValid = true }; + + if (request == null) + { + result.IsValid = false; + result.Errors.Add("Request cannot be null."); + return result; + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + result.IsValid = false; + result.Errors.Add("Name is required."); + } + + return result; + } +} diff --git a/src/CSharpApp.Application/Categories/Validation/ICategoryValidator.cs b/src/CSharpApp.Application/Categories/Validation/ICategoryValidator.cs new file mode 100644 index 00000000..ae63b1a9 --- /dev/null +++ b/src/CSharpApp.Application/Categories/Validation/ICategoryValidator.cs @@ -0,0 +1,12 @@ +namespace CSharpApp.Application.Categories.Validation; + +public interface ICategoryValidator +{ + ValidationResult ValidateForCreate(CSharpApp.Core.Dtos.CreateCategoryRequestDto request); +} + +public sealed class ValidationResult +{ + public bool IsValid { get; set; } + public System.Collections.Generic.List Errors { get; } = new System.Collections.Generic.List(); +} diff --git a/src/CSharpApp.Application/GlobalUsings.cs b/src/CSharpApp.Application/GlobalUsings.cs index c434ea5a..68687fff 100644 --- a/src/CSharpApp.Application/GlobalUsings.cs +++ b/src/CSharpApp.Application/GlobalUsings.cs @@ -1,6 +1,7 @@ // Global using directives global using System.Text.Json; +// DTOs are provided by CSharpApp.Dtos but keep namespace CSharpApp.Core.Dtos for compatibility global using CSharpApp.Core.Dtos; global using CSharpApp.Core.Interfaces; global using CSharpApp.Core.Settings; diff --git a/src/CSharpApp.Application/Products/Mapping/IProductMapper.cs b/src/CSharpApp.Application/Products/Mapping/IProductMapper.cs new file mode 100644 index 00000000..14c9a66b --- /dev/null +++ b/src/CSharpApp.Application/Products/Mapping/IProductMapper.cs @@ -0,0 +1,7 @@ +namespace CSharpApp.Application.Products; + +public interface IProductMapper +{ + CSharpApp.Core.Dtos.Product MapFromCreateRequest(CSharpApp.Core.Dtos.CreateProductRequestDto request); + CSharpApp.Application.Products.UpstreamCreateProduct MapToUpstream(CSharpApp.Core.Dtos.Product product); +} diff --git a/src/CSharpApp.Application/Products/Mapping/IUpstreamProductMapper.cs b/src/CSharpApp.Application/Products/Mapping/IUpstreamProductMapper.cs new file mode 100644 index 00000000..8a16ba56 --- /dev/null +++ b/src/CSharpApp.Application/Products/Mapping/IUpstreamProductMapper.cs @@ -0,0 +1,6 @@ +namespace CSharpApp.Application.Products; + +public interface IUpstreamProductMapper +{ + UpstreamCreateProduct Map(CSharpApp.Core.Dtos.Product product); +} diff --git a/src/CSharpApp.Application/Products/Mapping/ProductMapper.cs b/src/CSharpApp.Application/Products/Mapping/ProductMapper.cs new file mode 100644 index 00000000..a235f02a --- /dev/null +++ b/src/CSharpApp.Application/Products/Mapping/ProductMapper.cs @@ -0,0 +1,37 @@ +namespace CSharpApp.Application.Products; + +public class ProductMapper : IProductMapper +{ + public CSharpApp.Core.Dtos.Product MapFromCreateRequest(CSharpApp.Core.Dtos.CreateProductRequestDto request) + { + var product = new CSharpApp.Core.Dtos.Product + { + Title = request.Title, + Price = request.Price, + Description = request.Description, + Category = request.CategoryId.HasValue ? new CSharpApp.Core.Dtos.Category { Id = request.CategoryId } : null + }; + + if (request.Images != null) + { + product.Images.AddRange(request.Images); + // No-op patch: ensure file change is recorded + } + + return product; + } + + public UpstreamCreateProduct MapToUpstream(CSharpApp.Core.Dtos.Product product) + { + // Delegate to infrastructure mapper via IUpstreamProductMapper when available. + // Keep fallback mapping here in case Infrastructure implementation is not registered. + return new UpstreamCreateProduct + { + Title = product.Title, + Price = product.Price, + Description = product.Description, + Images = product.Images != null && product.Images.Count > 0 ? new System.Collections.Generic.List(product.Images) : null, + CategoryId = product.Category?.Id + }; + } +} diff --git a/src/CSharpApp.Application/Products/ProductsService.cs b/src/CSharpApp.Application/Products/ProductsService.cs index e33fc444..8a930ed8 100644 --- a/src/CSharpApp.Application/Products/ProductsService.cs +++ b/src/CSharpApp.Application/Products/ProductsService.cs @@ -5,13 +5,15 @@ public class ProductsService : IProductsService private readonly HttpClient _httpClient; private readonly RestApiSettings _restApiSettings; private readonly ILogger _logger; + private readonly IUpstreamProductMapper? _upstreamMapper; public ProductsService(HttpClient httpClient, IOptions restApiSettings, - ILogger logger) + ILogger logger, IUpstreamProductMapper? upstreamMapper = null) { _httpClient = httpClient; _restApiSettings = restApiSettings.Value; _logger = logger; + _upstreamMapper = upstreamMapper; } public async Task GetProductById(int id, CancellationToken cancellationToken = default) @@ -51,7 +53,22 @@ public ProductsService(HttpClient httpClient, IOptions restApiS throw new ArgumentNullException(nameof(product)); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var json = JsonSerializer.Serialize(product, options); + // Use infrastructure mapper when available by resolving IUpstreamProductMapper from DI via HttpClient's service provider + // Fallback to local mapping if mapper not available + UpstreamCreateProduct upstream; + if (_upstreamMapper != null) + upstream = _upstreamMapper.Map(product); + else + upstream = new UpstreamCreateProduct + { + Title = product.Title, + Price = product.Price, + Description = product.Description, + Images = product.Images != null && product.Images.Count > 0 ? new List(product.Images) : null, + CategoryId = product.Category?.Id + }; + + var json = JsonSerializer.Serialize(upstream, options); using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); var path = string.IsNullOrWhiteSpace(_restApiSettings.Products) ? string.Empty : _restApiSettings.Products; @@ -76,13 +93,24 @@ public ProductsService(HttpClient httpClient, IOptions restApiS } } - public async Task> GetProducts() + public async Task> GetProducts(int? offset = null, int? limit = null) { try { // If Products path is set, request it; otherwise request root var path = string.IsNullOrWhiteSpace(_restApiSettings.Products) ? string.Empty : _restApiSettings.Products; + // Append query parameters for upstream pagination when provided + if (offset.HasValue || limit.HasValue) + { + var query = System.Web.HttpUtility.ParseQueryString(string.Empty); + if (offset.HasValue) query["offset"] = offset.Value.ToString(); + if (limit.HasValue) query["limit"] = limit.Value.ToString(); + + var qs = query.ToString(); + if (!string.IsNullOrEmpty(qs)) path = string.IsNullOrEmpty(path) ? "?" + qs : path + "?" + qs; + } + var response = await _httpClient.GetAsync(path); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(); diff --git a/src/CSharpApp.Application/Products/UpstreamCreateProduct.cs b/src/CSharpApp.Application/Products/UpstreamCreateProduct.cs new file mode 100644 index 00000000..221f6081 --- /dev/null +++ b/src/CSharpApp.Application/Products/UpstreamCreateProduct.cs @@ -0,0 +1,22 @@ +namespace CSharpApp.Application.Products; + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +public sealed class UpstreamCreateProduct +{ + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("price")] + public decimal? Price { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("images")] + public List? Images { get; set; } + + [JsonPropertyName("categoryId")] + public int? CategoryId { get; set; } +} diff --git a/src/CSharpApp.Application/Products/Validation/IProductValidator.cs b/src/CSharpApp.Application/Products/Validation/IProductValidator.cs new file mode 100644 index 00000000..2ddc65ce --- /dev/null +++ b/src/CSharpApp.Application/Products/Validation/IProductValidator.cs @@ -0,0 +1,12 @@ +namespace CSharpApp.Application.Products; + +public interface IProductValidator +{ + ValidationResult ValidateForCreate(CSharpApp.Core.Dtos.CreateProductRequestDto request); +} + +public sealed class ValidationResult +{ + public bool IsValid { get; set; } + public System.Collections.Generic.List Errors { get; } = new System.Collections.Generic.List(); +} diff --git a/src/CSharpApp.Application/Products/Validation/ProductBusinessValidator.cs b/src/CSharpApp.Application/Products/Validation/ProductBusinessValidator.cs new file mode 100644 index 00000000..f332a1d3 --- /dev/null +++ b/src/CSharpApp.Application/Products/Validation/ProductBusinessValidator.cs @@ -0,0 +1,36 @@ +namespace CSharpApp.Application.Products; + +public class ProductBusinessValidator : IProductValidator +{ + public ValidationResult ValidateForCreate(CSharpApp.Core.Dtos.CreateProductRequestDto request) + { + var result = new ValidationResult { IsValid = true }; + + if (request == null) + { + result.IsValid = false; + result.Errors.Add("Request cannot be null."); + return result; + } + + if (string.IsNullOrWhiteSpace(request.Title)) + { + result.IsValid = false; + result.Errors.Add("Title is required."); + } + + if (request.Price.HasValue && request.Price <= 0) + { + result.IsValid = false; + result.Errors.Add("Price must be greater than zero when provided."); + } + + if (request.CategoryId.HasValue && request.CategoryId <= 0) + { + result.IsValid = false; + result.Errors.Add("CategoryId, when provided, must be a positive integer."); + } + + return result; + } +} diff --git a/src/CSharpApp.Application/Products/Validation/ProductValidator.cs b/src/CSharpApp.Application/Products/Validation/ProductValidator.cs new file mode 100644 index 00000000..1beb100a --- /dev/null +++ b/src/CSharpApp.Application/Products/Validation/ProductValidator.cs @@ -0,0 +1,36 @@ +namespace CSharpApp.Application.Products; + +public class ProductValidator : IProductValidator +{ + public ValidationResult ValidateForCreate(CSharpApp.Core.Dtos.CreateProductRequestDto request) + { + var result = new ValidationResult { IsValid = true }; + + if (request == null) + { + result.IsValid = false; + result.Errors.Add("Request cannot be null."); + return result; + } + + if (string.IsNullOrWhiteSpace(request.Title)) + { + result.IsValid = false; + result.Errors.Add("Title is required."); + } + + if (request.Price.HasValue && request.Price <= 0) + { + result.IsValid = false; + result.Errors.Add("Price must be greater than zero when provided."); + } + + if (request.CategoryId.HasValue && request.CategoryId <= 0) + { + result.IsValid = false; + result.Errors.Add("CategoryId, when provided, must be a positive integer."); + } + + return result; + } +} diff --git a/src/CSharpApp.Application/ServiceCollectionExtensions.cs b/src/CSharpApp.Application/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..18b96572 --- /dev/null +++ b/src/CSharpApp.Application/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpApp.Application +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + // Application-level validators, mappers and other services + services.AddSingleton(); + services.AddSingleton(); + // Categories (moved to structured folders) + services.AddSingleton(); + services.AddSingleton(); + // Upstream category mapper interface will be implemented by Infrastructure + // We do not register it here to allow Infrastructure to provide its implementation. + + return services; + } + } +} diff --git a/src/CSharpApp.Core/CSharpApp.Core.csproj b/src/CSharpApp.Core/CSharpApp.Core.csproj index 17b910f6..4ca2dfaf 100644 --- a/src/CSharpApp.Core/CSharpApp.Core.csproj +++ b/src/CSharpApp.Core/CSharpApp.Core.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/CSharpApp.Core/GlobalUsings.cs b/src/CSharpApp.Core/GlobalUsings.cs index 9a48d664..5c85dc88 100644 --- a/src/CSharpApp.Core/GlobalUsings.cs +++ b/src/CSharpApp.Core/GlobalUsings.cs @@ -1,4 +1,6 @@ // Global using directives global using System.Text.Json.Serialization; -global using CSharpApp.Core.Dtos; \ No newline at end of file +// DTOs are declared with namespace CSharpApp.Core.Dtos in the shared Dtos project. +// DTOs moved to CSharpApp.Models project. Keep compatibility namespace CSharpApp.Core.Dtos. +global using CSharpApp.Core.Dtos; diff --git a/src/CSharpApp.Core/Interfaces/ICategoriesService.cs b/src/CSharpApp.Core/Interfaces/ICategoriesService.cs index d661c7b2..96e82777 100644 --- a/src/CSharpApp.Core/Interfaces/ICategoriesService.cs +++ b/src/CSharpApp.Core/Interfaces/ICategoriesService.cs @@ -1,3 +1,5 @@ + + namespace CSharpApp.Core.Interfaces; public interface ICategoriesService diff --git a/src/CSharpApp.Core/Interfaces/IProductsService.cs b/src/CSharpApp.Core/Interfaces/IProductsService.cs index 715f8e56..261dcfee 100644 --- a/src/CSharpApp.Core/Interfaces/IProductsService.cs +++ b/src/CSharpApp.Core/Interfaces/IProductsService.cs @@ -2,7 +2,7 @@ namespace CSharpApp.Core.Interfaces; public interface IProductsService { - Task> GetProducts(); + Task> GetProducts(int? offset = null, int? limit = null); Task GetProductById(int id, CancellationToken cancellationToken = default); Task CreateProduct(Product product, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/CSharpApp.Infrastructure/Adapters/UpstreamCategoryMapper.cs b/src/CSharpApp.Infrastructure/Adapters/UpstreamCategoryMapper.cs new file mode 100644 index 00000000..6087acef --- /dev/null +++ b/src/CSharpApp.Infrastructure/Adapters/UpstreamCategoryMapper.cs @@ -0,0 +1,15 @@ +namespace CSharpApp.Infrastructure.Adapters; + +using CSharpApp.Application.Categories; + +public class UpstreamCategoryMapper : IUpstreamCategoryMapper +{ + public UpstreamCreateCategory Map(CSharpApp.Core.Dtos.Category category) + { + return new UpstreamCreateCategory + { + Name = category.Name, + Image = category.Image + }; + } +} diff --git a/src/CSharpApp.Infrastructure/Adapters/UpstreamProductMapper.cs b/src/CSharpApp.Infrastructure/Adapters/UpstreamProductMapper.cs new file mode 100644 index 00000000..66f715a5 --- /dev/null +++ b/src/CSharpApp.Infrastructure/Adapters/UpstreamProductMapper.cs @@ -0,0 +1,18 @@ +namespace CSharpApp.Infrastructure.Adapters; + +using CSharpApp.Application.Products; + +public class UpstreamProductMapper : IUpstreamProductMapper +{ + public UpstreamCreateProduct Map(CSharpApp.Core.Dtos.Product product) + { + return new UpstreamCreateProduct + { + Title = product.Title, + Price = product.Price, + Description = product.Description, + Images = product.Images != null && product.Images.Count > 0 ? new System.Collections.Generic.List(product.Images) : null, + CategoryId = product.Category?.Id + }; + } +} diff --git a/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj b/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj index f8207d25..73027c28 100644 --- a/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj +++ b/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj @@ -24,6 +24,7 @@ + diff --git a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs index 6d53238f..97498f7c 100755 --- a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs @@ -8,6 +8,12 @@ public static IServiceCollection AddDefaultConfiguration(this IServiceCollection services.Configure(configuration.GetSection(nameof(RestApiSettings))); services.Configure(configuration.GetSection(nameof(HttpClientSettings))); + // Register product validation and mapping + services.AddSingleton(); + services.AddSingleton(); + // Register infrastructure adapter for upstream mapping + services.AddSingleton(); + return services; } } diff --git a/src/CSharpApp.Infrastructure/GlobalUsings.cs b/src/CSharpApp.Infrastructure/GlobalUsings.cs index cb253663..fb03056e 100644 --- a/src/CSharpApp.Infrastructure/GlobalUsings.cs +++ b/src/CSharpApp.Infrastructure/GlobalUsings.cs @@ -3,5 +3,7 @@ global using CSharpApp.Application.Products; global using CSharpApp.Core.Interfaces; global using CSharpApp.Core.Settings; +// DTOs are provided by CSharpApp.Dtos but keep namespace CSharpApp.Core.Dtos for compatibility +global using CSharpApp.Core.Dtos; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; \ No newline at end of file diff --git a/src/CSharpApp.Infrastructure/ServiceCollectionExtensions.cs b/src/CSharpApp.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..9034bfce --- /dev/null +++ b/src/CSharpApp.Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpApp.Infrastructure +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration) + { + // Infrastructure adapters, clients and other registrations + services.AddSingleton(); + // Upstream category mapper + services.AddSingleton(); + + return services; + } + } +} diff --git a/src/CSharpApp.Models/CSharpApp.Models.csproj b/src/CSharpApp.Models/CSharpApp.Models.csproj new file mode 100644 index 00000000..9623deb4 --- /dev/null +++ b/src/CSharpApp.Models/CSharpApp.Models.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/src/CSharpApp.Core/Dtos/CategoryDto.cs b/src/CSharpApp.Models/Dtos/Category.cs similarity index 91% rename from src/CSharpApp.Core/Dtos/CategoryDto.cs rename to src/CSharpApp.Models/Dtos/Category.cs index 2d85d090..bfcd8ec3 100644 --- a/src/CSharpApp.Core/Dtos/CategoryDto.cs +++ b/src/CSharpApp.Models/Dtos/Category.cs @@ -1,5 +1,7 @@ namespace CSharpApp.Core.Dtos; +using System.Text.Json.Serialization; + public sealed class Category { [JsonPropertyName("id")] @@ -16,4 +18,4 @@ public sealed class Category [JsonPropertyName("updatedAt")] public DateTime? UpdatedAt { get; set; } -} \ No newline at end of file +} diff --git a/src/CSharpApp.Models/Dtos/CreateCategoryRequestDto.cs b/src/CSharpApp.Models/Dtos/CreateCategoryRequestDto.cs new file mode 100644 index 00000000..fe9cbfe3 --- /dev/null +++ b/src/CSharpApp.Models/Dtos/CreateCategoryRequestDto.cs @@ -0,0 +1,12 @@ +namespace CSharpApp.Core.Dtos; + +using System.Text.Json.Serialization; + +public sealed class CreateCategoryRequestDto +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("image")] + public string? Image { get; set; } +} diff --git a/src/CSharpApp.Models/Dtos/CreateProductRequestDto.cs b/src/CSharpApp.Models/Dtos/CreateProductRequestDto.cs new file mode 100644 index 00000000..c66e2c14 --- /dev/null +++ b/src/CSharpApp.Models/Dtos/CreateProductRequestDto.cs @@ -0,0 +1,22 @@ +namespace CSharpApp.Core.Dtos; + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +public sealed class CreateProductRequestDto +{ + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("price")] + public decimal? Price { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("images")] + public List? Images { get; set; } + + [JsonPropertyName("categoryId")] + public int? CategoryId { get; set; } +} diff --git a/src/CSharpApp.Core/Dtos/ProductDto.cs b/src/CSharpApp.Models/Dtos/Product.cs similarity index 77% rename from src/CSharpApp.Core/Dtos/ProductDto.cs rename to src/CSharpApp.Models/Dtos/Product.cs index db83d46b..d77dedb7 100644 --- a/src/CSharpApp.Core/Dtos/ProductDto.cs +++ b/src/CSharpApp.Models/Dtos/Product.cs @@ -1,5 +1,8 @@ namespace CSharpApp.Core.Dtos; +using System.Collections.Generic; +using System.Text.Json.Serialization; + public sealed class Product { [JsonPropertyName("id")] @@ -9,13 +12,13 @@ public sealed class Product public string? Title { get; set; } [JsonPropertyName("price")] - public int? Price { get; set; } + public decimal? Price { get; set; } [JsonPropertyName("description")] public string? Description { get; set; } [JsonPropertyName("images")] - public List Images { get; } = []; + public List Images { get; } = new List(); [JsonPropertyName("creationAt")] public DateTime? CreationAt { get; set; } @@ -25,4 +28,4 @@ public sealed class Product [JsonPropertyName("category")] public Category? Category { get; set; } -} \ No newline at end of file +} diff --git a/src/CSharpApp.sln b/src/CSharpApp.sln index 6382d09b..f579026a 100644 --- a/src/CSharpApp.sln +++ b/src/CSharpApp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.6.11822.322 stable +VisualStudioVersion = 18.6.11822.322 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{09E4065D-8EA9-43F4-AB9C-5E70ACC35F88}" EndProject @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpApp.Infrastructure", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpApp.Tests", "test\CSharpApp.Tests\CSharpApp.Tests.csproj", "{FA0E527F-0190-B11D-0A5E-4007C794BEA2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpApp.Models", "CSharpApp.Models\CSharpApp.Models.csproj", "{DFD4C3EC-EC3F-96F8-8AF4-BC4961A85119}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,6 +45,10 @@ Global {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Debug|Any CPU.Build.0 = Debug|Any CPU {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Release|Any CPU.ActiveCfg = Release|Any CPU {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Release|Any CPU.Build.0 = Release|Any CPU + {DFD4C3EC-EC3F-96F8-8AF4-BC4961A85119}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFD4C3EC-EC3F-96F8-8AF4-BC4961A85119}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFD4C3EC-EC3F-96F8-8AF4-BC4961A85119}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFD4C3EC-EC3F-96F8-8AF4-BC4961A85119}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -53,5 +59,9 @@ Global {75D8AC89-79D6-4C05-88C5-E2BC6445F202} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} {1D24449A-3896-48C5-B007-A33F8479456C} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} {FA0E527F-0190-B11D-0A5E-4007C794BEA2} = {AEEC3AD8-B5EE-4590-8714-024364B7557A} + {DFD4C3EC-EC3F-96F8-8AF4-BC4961A85119} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7400F1E6-9114-4348-90B4-92EF0FAECECE} EndGlobalSection EndGlobal diff --git a/src/test/CSharpApp.Tests/CategoryMapperTests.cs b/src/test/CSharpApp.Tests/CategoryMapperTests.cs new file mode 100644 index 00000000..7f020563 --- /dev/null +++ b/src/test/CSharpApp.Tests/CategoryMapperTests.cs @@ -0,0 +1,22 @@ +using Xunit; + +namespace CSharpApp.Tests; + +public class CategoryMapperTests +{ + [Fact] + public void MapFromCreateRequest_MapsNameAndImage() + { + // Arrange + var mapper = new CSharpApp.Application.Categories.Mapping.CategoryMapper(); + var request = new CSharpApp.Core.Dtos.CreateCategoryRequestDto { Name = "Books", Image = "/books.png" }; + + // Act + var category = mapper.MapFromCreateRequest(request); + + // Assert + Assert.NotNull(category); + Assert.Equal("Books", category.Name); + Assert.Equal("/books.png", category.Image); + } +} diff --git a/src/test/CSharpApp.Tests/CategoryValidatorTests.cs b/src/test/CSharpApp.Tests/CategoryValidatorTests.cs new file mode 100644 index 00000000..868079b6 --- /dev/null +++ b/src/test/CSharpApp.Tests/CategoryValidatorTests.cs @@ -0,0 +1,50 @@ +using Xunit; + +namespace CSharpApp.Tests; + +public class CategoryValidatorTests +{ + [Fact] + public void ValidateForCreate_ReturnsInvalid_WhenRequestIsNull() + { + // Arrange + CSharpApp.Application.Categories.Validation.CategoryValidator validator = new CSharpApp.Application.Categories.Validation.CategoryValidator(); + + // Act + var result = validator.ValidateForCreate(null!); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("Request cannot be null.", result.Errors); + } + + [Fact] + public void ValidateForCreate_ReturnsInvalid_WhenNameMissing() + { + // Arrange + var request = new CSharpApp.Core.Dtos.CreateCategoryRequestDto { Name = null }; + CSharpApp.Application.Categories.Validation.CategoryValidator validator = new CSharpApp.Application.Categories.Validation.CategoryValidator(); + + // Act + var result = validator.ValidateForCreate(request); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("Name is required.", result.Errors); + } + + [Fact] + public void ValidateForCreate_ReturnsValid_WhenNameProvided() + { + // Arrange + var request = new CSharpApp.Core.Dtos.CreateCategoryRequestDto { Name = "Books" }; + CSharpApp.Application.Categories.Validation.CategoryValidator validator = new CSharpApp.Application.Categories.Validation.CategoryValidator(); + + // Act + var result = validator.ValidateForCreate(request); + + // Assert + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } +} diff --git a/src/test/CSharpApp.Tests/CreateCategoryValidatorTests.cs b/src/test/CSharpApp.Tests/CreateCategoryValidatorTests.cs new file mode 100644 index 00000000..543ebcb2 --- /dev/null +++ b/src/test/CSharpApp.Tests/CreateCategoryValidatorTests.cs @@ -0,0 +1,50 @@ +using Xunit; + +namespace CSharpApp.Tests; + +public class CreateCategoryValidatorTests +{ + [Fact] + public void Validate_ReturnsInvalid_WhenRequestIsNull() + { + // Arrange + var validator = new CSharpApp.Api.Validation.CreateCategoryValidator(); + + // Act + var ok = validator.Validate(null!, out var errors); + + // Assert + Assert.False(ok); + Assert.Contains("Request cannot be null.", errors); + } + + [Fact] + public void Validate_ReturnsInvalid_WhenNameMissing() + { + // Arrange + var validator = new CSharpApp.Api.Validation.CreateCategoryValidator(); + var request = new CSharpApp.Core.Dtos.CreateCategoryRequestDto { Name = null }; + + // Act + var ok = validator.Validate(request, out var errors); + + // Assert + Assert.False(ok); + Assert.Contains("Name is required.", errors); + } + + [Fact] + public void Validate_ReturnsValid_WhenNameProvided() + { + // Arrange + var validator = new CSharpApp.Api.Validation.CreateCategoryValidator(); + var request = new CSharpApp.Core.Dtos.CreateCategoryRequestDto { Name = "Books" }; + + // Act + var ok = validator.Validate(request, out var errors); + + // Assert + Assert.True(ok); + Assert.Empty(errors); + } +} diff --git a/src/test/CSharpApp.Tests/ProductMapperTests.cs b/src/test/CSharpApp.Tests/ProductMapperTests.cs new file mode 100644 index 00000000..5c1099c6 --- /dev/null +++ b/src/test/CSharpApp.Tests/ProductMapperTests.cs @@ -0,0 +1,32 @@ +using Xunit; + +namespace CSharpApp.Tests; + +public class ProductMapperTests +{ + [Fact] + public void MapFromCreateRequest_MapsFields() + { + // Arrange + var mapper = new CSharpApp.Application.Products.ProductMapper(); + var request = new CSharpApp.Core.Dtos.CreateProductRequestDto + { + Title = "New", + Price = 20m, + Description = "Desc", + Images = new System.Collections.Generic.List { "/1.png" }, + CategoryId = 2 + }; + + // Act + var product = mapper.MapFromCreateRequest(request); + + // Assert + Assert.NotNull(product); + Assert.Equal("New", product.Title); + Assert.Equal(20m, product.Price); + Assert.Equal("Desc", product.Description); + Assert.Equal(1, product.Images.Count); + Assert.Equal(2, product.Category?.Id); + } +} diff --git a/src/test/CSharpApp.Tests/ProductValidatorTests.cs b/src/test/CSharpApp.Tests/ProductValidatorTests.cs new file mode 100644 index 00000000..2b436b96 --- /dev/null +++ b/src/test/CSharpApp.Tests/ProductValidatorTests.cs @@ -0,0 +1,65 @@ +using Xunit; + +namespace CSharpApp.Tests; + +public class ProductValidatorTests +{ + [Fact] + public void ValidateForCreate_ReturnsInvalid_WhenRequestIsNull() + { + // Arrange + var validator = new CSharpApp.Application.Products.ProductValidator(); + + // Act + var result = validator.ValidateForCreate(null!); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("Request cannot be null.", result.Errors); + } + + [Fact] + public void ValidateForCreate_ReturnsInvalid_WhenTitleMissing() + { + // Arrange + var request = new CSharpApp.Core.Dtos.CreateProductRequestDto { Title = null }; + var validator = new CSharpApp.Application.Products.ProductValidator(); + + // Act + var result = validator.ValidateForCreate(request); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("Title is required.", result.Errors); + } + + [Fact] + public void ValidateForCreate_ReturnsInvalid_WhenPriceNonPositive() + { + // Arrange + var request = new CSharpApp.Core.Dtos.CreateProductRequestDto { Title = "T", Price = 0m }; + var validator = new CSharpApp.Application.Products.ProductValidator(); + + // Act + var result = validator.ValidateForCreate(request); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("Price must be greater than zero when provided.", result.Errors); + } + + [Fact] + public void ValidateForCreate_ReturnsValid_WhenDataIsGood() + { + // Arrange + var request = new CSharpApp.Core.Dtos.CreateProductRequestDto { Title = "T", Price = 10m }; + var validator = new CSharpApp.Application.Products.ProductValidator(); + + // Act + var result = validator.ValidateForCreate(request); + + // Assert + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } +} diff --git a/src/test/CSharpApp.Tests/ProductsServiceTests.cs b/src/test/CSharpApp.Tests/ProductsServiceTests.cs index 91301b3c..be708858 100644 --- a/src/test/CSharpApp.Tests/ProductsServiceTests.cs +++ b/src/test/CSharpApp.Tests/ProductsServiceTests.cs @@ -48,7 +48,7 @@ public async Task GetProductById_ReturnsProduct_WhenFound() public async Task CreateProduct_ReturnsCreatedProduct_WhenSuccess() { // Arrange - var product = new CSharpApp.Core.Dtos.Product { Title = "New", Price = 50 }; + var product = new CSharpApp.Core.Dtos.Product { Title = "New", Price = 50m }; var responseJson = "{ \"id\": 10, \"title\": \"New\", \"price\": 50 }"; var handler = new DelegatingHandlerStub((request, ct) =>