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 4fb0d713..fd944f1d 100644
--- a/src/CSharpApp.Api/GlobalUsings.cs
+++ b/src/CSharpApp.Api/GlobalUsings.cs
@@ -2,4 +2,7 @@
global using CSharpApp.Core.Interfaces;
global using CSharpApp.Infrastructure.Configuration;
-global using Serilog;
\ No newline at end of file
+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/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 b0eb6a01..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();
@@ -6,11 +12,16 @@
// 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();
+// 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.
@@ -21,14 +32,94 @@
//app.UseHttpsRedirection();
+// Performance logging middleware
+app.UseMiddleware();
+
var versionedEndpointRouteBuilder = app.NewVersionedApi();
-versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/getproducts", async (IProductsService productsService) =>
+versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/products", async (IProductsService productsService, int? offset, int? limit) =>
{
- var products = await productsService.GetProducts();
- return products;
+ var products = await productsService.GetProducts(offset, limit);
+ 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, CSharpApp.Api.Validation.ICreateProductValidator apiValidator, CSharpApp.Application.Products.IProductValidator validator, CSharpApp.Application.Products.IProductMapper mapper, CreateProductRequestDto request, HttpContext http, CancellationToken ct) =>
+ {
+ if (request == null)
+ return Results.BadRequest();
+
+ // 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)
+ return Results.StatusCode(StatusCodes.Status502BadGateway);
+
+ var location = $"/api/v1/products/{created.Id}";
+ return Results.Created(location, created);
+ })
+ .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, 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 (request == null)
+ return Results.BadRequest();
+
+ // 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)
+ 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.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/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/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 0f1d367e..8a930ed8 100644
--- a/src/CSharpApp.Application/Products/ProductsService.cs
+++ b/src/CSharpApp.Application/Products/ProductsService.cs
@@ -5,23 +5,129 @@ public class ProductsService : IProductsService
private readonly HttpClient _httpClient;
private readonly RestApiSettings _restApiSettings;
private readonly ILogger _logger;
+ private readonly IUpstreamProductMapper? _upstreamMapper;
- public ProductsService(IOptions restApiSettings,
- ILogger logger)
+ public ProductsService(HttpClient httpClient, IOptions restApiSettings,
+ ILogger logger, IUpstreamProductMapper? upstreamMapper = null)
{
- _httpClient = new HttpClient();
+ _httpClient = httpClient;
_restApiSettings = restApiSettings.Value;
_logger = logger;
+ _upstreamMapper = upstreamMapper;
}
- public async Task> GetProducts()
+ public async Task GetProductById(int id, CancellationToken cancellationToken = default)
{
- _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
+ {
+ 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 };
+ // 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;
+
+ 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(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();
+
+ 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.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 f362f27e..4ca2dfaf 100644
--- a/src/CSharpApp.Core/CSharpApp.Core.csproj
+++ b/src/CSharpApp.Core/CSharpApp.Core.csproj
@@ -7,9 +7,7 @@
-
-
-
+
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/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
new file mode 100644
index 00000000..96e82777
--- /dev/null
+++ b/src/CSharpApp.Core/Interfaces/ICategoriesService.cs
@@ -0,0 +1,10 @@
+
+
+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.Core/Interfaces/IProductsService.cs b/src/CSharpApp.Core/Interfaces/IProductsService.cs
index 5f321f2f..261dcfee 100644
--- a/src/CSharpApp.Core/Interfaces/IProductsService.cs
+++ b/src/CSharpApp.Core/Interfaces/IProductsService.cs
@@ -2,5 +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.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.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/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/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..73027c28 100644
--- a/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj
+++ b/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj
@@ -12,6 +12,9 @@
+
+
+
@@ -21,6 +24,7 @@
+
diff --git a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs
index 376d7cd3..97498f7c 100755
--- a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs
+++ b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs
@@ -2,16 +2,18 @@ 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)));
+ // Bind configuration sections to options without building a temporary provider
+ services.Configure(configuration.GetSection(nameof(RestApiSettings)));
services.Configure(configuration.GetSection(nameof(HttpClientSettings)));
- services.AddSingleton();
-
+ // Register product validation and mapping
+ services.AddSingleton();
+ services.AddSingleton();
+ // Register infrastructure adapter for upstream mapping
+ 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..2ba7dc8c 100755
--- a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs
+++ b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs
@@ -1,9 +1,64 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using CSharpApp.Application.Categories;
+using CSharpApp.Application.Products;
+using CSharpApp.Infrastructure.Authentication;
+using CSharpApp.Infrastructure.Http;
+using CSharpApp.Core.Interfaces;
+
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 token service, memory cache and authentication handler
+ services.AddMemoryCache();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
+ // 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);
+ }
+ })
+ .AddHttpMessageHandler()
+ .AddHttpMessageHandler();
+
+ // 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);
+ }
+ })
+ .AddHttpMessageHandler()
+ .AddHttpMessageHandler();
+
return services;
}
-}
\ No newline at end of file
+}
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/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/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 62efd413..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 17
-VisualStudioVersion = 17.0.31903.59
+# Visual Studio Version 18
+VisualStudioVersion = 18.6.11822.322
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{09E4065D-8EA9-43F4-AB9C-5E70ACC35F88}"
EndProject
@@ -15,20 +15,15 @@ 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
+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
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 +41,27 @@ 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
+ {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
+ 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}
+ {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/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
new file mode 100644
index 00000000..60b8d022
--- /dev/null
+++ b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net9.0
+ false
+
+
+
+
+
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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);
+ }
+}
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/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/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/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"));
+ }
+}
+
+
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
new file mode 100644
index 00000000..be708858
--- /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 = 50m };
+ 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);
+ }
+}
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));
+ }
+}