From d2a44460ba6bd58224a116b0ee1473ddb99fc1f1 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 13:09:58 +0300 Subject: [PATCH 1/3] 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 b0eb6a0..8d1e35f 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 0f1d367..91b1cb6 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 376d7cd..f4602d9 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 b4c5c6e..b5fd89d 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/3] - 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 4fb0d71..65608b0 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 8d1e35f..3bc1da7 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 91b1cb6..57a8e9d 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 5f321f2..715f8e5 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 62efd41..6382d09 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 0000000..faa214f --- /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 0000000..91301b3 --- /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/3] - 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 3bc1da7..2d13953 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 0000000..f76a7ae --- /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 57a8e9d..e33fc44 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 f362f27..916e140 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 0000000..d661c7b --- /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 f4602d9..6d53238 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 b5fd89d..c201b6f 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 0000000..e271bf2 --- /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); + } +}