From 8e1b02330317fe6130f966875bc812c287632a06 Mon Sep 17 00:00:00 2001 From: Domen Jesenovec Date: Sat, 21 Feb 2026 23:51:01 +0100 Subject: [PATCH] Add initial implementation of product management API --- .gitignore | 5 ++ EndavaTask.Tests/EndavaTask.Tests.csproj | 21 +++++ EndavaTask.Tests/EntityDtoMappingsTests.cs | 85 ++++++++++++++++++ .../InMemoryProductRepositoryTests.cs | 44 +++++++++ EndavaTask.Tests/ProductServiceTests.cs | 90 +++++++++++++++++++ EndavaTask.sln | 22 +++++ EndavaTask/Contracts/PagedResult.cs | 32 +++++++ EndavaTask/Contracts/PaginationQuery.cs | 21 +++++ EndavaTask/Contracts/ProductFilterQuery.cs | 49 ++++++++++ EndavaTask/Contracts/UpdateProductRequest.cs | 21 +++++ EndavaTask/Controllers/ProductsController.cs | 40 +++++++++ EndavaTask/Dtos/CategoryDto.cs | 22 +++++ EndavaTask/Dtos/ProductDto.cs | 27 ++++++ EndavaTask/EndavaTask.csproj | 14 +++ EndavaTask/EndavaTask.http | 14 +++ EndavaTask/Exceptions/ApiNotFoundException.cs | 3 + .../Exceptions/ApiValidationException.cs | 6 ++ EndavaTask/Extensions/PaginationExtensions.cs | 27 ++++++ EndavaTask/Mappings/EntityDtoMappings.cs | 62 +++++++++++++ .../Middleware/ExceptionHandlingMiddleware.cs | 72 +++++++++++++++ .../ExceptionHandlingMiddlewareExtensions.cs | 9 ++ EndavaTask/Models/Category.cs | 22 +++++ EndavaTask/Models/Product.cs | 27 ++++++ EndavaTask/Program.cs | 32 +++++++ EndavaTask/Properties/launchSettings.json | 25 ++++++ .../Repositories/InMemoryProductRepository.cs | 78 ++++++++++++++++ .../Interfaces/IProductRepository.cs | 11 +++ .../Services/Interfaces/IProductService.cs | 10 +++ EndavaTask/Services/ProductService.cs | 61 +++++++++++++ EndavaTask/appsettings.Development.json | 8 ++ EndavaTask/appsettings.json | 9 ++ 31 files changed, 969 insertions(+) create mode 100644 .gitignore create mode 100644 EndavaTask.Tests/EndavaTask.Tests.csproj create mode 100644 EndavaTask.Tests/EntityDtoMappingsTests.cs create mode 100644 EndavaTask.Tests/InMemoryProductRepositoryTests.cs create mode 100644 EndavaTask.Tests/ProductServiceTests.cs create mode 100644 EndavaTask.sln create mode 100644 EndavaTask/Contracts/PagedResult.cs create mode 100644 EndavaTask/Contracts/PaginationQuery.cs create mode 100644 EndavaTask/Contracts/ProductFilterQuery.cs create mode 100644 EndavaTask/Contracts/UpdateProductRequest.cs create mode 100644 EndavaTask/Controllers/ProductsController.cs create mode 100644 EndavaTask/Dtos/CategoryDto.cs create mode 100644 EndavaTask/Dtos/ProductDto.cs create mode 100644 EndavaTask/EndavaTask.csproj create mode 100644 EndavaTask/EndavaTask.http create mode 100644 EndavaTask/Exceptions/ApiNotFoundException.cs create mode 100644 EndavaTask/Exceptions/ApiValidationException.cs create mode 100644 EndavaTask/Extensions/PaginationExtensions.cs create mode 100644 EndavaTask/Mappings/EntityDtoMappings.cs create mode 100644 EndavaTask/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 EndavaTask/Middleware/ExceptionHandlingMiddlewareExtensions.cs create mode 100644 EndavaTask/Models/Category.cs create mode 100644 EndavaTask/Models/Product.cs create mode 100644 EndavaTask/Program.cs create mode 100644 EndavaTask/Properties/launchSettings.json create mode 100644 EndavaTask/Repositories/InMemoryProductRepository.cs create mode 100644 EndavaTask/Repositories/Interfaces/IProductRepository.cs create mode 100644 EndavaTask/Services/Interfaces/IProductService.cs create mode 100644 EndavaTask/Services/ProductService.cs create mode 100644 EndavaTask/appsettings.Development.json create mode 100644 EndavaTask/appsettings.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/EndavaTask.Tests/EndavaTask.Tests.csproj b/EndavaTask.Tests/EndavaTask.Tests.csproj new file mode 100644 index 0000000..2dbea0f --- /dev/null +++ b/EndavaTask.Tests/EndavaTask.Tests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/EndavaTask.Tests/EntityDtoMappingsTests.cs b/EndavaTask.Tests/EntityDtoMappingsTests.cs new file mode 100644 index 0000000..efcaeb5 --- /dev/null +++ b/EndavaTask.Tests/EntityDtoMappingsTests.cs @@ -0,0 +1,85 @@ +using EndavaTask.Contracts; +using EndavaTask.Dtos; +using EndavaTask.Mappings; +using EndavaTask.Models; +using Xunit; + +namespace EndavaTask.Tests; + +public class EntityDtoMappingsTests +{ + [Fact] + public void Product_ToDto_And_ToEntity_Roundtrip_Works() + { + var entity = new Product + { + Id = Guid.NewGuid(), + Name = "Keyboard", + Price = 90m, + CategoryId = Guid.NewGuid() + }; + + var dto = entity.ToDto(); + var mappedBack = dto.ToEntity(); + + Assert.Equal(entity.Id, dto.Id); + Assert.Equal(entity.Name, dto.Name); + Assert.Equal(entity.Price, dto.Price); + Assert.Equal(entity.CategoryId, dto.CategoryId); + + Assert.Equal(entity.Id, mappedBack.Id); + Assert.Equal(entity.Name, mappedBack.Name); + Assert.Equal(entity.Price, mappedBack.Price); + Assert.Equal(entity.CategoryId, mappedBack.CategoryId); + } + + [Fact] + public void Category_ToDto_And_ToEntity_Roundtrip_Works() + { + var entity = new Category + { + Id = Guid.NewGuid(), + Name = "Home", + Description = "Home essentials" + }; + + var dto = entity.ToDto(); + var mappedBack = dto.ToEntity(); + + Assert.Equal(entity.Id, dto.Id); + Assert.Equal(entity.Name, dto.Name); + Assert.Equal(entity.Description, dto.Description); + + Assert.Equal(entity.Id, mappedBack.Id); + Assert.Equal(entity.Name, mappedBack.Name); + Assert.Equal(entity.Description, mappedBack.Description); + } + + [Fact] + public void PagedProducts_ToDto_MapsMetadataAndItems() + { + var paged = new PagedResult + { + Items = + [ + new Product { Id = Guid.NewGuid(), Name = "Laptop", Price = 1200m, CategoryId = Guid.NewGuid() }, + new Product { Id = Guid.NewGuid(), Name = "Monitor", Price = 300m, CategoryId = Guid.NewGuid() } + ], + PageNumber = 2, + PageSize = 2, + TotalCount = 5, + TotalPages = 3 + }; + + var dtoPaged = paged.ToDto(); + + Assert.Equal(2, dtoPaged.Items.Count); + Assert.Equal(2, dtoPaged.PageNumber); + Assert.Equal(2, dtoPaged.PageSize); + Assert.Equal(5, dtoPaged.TotalCount); + Assert.Equal(3, dtoPaged.TotalPages); + + var first = Assert.IsType(dtoPaged.Items.First()); + Assert.Equal("Laptop", first.Name); + } +} diff --git a/EndavaTask.Tests/InMemoryProductRepositoryTests.cs b/EndavaTask.Tests/InMemoryProductRepositoryTests.cs new file mode 100644 index 0000000..88157e5 --- /dev/null +++ b/EndavaTask.Tests/InMemoryProductRepositoryTests.cs @@ -0,0 +1,44 @@ +using EndavaTask.Contracts; +using EndavaTask.Data; +using Xunit; + +namespace EndavaTask.Tests; + +public class InMemoryProductRepositoryTests +{ + private readonly InMemoryProductRepository _repository = new(); + + [Fact] + public void GetFilteredProducts_FiltersByNameCategoryAndPriceRange_WithPagination() + { + var filter = new ProductFilterQuery + { + Name = "mon", + CategoryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + MinPrice = 200m, + MaxPrice = 350m + }; + + var result = _repository.GetFilteredProducts(filter, new PaginationQuery { PageNumber = 1, PageSize = 10 }); + + Assert.Single(result.Items); + + var product = Assert.Single(result.Items); + + Assert.Equal("Monitor", product.Name); + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.TotalPages); + } + + [Fact] + public void GetFilteredProducts_ReturnsPagedData_WhenNoFilters() + { + var result = _repository.GetFilteredProducts(new ProductFilterQuery(), new PaginationQuery { PageNumber = 2, PageSize = 2 }); + + Assert.Equal(2, result.Items.Count); + Assert.Equal(5, result.TotalCount); + Assert.Equal(3, result.TotalPages); + Assert.Equal(2, result.PageNumber); + Assert.Equal(2, result.PageSize); + } +} diff --git a/EndavaTask.Tests/ProductServiceTests.cs b/EndavaTask.Tests/ProductServiceTests.cs new file mode 100644 index 0000000..cb6522e --- /dev/null +++ b/EndavaTask.Tests/ProductServiceTests.cs @@ -0,0 +1,90 @@ +using EndavaTask.Contracts; +using EndavaTask.Data; +using EndavaTask.Dtos; +using EndavaTask.Exceptions; +using EndavaTask.Models; +using EndavaTask.Services; +using Moq; +using Xunit; + +namespace EndavaTask.Tests; + +public class ProductServiceTests +{ + [Fact] + public void GetProducts_WithRepositoryResult_MapsAndReturnsPagedResult() + { + var categoryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + var repositoryMock = new Mock(); + + repositoryMock + .Setup(r => r.CategoryExists(categoryId)) + .Returns(true); + + repositoryMock + .Setup(r => r.GetFilteredProducts(It.IsAny(), It.IsAny())) + .Returns(new PagedResult + { + Items = + [ + new Product { Id = Guid.NewGuid(), Name = "Laptop", Price = 1200m, CategoryId = categoryId }, + new Product { Id = Guid.NewGuid(), Name = "Headphones", Price = 180m, CategoryId = categoryId } + ], + PageNumber = 1, + PageSize = 2, + TotalCount = 3, + TotalPages = 2 + }); + + var service = new ProductService(repositoryMock.Object); + var result = service.GetProducts(new ProductFilterQuery { CategoryId = categoryId }, new PaginationQuery { PageNumber = 1, PageSize = 2 }); + var value = Assert.IsType>(result); + + Assert.Equal(2, value.Items.Count); + Assert.Equal(3, value.TotalCount); + Assert.Equal(2, value.TotalPages); + } + + [Fact] + public void UpdateProduct_WithRepositoryEntity_ReturnsMappedDto() + { + var productId = Guid.Parse("55555555-5555-5555-5555-555555555555"); + var categoryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + var repositoryMock = new Mock(); + + repositoryMock + .Setup(r => r.UpdateProduct(productId, It.IsAny())) + .Returns(new Product + { + Id = productId, + Name = "Updated Monitor", + Price = 350m, + CategoryId = categoryId + }); + + var service = new ProductService(repositoryMock.Object); + var result = service.UpdateProduct(productId, new UpdateProductRequest { Name = "Updated Monitor", Price = 350m }); + var value = Assert.IsType(result); + + Assert.Equal("Updated Monitor", value.Name); + Assert.Equal(350m, value.Price); + } + + [Fact] + public void GetProducts_WithUnknownCategory_ThrowsValidationException() + { + var repositoryMock = new Mock(); + + repositoryMock + .Setup(r => r.CategoryExists(It.IsAny())) + .Returns(false); + + var service = new ProductService(repositoryMock.Object); + + var exception = Assert.Throws(() => + service.GetProducts(new ProductFilterQuery { CategoryId = Guid.NewGuid() }, new PaginationQuery())); + + Assert.True(exception.Errors.ContainsKey(nameof(ProductFilterQuery.CategoryId))); + } +} diff --git a/EndavaTask.sln b/EndavaTask.sln new file mode 100644 index 0000000..9a8e6d2 --- /dev/null +++ b/EndavaTask.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EndavaTask", "EndavaTask\EndavaTask.csproj", "{56A4768E-E9F6-4C1B-8DDF-5BB907A84E15}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EndavaTask.Tests", "EndavaTask.Tests\EndavaTask.Tests.csproj", "{C1248566-E881-41AE-9260-B943D0DC3BB1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {56A4768E-E9F6-4C1B-8DDF-5BB907A84E15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56A4768E-E9F6-4C1B-8DDF-5BB907A84E15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56A4768E-E9F6-4C1B-8DDF-5BB907A84E15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56A4768E-E9F6-4C1B-8DDF-5BB907A84E15}.Release|Any CPU.Build.0 = Release|Any CPU + {C1248566-E881-41AE-9260-B943D0DC3BB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1248566-E881-41AE-9260-B943D0DC3BB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1248566-E881-41AE-9260-B943D0DC3BB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1248566-E881-41AE-9260-B943D0DC3BB1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/EndavaTask/Contracts/PagedResult.cs b/EndavaTask/Contracts/PagedResult.cs new file mode 100644 index 0000000..6961468 --- /dev/null +++ b/EndavaTask/Contracts/PagedResult.cs @@ -0,0 +1,32 @@ +namespace EndavaTask.Contracts; + +/// +/// Generic paged response container. +/// +public class PagedResult +{ + /// + /// Items returned for the current page. + /// + public required IReadOnlyCollection Items { get; init; } + + /// + /// Current page number. + /// + public required int PageNumber { get; init; } + + /// + /// Requested page size. + /// + public required int PageSize { get; init; } + + /// + /// Total number of items matching the filter. + /// + public required int TotalCount { get; init; } + + /// + /// Total number of pages for the current page size. + /// + public required int TotalPages { get; init; } +} diff --git a/EndavaTask/Contracts/PaginationQuery.cs b/EndavaTask/Contracts/PaginationQuery.cs new file mode 100644 index 0000000..19180b4 --- /dev/null +++ b/EndavaTask/Contracts/PaginationQuery.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace EndavaTask.Contracts; + +/// +/// Standard pagination query parameters. +/// +public class PaginationQuery +{ + /// + /// Page number to return, starting from 1. + /// + [Range(1, int.MaxValue)] + public int PageNumber { get; init; } = 1; + + /// + /// Number of items per page. Max allowed value is 100. + /// + [Range(1, 100)] + public int PageSize { get; init; } = 10; +} diff --git a/EndavaTask/Contracts/ProductFilterQuery.cs b/EndavaTask/Contracts/ProductFilterQuery.cs new file mode 100644 index 0000000..5586318 --- /dev/null +++ b/EndavaTask/Contracts/ProductFilterQuery.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations; + +namespace EndavaTask.Contracts; + +/// +/// Query parameters used to filter products. +/// +public class ProductFilterQuery : IValidatableObject +{ + /// + /// Returns products whose name contains this value (case-insensitive). + /// + [StringLength(100, MinimumLength = 1)] + public string? Name { get; init; } + + /// + /// Returns products that belong to the specified category id. + /// + public Guid? CategoryId { get; init; } + + /// + /// Minimum product price (inclusive). + /// + [Range(0, double.MaxValue)] + public decimal? MinPrice { get; init; } + + /// + /// Maximum product price (inclusive). + /// + [Range(0, double.MaxValue)] + public decimal? MaxPrice { get; init; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (CategoryId.HasValue && CategoryId.Value == Guid.Empty) + { + yield return new ValidationResult( + "CategoryId must not be an empty GUID.", + [nameof(CategoryId)]); + } + + if (MinPrice.HasValue && MaxPrice.HasValue && MinPrice.Value > MaxPrice.Value) + { + yield return new ValidationResult( + "MinPrice must be less than or equal to MaxPrice.", + [nameof(MinPrice), nameof(MaxPrice)]); + } + } +} diff --git a/EndavaTask/Contracts/UpdateProductRequest.cs b/EndavaTask/Contracts/UpdateProductRequest.cs new file mode 100644 index 0000000..750987e --- /dev/null +++ b/EndavaTask/Contracts/UpdateProductRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace EndavaTask.Contracts; + +/// +/// Request payload for updating a product. +/// +public class UpdateProductRequest +{ + /// + /// New product name. + /// + [StringLength(100, MinimumLength = 1)] + public string? Name { get; init; } + + /// + /// New product price. + /// + [Range(0, double.MaxValue)] + public decimal? Price { get; init; } +} diff --git a/EndavaTask/Controllers/ProductsController.cs b/EndavaTask/Controllers/ProductsController.cs new file mode 100644 index 0000000..9e4969d --- /dev/null +++ b/EndavaTask/Controllers/ProductsController.cs @@ -0,0 +1,40 @@ +using EndavaTask.Contracts; +using EndavaTask.Dtos; +using EndavaTask.Services; +using Microsoft.AspNetCore.Mvc; + +namespace EndavaTask.Controllers; + +[ApiController] +[Route("api/v1/[controller]")] +public class ProductsController(IProductService productService) : ControllerBase +{ + /// + /// Returns products filtered by optional criteria and paginated. + /// + /// Filtering parameters such as name, category and price range. + /// Pagination parameters. + /// A paged list of products that match the filter. + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + public ActionResult> GetProducts([FromQuery] ProductFilterQuery filter, [FromQuery] PaginationQuery pagination) + { + return Ok(productService.GetProducts(filter, pagination)); + } + + /// + /// Updates an existing product's name and/or price. + /// + /// Product identifier. + /// Fields to update. + /// The updated product. + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + public ActionResult UpdateProduct(Guid id, [FromBody] UpdateProductRequest request) + { + return Ok(productService.UpdateProduct(id, request)); + } +} diff --git a/EndavaTask/Dtos/CategoryDto.cs b/EndavaTask/Dtos/CategoryDto.cs new file mode 100644 index 0000000..029593d --- /dev/null +++ b/EndavaTask/Dtos/CategoryDto.cs @@ -0,0 +1,22 @@ +namespace EndavaTask.Dtos; + +/// +/// Category data transfer object. +/// +public class CategoryDto +{ + /// + /// Unique category identifier. + /// + public Guid Id { get; init; } + + /// + /// Category name. + /// + public required string Name { get; init; } + + /// + /// Category description. + /// + public required string Description { get; init; } +} diff --git a/EndavaTask/Dtos/ProductDto.cs b/EndavaTask/Dtos/ProductDto.cs new file mode 100644 index 0000000..be2ba4e --- /dev/null +++ b/EndavaTask/Dtos/ProductDto.cs @@ -0,0 +1,27 @@ +namespace EndavaTask.Dtos; + +/// +/// Product data transfer object. +/// +public class ProductDto +{ + /// + /// Unique product identifier. + /// + public Guid Id { get; init; } + + /// + /// Product name. + /// + public required string Name { get; init; } + + /// + /// Product price. + /// + public decimal Price { get; init; } + + /// + /// Identifier of the category this product belongs to. + /// + public Guid CategoryId { get; init; } +} diff --git a/EndavaTask/EndavaTask.csproj b/EndavaTask/EndavaTask.csproj new file mode 100644 index 0000000..1a41939 --- /dev/null +++ b/EndavaTask/EndavaTask.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + true + + + + + + + diff --git a/EndavaTask/EndavaTask.http b/EndavaTask/EndavaTask.http new file mode 100644 index 0000000..d792ed3 --- /dev/null +++ b/EndavaTask/EndavaTask.http @@ -0,0 +1,14 @@ +@EndavaTask_HostAddress = http://localhost:5262 + +### Filter + paginate products +GET {{EndavaTask_HostAddress}}/api/v1/products?name=mon&categoryId=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa&minPrice=100&maxPrice=500&pageNumber=1&pageSize=10 +Accept: application/json + +### Update product name and/or price +PUT {{EndavaTask_HostAddress}}/api/v1/products/55555555-5555-5555-5555-555555555555 +Content-Type: application/json + +{ + "name": "Ultra Monitor", + "price": 349.99 +} diff --git a/EndavaTask/Exceptions/ApiNotFoundException.cs b/EndavaTask/Exceptions/ApiNotFoundException.cs new file mode 100644 index 0000000..0aaa8e0 --- /dev/null +++ b/EndavaTask/Exceptions/ApiNotFoundException.cs @@ -0,0 +1,3 @@ +namespace EndavaTask.Exceptions; + +public class ApiNotFoundException(string message) : Exception(message); diff --git a/EndavaTask/Exceptions/ApiValidationException.cs b/EndavaTask/Exceptions/ApiValidationException.cs new file mode 100644 index 0000000..0f66222 --- /dev/null +++ b/EndavaTask/Exceptions/ApiValidationException.cs @@ -0,0 +1,6 @@ +namespace EndavaTask.Exceptions; + +public class ApiValidationException(IReadOnlyDictionary errors) : Exception("Validation failed.") +{ + public IReadOnlyDictionary Errors { get; } = errors; +} diff --git a/EndavaTask/Extensions/PaginationExtensions.cs b/EndavaTask/Extensions/PaginationExtensions.cs new file mode 100644 index 0000000..6a2463b --- /dev/null +++ b/EndavaTask/Extensions/PaginationExtensions.cs @@ -0,0 +1,27 @@ +using EndavaTask.Contracts; + +namespace EndavaTask.Extensions; + +public static class PaginationExtensions +{ + public static PagedResult ToPagedResult(this IEnumerable source, PaginationQuery pagination) + { + var items = source.ToArray(); + var totalCount = items.Length; + var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pagination.PageSize); + + var pagedItems = items + .Skip((pagination.PageNumber - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToArray(); + + return new PagedResult + { + Items = pagedItems, + PageNumber = pagination.PageNumber, + PageSize = pagination.PageSize, + TotalCount = totalCount, + TotalPages = totalPages + }; + } +} diff --git a/EndavaTask/Mappings/EntityDtoMappings.cs b/EndavaTask/Mappings/EntityDtoMappings.cs new file mode 100644 index 0000000..ac823ad --- /dev/null +++ b/EndavaTask/Mappings/EntityDtoMappings.cs @@ -0,0 +1,62 @@ +using EndavaTask.Contracts; +using EndavaTask.Dtos; +using EndavaTask.Models; + +namespace EndavaTask.Mappings; + +public static class EntityDtoMappings +{ + public static ProductDto ToDto(this Product entity) + { + return new ProductDto + { + Id = entity.Id, + Name = entity.Name, + Price = entity.Price, + CategoryId = entity.CategoryId + }; + } + + public static Product ToEntity(this ProductDto dto) + { + return new Product + { + Id = dto.Id, + Name = dto.Name, + Price = dto.Price, + CategoryId = dto.CategoryId + }; + } + + public static CategoryDto ToDto(this Category entity) + { + return new CategoryDto + { + Id = entity.Id, + Name = entity.Name, + Description = entity.Description + }; + } + + public static Category ToEntity(this CategoryDto dto) + { + return new Category + { + Id = dto.Id, + Name = dto.Name, + Description = dto.Description + }; + } + + public static PagedResult ToDto(this PagedResult pagedResult) + { + return new PagedResult + { + Items = pagedResult.Items.Select(i => i.ToDto()).ToArray(), + PageNumber = pagedResult.PageNumber, + PageSize = pagedResult.PageSize, + TotalCount = pagedResult.TotalCount, + TotalPages = pagedResult.TotalPages + }; + } +} diff --git a/EndavaTask/Middleware/ExceptionHandlingMiddleware.cs b/EndavaTask/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..defae33 --- /dev/null +++ b/EndavaTask/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using EndavaTask.Exceptions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace EndavaTask.Middleware; + +public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment environment) +{ + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception exception) + { + logger.LogError(exception, "Unhandled exception while processing {Method} {Path}", context.Request.Method, context.Request.Path); + + var details = BuildProblemDetails(context, exception); + + context.Response.StatusCode = details.Status ?? StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/problem+json"; + details.Extensions["traceId"] = context.TraceIdentifier; + + var json = JsonSerializer.Serialize(details); + await context.Response.WriteAsync(json); + } + } + + private ProblemDetails BuildProblemDetails(HttpContext context, Exception exception) + { + switch (exception) + { + case ApiValidationException validationException: + { + var modelState = new ModelStateDictionary(); + foreach (var error in validationException.Errors) + { + foreach (var message in error.Value) + { + modelState.AddModelError(error.Key, message); + } + } + + return new ValidationProblemDetails(modelState) + { + Status = StatusCodes.Status400BadRequest, + Title = "Validation failed", + Detail = "One or more validation errors occurred.", + Instance = context.Request.Path + }; + } + case ApiNotFoundException: + return new ProblemDetails + { + Status = StatusCodes.Status404NotFound, + Title = "Resource not found", + Detail = exception.Message, + Instance = context.Request.Path + }; + default: + return new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "Unexpected server error", + Detail = environment.IsDevelopment() ? exception.Message : "An unexpected error occurred.", + Instance = context.Request.Path + }; + } + } +} diff --git a/EndavaTask/Middleware/ExceptionHandlingMiddlewareExtensions.cs b/EndavaTask/Middleware/ExceptionHandlingMiddlewareExtensions.cs new file mode 100644 index 0000000..0f275f3 --- /dev/null +++ b/EndavaTask/Middleware/ExceptionHandlingMiddlewareExtensions.cs @@ -0,0 +1,9 @@ +namespace EndavaTask.Middleware; + +public static class ExceptionHandlingMiddlewareExtensions +{ + public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } +} diff --git a/EndavaTask/Models/Category.cs b/EndavaTask/Models/Category.cs new file mode 100644 index 0000000..fd2181b --- /dev/null +++ b/EndavaTask/Models/Category.cs @@ -0,0 +1,22 @@ +namespace EndavaTask.Models; + +/// +/// Product category entity. +/// +public class Category +{ + /// + /// Unique category identifier. + /// + public Guid Id { get; init; } + + /// + /// Category name. + /// + public required string Name { get; init; } + + /// + /// Category description. + /// + public required string Description { get; init; } +} diff --git a/EndavaTask/Models/Product.cs b/EndavaTask/Models/Product.cs new file mode 100644 index 0000000..99f1861 --- /dev/null +++ b/EndavaTask/Models/Product.cs @@ -0,0 +1,27 @@ +namespace EndavaTask.Models; + +/// +/// Product entity. +/// +public class Product +{ + /// + /// Unique product identifier. + /// + public Guid Id { get; init; } + + /// + /// Product name. + /// + public required string Name { get; set; } + + /// + /// Product price. + /// + public decimal Price { get; set; } + + /// + /// Identifier of the category this product belongs to. + /// + public Guid CategoryId { get; init; } +} diff --git a/EndavaTask/Program.cs b/EndavaTask/Program.cs new file mode 100644 index 0000000..5ad2f50 --- /dev/null +++ b/EndavaTask/Program.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using EndavaTask.Data; +using EndavaTask.Middleware; +using EndavaTask.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + var xmlFileName = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFileName); + options.IncludeXmlComments(xmlPath); +}); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseGlobalExceptionHandling(); +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/EndavaTask/Properties/launchSettings.json b/EndavaTask/Properties/launchSettings.json new file mode 100644 index 0000000..f5df30c --- /dev/null +++ b/EndavaTask/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5262", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7079;http://localhost:5262", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/EndavaTask/Repositories/InMemoryProductRepository.cs b/EndavaTask/Repositories/InMemoryProductRepository.cs new file mode 100644 index 0000000..751b6fb --- /dev/null +++ b/EndavaTask/Repositories/InMemoryProductRepository.cs @@ -0,0 +1,78 @@ +using EndavaTask.Contracts; +using EndavaTask.Extensions; +using EndavaTask.Models; + +namespace EndavaTask.Data; + +public class InMemoryProductRepository : IProductRepository +{ + private readonly List _categories = + [ + new() { Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), Name = "Electronics", Description = "Devices and gadgets" }, + new() { Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Name = "Books", Description = "Printed and digital books" }, + new() { Id = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"), Name = "Home", Description = "Home essentials" } + ]; + + private readonly List _products = + [ + new() { Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), Name = "Laptop", Price = 1200m, CategoryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") }, + new() { Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), Name = "Headphones", Price = 180m, CategoryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") }, + new() { Id = Guid.Parse("33333333-3333-3333-3333-333333333333"), Name = "Clean Code", Price = 42m, CategoryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") }, + new() { Id = Guid.Parse("44444444-4444-4444-4444-444444444444"), Name = "Desk Lamp", Price = 35m, CategoryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc") }, + new() { Id = Guid.Parse("55555555-5555-5555-5555-555555555555"), Name = "Monitor", Price = 300m, CategoryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") } + ]; + + public PagedResult GetFilteredProducts(ProductFilterQuery filter, PaginationQuery pagination) + { + IEnumerable query = _products; + + if (!string.IsNullOrWhiteSpace(filter.Name)) + { + var nameFilter = filter.Name.Trim(); + query = query.Where(p => p.Name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase)); + } + + if (filter.CategoryId.HasValue) + { + query = query.Where(p => p.CategoryId == filter.CategoryId.Value); + } + + if (filter.MinPrice.HasValue) + { + query = query.Where(p => p.Price >= filter.MinPrice.Value); + } + + if (filter.MaxPrice.HasValue) + { + query = query.Where(p => p.Price <= filter.MaxPrice.Value); + } + + return query.ToPagedResult(pagination); + } + + public Product? UpdateProduct(Guid id, UpdateProductRequest request) + { + var product = _products.SingleOrDefault(p => p.Id == id); + if (product is null) + { + return null; + } + + if (request.Name is not null) + { + product.Name = request.Name.Trim(); + } + + if (request.Price.HasValue) + { + product.Price = request.Price.Value; + } + + return product; + } + + public bool CategoryExists(Guid categoryId) + { + return _categories.Any(c => c.Id == categoryId); + } +} diff --git a/EndavaTask/Repositories/Interfaces/IProductRepository.cs b/EndavaTask/Repositories/Interfaces/IProductRepository.cs new file mode 100644 index 0000000..d1a213d --- /dev/null +++ b/EndavaTask/Repositories/Interfaces/IProductRepository.cs @@ -0,0 +1,11 @@ +using EndavaTask.Contracts; +using EndavaTask.Models; + +namespace EndavaTask.Data; + +public interface IProductRepository +{ + PagedResult GetFilteredProducts(ProductFilterQuery filter, PaginationQuery pagination); + Product? UpdateProduct(Guid id, UpdateProductRequest request); + bool CategoryExists(Guid categoryId); +} diff --git a/EndavaTask/Services/Interfaces/IProductService.cs b/EndavaTask/Services/Interfaces/IProductService.cs new file mode 100644 index 0000000..f57a862 --- /dev/null +++ b/EndavaTask/Services/Interfaces/IProductService.cs @@ -0,0 +1,10 @@ +using EndavaTask.Contracts; +using EndavaTask.Dtos; + +namespace EndavaTask.Services; + +public interface IProductService +{ + PagedResult GetProducts(ProductFilterQuery filter, PaginationQuery pagination); + ProductDto UpdateProduct(Guid id, UpdateProductRequest request); +} diff --git a/EndavaTask/Services/ProductService.cs b/EndavaTask/Services/ProductService.cs new file mode 100644 index 0000000..275cf59 --- /dev/null +++ b/EndavaTask/Services/ProductService.cs @@ -0,0 +1,61 @@ +using EndavaTask.Contracts; +using EndavaTask.Data; +using EndavaTask.Dtos; +using EndavaTask.Exceptions; +using EndavaTask.Mappings; + +namespace EndavaTask.Services; + +public class ProductService(IProductRepository repository) : IProductService +{ + public PagedResult GetProducts(ProductFilterQuery filter, PaginationQuery pagination) + { + var errors = new Dictionary(); + + if (filter.MinPrice.HasValue && filter.MaxPrice.HasValue && filter.MinPrice > filter.MaxPrice) + { + errors[nameof(filter.MinPrice)] = ["MinPrice must be less than or equal to MaxPrice."]; + } + + if (filter.CategoryId.HasValue && !repository.CategoryExists(filter.CategoryId.Value)) + { + errors[nameof(filter.CategoryId)] = ["CategoryId does not exist."]; + } + + if (errors.Count > 0) + { + throw new ApiValidationException(errors); + } + + return repository.GetFilteredProducts(filter, pagination).ToDto(); + } + + public ProductDto UpdateProduct(Guid id, UpdateProductRequest request) + { + var errors = new Dictionary(); + + if (request.Name is null && request.Price is null) + { + errors[string.Empty] = ["At least one field (name or price) must be provided."]; + } + + if (request.Name != null && string.IsNullOrWhiteSpace(request.Name)) + { + errors[nameof(request.Name)] = ["Name cannot be empty."]; + } + + if (errors.Count > 0) + { + throw new ApiValidationException(errors); + } + + var entity = repository.UpdateProduct(id, request); + + if (entity is null) + { + throw new ApiNotFoundException($"Product with id '{id}' was not found."); + } + + return entity.ToDto(); + } +} diff --git a/EndavaTask/appsettings.Development.json b/EndavaTask/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/EndavaTask/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/EndavaTask/appsettings.json b/EndavaTask/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/EndavaTask/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}