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