Add initial implementation of product management API
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
||||||
21
EndavaTask.Tests/EndavaTask.Tests.csproj
Normal file
21
EndavaTask.Tests/EndavaTask.Tests.csproj
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.0" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EndavaTask\EndavaTask.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
85
EndavaTask.Tests/EntityDtoMappingsTests.cs
Normal file
85
EndavaTask.Tests/EntityDtoMappingsTests.cs
Normal file
@@ -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<Product>
|
||||||
|
{
|
||||||
|
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<ProductDto>(dtoPaged.Items.First());
|
||||||
|
Assert.Equal("Laptop", first.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
EndavaTask.Tests/InMemoryProductRepositoryTests.cs
Normal file
44
EndavaTask.Tests/InMemoryProductRepositoryTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
EndavaTask.Tests/ProductServiceTests.cs
Normal file
90
EndavaTask.Tests/ProductServiceTests.cs
Normal file
@@ -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<IProductRepository>();
|
||||||
|
|
||||||
|
repositoryMock
|
||||||
|
.Setup(r => r.CategoryExists(categoryId))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
repositoryMock
|
||||||
|
.Setup(r => r.GetFilteredProducts(It.IsAny<ProductFilterQuery>(), It.IsAny<PaginationQuery>()))
|
||||||
|
.Returns(new PagedResult<Product>
|
||||||
|
{
|
||||||
|
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<PagedResult<ProductDto>>(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<IProductRepository>();
|
||||||
|
|
||||||
|
repositoryMock
|
||||||
|
.Setup(r => r.UpdateProduct(productId, It.IsAny<UpdateProductRequest>()))
|
||||||
|
.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<ProductDto>(result);
|
||||||
|
|
||||||
|
Assert.Equal("Updated Monitor", value.Name);
|
||||||
|
Assert.Equal(350m, value.Price);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetProducts_WithUnknownCategory_ThrowsValidationException()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IProductRepository>();
|
||||||
|
|
||||||
|
repositoryMock
|
||||||
|
.Setup(r => r.CategoryExists(It.IsAny<Guid>()))
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
var service = new ProductService(repositoryMock.Object);
|
||||||
|
|
||||||
|
var exception = Assert.Throws<ApiValidationException>(() =>
|
||||||
|
service.GetProducts(new ProductFilterQuery { CategoryId = Guid.NewGuid() }, new PaginationQuery()));
|
||||||
|
|
||||||
|
Assert.True(exception.Errors.ContainsKey(nameof(ProductFilterQuery.CategoryId)));
|
||||||
|
}
|
||||||
|
}
|
||||||
22
EndavaTask.sln
Normal file
22
EndavaTask.sln
Normal file
@@ -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
|
||||||
32
EndavaTask/Contracts/PagedResult.cs
Normal file
32
EndavaTask/Contracts/PagedResult.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace EndavaTask.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic paged response container.
|
||||||
|
/// </summary>
|
||||||
|
public class PagedResult<T>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Items returned for the current page.
|
||||||
|
/// </summary>
|
||||||
|
public required IReadOnlyCollection<T> Items { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current page number.
|
||||||
|
/// </summary>
|
||||||
|
public required int PageNumber { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requested page size.
|
||||||
|
/// </summary>
|
||||||
|
public required int PageSize { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total number of items matching the filter.
|
||||||
|
/// </summary>
|
||||||
|
public required int TotalCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total number of pages for the current page size.
|
||||||
|
/// </summary>
|
||||||
|
public required int TotalPages { get; init; }
|
||||||
|
}
|
||||||
21
EndavaTask/Contracts/PaginationQuery.cs
Normal file
21
EndavaTask/Contracts/PaginationQuery.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace EndavaTask.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard pagination query parameters.
|
||||||
|
/// </summary>
|
||||||
|
public class PaginationQuery
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Page number to return, starting from 1.
|
||||||
|
/// </summary>
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int PageNumber { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of items per page. Max allowed value is 100.
|
||||||
|
/// </summary>
|
||||||
|
[Range(1, 100)]
|
||||||
|
public int PageSize { get; init; } = 10;
|
||||||
|
}
|
||||||
49
EndavaTask/Contracts/ProductFilterQuery.cs
Normal file
49
EndavaTask/Contracts/ProductFilterQuery.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace EndavaTask.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query parameters used to filter products.
|
||||||
|
/// </summary>
|
||||||
|
public class ProductFilterQuery : IValidatableObject
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns products whose name contains this value (case-insensitive).
|
||||||
|
/// </summary>
|
||||||
|
[StringLength(100, MinimumLength = 1)]
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns products that belong to the specified category id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? CategoryId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum product price (inclusive).
|
||||||
|
/// </summary>
|
||||||
|
[Range(0, double.MaxValue)]
|
||||||
|
public decimal? MinPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum product price (inclusive).
|
||||||
|
/// </summary>
|
||||||
|
[Range(0, double.MaxValue)]
|
||||||
|
public decimal? MaxPrice { get; init; }
|
||||||
|
|
||||||
|
public IEnumerable<ValidationResult> 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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
EndavaTask/Contracts/UpdateProductRequest.cs
Normal file
21
EndavaTask/Contracts/UpdateProductRequest.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace EndavaTask.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request payload for updating a product.
|
||||||
|
/// </summary>
|
||||||
|
public class UpdateProductRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// New product name.
|
||||||
|
/// </summary>
|
||||||
|
[StringLength(100, MinimumLength = 1)]
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// New product price.
|
||||||
|
/// </summary>
|
||||||
|
[Range(0, double.MaxValue)]
|
||||||
|
public decimal? Price { get; init; }
|
||||||
|
}
|
||||||
40
EndavaTask/Controllers/ProductsController.cs
Normal file
40
EndavaTask/Controllers/ProductsController.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns products filtered by optional criteria and paginated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filter">Filtering parameters such as name, category and price range.</param>
|
||||||
|
/// <param name="pagination">Pagination parameters.</param>
|
||||||
|
/// <returns>A paged list of products that match the filter.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
||||||
|
public ActionResult<PagedResult<ProductDto>> GetProducts([FromQuery] ProductFilterQuery filter, [FromQuery] PaginationQuery pagination)
|
||||||
|
{
|
||||||
|
return Ok(productService.GetProducts(filter, pagination));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing product's name and/or price.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Product identifier.</param>
|
||||||
|
/// <param name="request">Fields to update.</param>
|
||||||
|
/// <returns>The updated product.</returns>
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
||||||
|
public ActionResult<ProductDto> UpdateProduct(Guid id, [FromBody] UpdateProductRequest request)
|
||||||
|
{
|
||||||
|
return Ok(productService.UpdateProduct(id, request));
|
||||||
|
}
|
||||||
|
}
|
||||||
22
EndavaTask/Dtos/CategoryDto.cs
Normal file
22
EndavaTask/Dtos/CategoryDto.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace EndavaTask.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category data transfer object.
|
||||||
|
/// </summary>
|
||||||
|
public class CategoryDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique category identifier.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category name.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category description.
|
||||||
|
/// </summary>
|
||||||
|
public required string Description { get; init; }
|
||||||
|
}
|
||||||
27
EndavaTask/Dtos/ProductDto.cs
Normal file
27
EndavaTask/Dtos/ProductDto.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace EndavaTask.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product data transfer object.
|
||||||
|
/// </summary>
|
||||||
|
public class ProductDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique product identifier.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product name.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product price.
|
||||||
|
/// </summary>
|
||||||
|
public decimal Price { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifier of the category this product belongs to.
|
||||||
|
/// </summary>
|
||||||
|
public Guid CategoryId { get; init; }
|
||||||
|
}
|
||||||
14
EndavaTask/EndavaTask.csproj
Normal file
14
EndavaTask/EndavaTask.csproj
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
14
EndavaTask/EndavaTask.http
Normal file
14
EndavaTask/EndavaTask.http
Normal file
@@ -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
|
||||||
|
}
|
||||||
3
EndavaTask/Exceptions/ApiNotFoundException.cs
Normal file
3
EndavaTask/Exceptions/ApiNotFoundException.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace EndavaTask.Exceptions;
|
||||||
|
|
||||||
|
public class ApiNotFoundException(string message) : Exception(message);
|
||||||
6
EndavaTask/Exceptions/ApiValidationException.cs
Normal file
6
EndavaTask/Exceptions/ApiValidationException.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace EndavaTask.Exceptions;
|
||||||
|
|
||||||
|
public class ApiValidationException(IReadOnlyDictionary<string, string[]> errors) : Exception("Validation failed.")
|
||||||
|
{
|
||||||
|
public IReadOnlyDictionary<string, string[]> Errors { get; } = errors;
|
||||||
|
}
|
||||||
27
EndavaTask/Extensions/PaginationExtensions.cs
Normal file
27
EndavaTask/Extensions/PaginationExtensions.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using EndavaTask.Contracts;
|
||||||
|
|
||||||
|
namespace EndavaTask.Extensions;
|
||||||
|
|
||||||
|
public static class PaginationExtensions
|
||||||
|
{
|
||||||
|
public static PagedResult<T> ToPagedResult<T>(this IEnumerable<T> 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<T>
|
||||||
|
{
|
||||||
|
Items = pagedItems,
|
||||||
|
PageNumber = pagination.PageNumber,
|
||||||
|
PageSize = pagination.PageSize,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
TotalPages = totalPages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
62
EndavaTask/Mappings/EntityDtoMappings.cs
Normal file
62
EndavaTask/Mappings/EntityDtoMappings.cs
Normal file
@@ -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<ProductDto> ToDto(this PagedResult<Product> pagedResult)
|
||||||
|
{
|
||||||
|
return new PagedResult<ProductDto>
|
||||||
|
{
|
||||||
|
Items = pagedResult.Items.Select(i => i.ToDto()).ToArray(),
|
||||||
|
PageNumber = pagedResult.PageNumber,
|
||||||
|
PageSize = pagedResult.PageSize,
|
||||||
|
TotalCount = pagedResult.TotalCount,
|
||||||
|
TotalPages = pagedResult.TotalPages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
72
EndavaTask/Middleware/ExceptionHandlingMiddleware.cs
Normal file
72
EndavaTask/Middleware/ExceptionHandlingMiddleware.cs
Normal file
@@ -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<ExceptionHandlingMiddleware> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EndavaTask.Middleware;
|
||||||
|
|
||||||
|
public static class ExceptionHandlingMiddlewareExtensions
|
||||||
|
{
|
||||||
|
public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
return app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
EndavaTask/Models/Category.cs
Normal file
22
EndavaTask/Models/Category.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace EndavaTask.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product category entity.
|
||||||
|
/// </summary>
|
||||||
|
public class Category
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique category identifier.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category name.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category description.
|
||||||
|
/// </summary>
|
||||||
|
public required string Description { get; init; }
|
||||||
|
}
|
||||||
27
EndavaTask/Models/Product.cs
Normal file
27
EndavaTask/Models/Product.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace EndavaTask.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product entity.
|
||||||
|
/// </summary>
|
||||||
|
public class Product
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique product identifier.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product name.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product price.
|
||||||
|
/// </summary>
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifier of the category this product belongs to.
|
||||||
|
/// </summary>
|
||||||
|
public Guid CategoryId { get; init; }
|
||||||
|
}
|
||||||
32
EndavaTask/Program.cs
Normal file
32
EndavaTask/Program.cs
Normal file
@@ -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<IProductRepository, InMemoryProductRepository>();
|
||||||
|
builder.Services.AddScoped<IProductService, ProductService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseGlobalExceptionHandling();
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
25
EndavaTask/Properties/launchSettings.json
Normal file
25
EndavaTask/Properties/launchSettings.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
EndavaTask/Repositories/InMemoryProductRepository.cs
Normal file
78
EndavaTask/Repositories/InMemoryProductRepository.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using EndavaTask.Contracts;
|
||||||
|
using EndavaTask.Extensions;
|
||||||
|
using EndavaTask.Models;
|
||||||
|
|
||||||
|
namespace EndavaTask.Data;
|
||||||
|
|
||||||
|
public class InMemoryProductRepository : IProductRepository
|
||||||
|
{
|
||||||
|
private readonly List<Category> _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<Product> _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<Product> GetFilteredProducts(ProductFilterQuery filter, PaginationQuery pagination)
|
||||||
|
{
|
||||||
|
IEnumerable<Product> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
EndavaTask/Repositories/Interfaces/IProductRepository.cs
Normal file
11
EndavaTask/Repositories/Interfaces/IProductRepository.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using EndavaTask.Contracts;
|
||||||
|
using EndavaTask.Models;
|
||||||
|
|
||||||
|
namespace EndavaTask.Data;
|
||||||
|
|
||||||
|
public interface IProductRepository
|
||||||
|
{
|
||||||
|
PagedResult<Product> GetFilteredProducts(ProductFilterQuery filter, PaginationQuery pagination);
|
||||||
|
Product? UpdateProduct(Guid id, UpdateProductRequest request);
|
||||||
|
bool CategoryExists(Guid categoryId);
|
||||||
|
}
|
||||||
10
EndavaTask/Services/Interfaces/IProductService.cs
Normal file
10
EndavaTask/Services/Interfaces/IProductService.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using EndavaTask.Contracts;
|
||||||
|
using EndavaTask.Dtos;
|
||||||
|
|
||||||
|
namespace EndavaTask.Services;
|
||||||
|
|
||||||
|
public interface IProductService
|
||||||
|
{
|
||||||
|
PagedResult<ProductDto> GetProducts(ProductFilterQuery filter, PaginationQuery pagination);
|
||||||
|
ProductDto UpdateProduct(Guid id, UpdateProductRequest request);
|
||||||
|
}
|
||||||
61
EndavaTask/Services/ProductService.cs
Normal file
61
EndavaTask/Services/ProductService.cs
Normal file
@@ -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<ProductDto> GetProducts(ProductFilterQuery filter, PaginationQuery pagination)
|
||||||
|
{
|
||||||
|
var errors = new Dictionary<string, string[]>();
|
||||||
|
|
||||||
|
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<string, string[]>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
EndavaTask/appsettings.Development.json
Normal file
8
EndavaTask/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
EndavaTask/appsettings.json
Normal file
9
EndavaTask/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user