Add initial implementation of product management API

This commit is contained in:
2026-02-21 23:51:01 +01:00
commit 8e1b023303
31 changed files with 969 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

View 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>

View 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);
}
}

View 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);
}
}

View 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
View 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

View 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; }
}

View 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;
}

View 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)]);
}
}
}

View 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; }
}

View 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));
}
}

View 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; }
}

View 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; }
}

View 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>

View 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
}

View File

@@ -0,0 +1,3 @@
namespace EndavaTask.Exceptions;
public class ApiNotFoundException(string message) : Exception(message);

View 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;
}

View 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
};
}
}

View 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
};
}
}

View 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
};
}
}
}

View File

@@ -0,0 +1,9 @@
namespace EndavaTask.Middleware;
public static class ExceptionHandlingMiddlewareExtensions
{
public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder app)
{
return app.UseMiddleware<ExceptionHandlingMiddleware>();
}
}

View 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; }
}

View 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
View 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();

View 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"
}
}
}
}

View 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);
}
}

View 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);
}

View 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);
}

View 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();
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}