diff --git a/src/Imprink.Application/Imprink.Application.csproj b/src/Imprink.Application/Imprink.Application.csproj index 7d1c2ec..b274257 100644 --- a/src/Imprink.Application/Imprink.Application.csproj +++ b/src/Imprink.Application/Imprink.Application.csproj @@ -8,8 +8,10 @@ - + + + @@ -17,4 +19,8 @@ + + + + diff --git a/src/Imprink.Application/Products/GetProductsHandler.cs b/src/Imprink.Application/Products/GetProductsHandler.cs index 48b5cb0..afd64cc 100644 --- a/src/Imprink.Application/Products/GetProductsHandler.cs +++ b/src/Imprink.Application/Products/GetProductsHandler.cs @@ -1,5 +1,5 @@ using Imprink.Application.Products.Dtos; -using Imprink.Domain.Common.Models; +using Imprink.Domain.Models; using MediatR; namespace Imprink.Application.Products; diff --git a/src/Imprink.Application/Users/SyncUserHandler.cs b/src/Imprink.Application/Users/SyncUserHandler.cs index 2a73cae..aadbf43 100644 --- a/src/Imprink.Application/Users/SyncUserHandler.cs +++ b/src/Imprink.Application/Users/SyncUserHandler.cs @@ -1,4 +1,4 @@ -using Imprink.Domain.Common.Models; +using Imprink.Domain.Models; using MediatR; namespace Imprink.Application.Users; diff --git a/src/Imprink.Application/Validation/Models/Auth0UserValidator.cs b/src/Imprink.Application/Validation/Models/Auth0UserValidator.cs new file mode 100644 index 0000000..4df6070 --- /dev/null +++ b/src/Imprink.Application/Validation/Models/Auth0UserValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Imprink.Domain.Models; + +namespace Imprink.Application.Validation.Models; + +public class Auth0UserValidator : AbstractValidator +{ + public Auth0UserValidator() + { + RuleFor(x => x.Sub) + .NotEmpty(); + + RuleFor(x => x.Name) + .NotEmpty(); + + RuleFor(x => x.Nickname) + .NotEmpty(); + + RuleFor(x => x.Email) + .NotEmpty() + .EmailAddress(); + } +} \ No newline at end of file diff --git a/src/Imprink.Application/Validation/Models/ProductFilterParametersValidator.cs b/src/Imprink.Application/Validation/Models/ProductFilterParametersValidator.cs new file mode 100644 index 0000000..08131c9 --- /dev/null +++ b/src/Imprink.Application/Validation/Models/ProductFilterParametersValidator.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using Imprink.Domain.Models; + +namespace Imprink.Application.Validation.Models; + +public class ProductFilterParametersValidator : AbstractValidator +{ + public ProductFilterParametersValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThan(0).WithMessage("PageNumber must be greater than 0."); + + RuleFor(x => x.PageSize) + .InclusiveBetween(1, 100).WithMessage("PageSize must be between 1 and 100."); + + RuleFor(x => x.SearchTerm) + .Length(1, 100).WithMessage("Length must be between 1 and 100.") + .When(x => !string.IsNullOrWhiteSpace(x.SearchTerm)); + + RuleFor(x => x.MinPrice) + .GreaterThanOrEqualTo(0).When(x => x.MinPrice.HasValue) + .WithMessage("MinPrice cannot be negative."); + + RuleFor(x => x.MaxPrice) + .GreaterThanOrEqualTo(0).When(x => x.MaxPrice.HasValue) + .WithMessage("MaxPrice cannot be negative."); + + RuleFor(x => x) + .Must(x => !x.MinPrice.HasValue || !x.MaxPrice.HasValue || x.MinPrice <= x.MaxPrice) + .WithMessage("MinPrice cannot be greater than MaxPrice."); + + RuleFor(x => x.SortBy) + .NotEmpty().WithMessage("SortBy is required.") + .Must(value => AllowedSortColumns.Contains(value, StringComparer.OrdinalIgnoreCase)) + .WithMessage("SortBy must be one of: Name, Price, CreatedDate."); + + RuleFor(x => x.SortDirection) + .NotEmpty().WithMessage("SortDirection is required.") + .Must(value => value.Equals("ASC", StringComparison.OrdinalIgnoreCase) + || value.Equals("DESC", StringComparison.OrdinalIgnoreCase)) + .WithMessage("SortDirection must be 'ASC' or 'DESC'."); + } + + private static readonly string[] AllowedSortColumns = ["Name", "Price", "CreatedDate"]; +} \ No newline at end of file diff --git a/src/Imprink.Application/Validation/Products/GetProductsQueryValidator.cs b/src/Imprink.Application/Validation/Products/GetProductsQueryValidator.cs new file mode 100644 index 0000000..b888657 --- /dev/null +++ b/src/Imprink.Application/Validation/Products/GetProductsQueryValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Imprink.Application.Products; +using Imprink.Application.Validation.Models; + +namespace Imprink.Application.Validation.Products; + +public class GetProductsQueryValidator : AbstractValidator +{ + public GetProductsQueryValidator() + { + RuleFor(x => x.FilterParameters) + .NotNull() + .SetValidator(new ProductFilterParametersValidator()); + } +} \ No newline at end of file diff --git a/src/Imprink.Domain/Common/Models/Auth0User.cs b/src/Imprink.Domain/Models/Auth0User.cs similarity index 86% rename from src/Imprink.Domain/Common/Models/Auth0User.cs rename to src/Imprink.Domain/Models/Auth0User.cs index 687980e..54f8ca6 100644 --- a/src/Imprink.Domain/Common/Models/Auth0User.cs +++ b/src/Imprink.Domain/Models/Auth0User.cs @@ -1,4 +1,4 @@ -namespace Imprink.Domain.Common.Models; +namespace Imprink.Domain.Models; public class Auth0User { diff --git a/src/Imprink.Domain/Common/Models/PagedResult.cs b/src/Imprink.Domain/Models/PagedResult.cs similarity index 90% rename from src/Imprink.Domain/Common/Models/PagedResult.cs rename to src/Imprink.Domain/Models/PagedResult.cs index cde7b5a..214eb90 100644 --- a/src/Imprink.Domain/Common/Models/PagedResult.cs +++ b/src/Imprink.Domain/Models/PagedResult.cs @@ -1,4 +1,4 @@ -namespace Imprink.Domain.Common.Models; +namespace Imprink.Domain.Models; public class PagedResult { diff --git a/src/Imprink.Domain/Common/Models/ProductFilterParameters.cs b/src/Imprink.Domain/Models/ProductFilterParameters.cs similarity index 92% rename from src/Imprink.Domain/Common/Models/ProductFilterParameters.cs rename to src/Imprink.Domain/Models/ProductFilterParameters.cs index 4631894..e932405 100644 --- a/src/Imprink.Domain/Common/Models/ProductFilterParameters.cs +++ b/src/Imprink.Domain/Models/ProductFilterParameters.cs @@ -1,4 +1,4 @@ -namespace Imprink.Domain.Common.Models; +namespace Imprink.Domain.Models; public class ProductFilterParameters { diff --git a/src/Imprink.Domain/Repositories/Products/IProductRepository.cs b/src/Imprink.Domain/Repositories/Products/IProductRepository.cs index 38eaee9..f77d96d 100644 --- a/src/Imprink.Domain/Repositories/Products/IProductRepository.cs +++ b/src/Imprink.Domain/Repositories/Products/IProductRepository.cs @@ -1,5 +1,5 @@ -using Imprink.Domain.Common.Models; using Imprink.Domain.Entities.Product; +using Imprink.Domain.Models; namespace Imprink.Domain.Repositories; diff --git a/src/Imprink.Domain/Repositories/Users/IUserRepository.cs b/src/Imprink.Domain/Repositories/Users/IUserRepository.cs index e5a0231..5809d58 100644 --- a/src/Imprink.Domain/Repositories/Users/IUserRepository.cs +++ b/src/Imprink.Domain/Repositories/Users/IUserRepository.cs @@ -1,5 +1,5 @@ -using Imprink.Domain.Common.Models; using Imprink.Domain.Entities.Users; +using Imprink.Domain.Models; namespace Imprink.Domain.Repositories; diff --git a/src/Imprink.Infrastructure/Repositories/Products/ProductRepository.cs b/src/Imprink.Infrastructure/Repositories/Products/ProductRepository.cs index f0bf4c7..72fbc4a 100644 --- a/src/Imprink.Infrastructure/Repositories/Products/ProductRepository.cs +++ b/src/Imprink.Infrastructure/Repositories/Products/ProductRepository.cs @@ -1,5 +1,5 @@ -using Imprink.Domain.Common.Models; using Imprink.Domain.Entities.Product; +using Imprink.Domain.Models; using Imprink.Domain.Repositories; using Imprink.Infrastructure.Database; using Microsoft.EntityFrameworkCore; diff --git a/src/Imprink.Infrastructure/Repositories/Users/UserRepository.cs b/src/Imprink.Infrastructure/Repositories/Users/UserRepository.cs index abf12d9..5ba761b 100644 --- a/src/Imprink.Infrastructure/Repositories/Users/UserRepository.cs +++ b/src/Imprink.Infrastructure/Repositories/Users/UserRepository.cs @@ -1,5 +1,5 @@ -using Imprink.Domain.Common.Models; using Imprink.Domain.Entities.Users; +using Imprink.Domain.Models; using Imprink.Domain.Repositories; using Imprink.Infrastructure.Database; using Microsoft.EntityFrameworkCore; diff --git a/src/Imprink.WebApi/Controllers/Products/ProductsController.cs b/src/Imprink.WebApi/Controllers/Products/ProductsController.cs index da950d7..63eb0f6 100644 --- a/src/Imprink.WebApi/Controllers/Products/ProductsController.cs +++ b/src/Imprink.WebApi/Controllers/Products/ProductsController.cs @@ -1,6 +1,6 @@ using Imprink.Application.Products; using Imprink.Application.Products.Dtos; -using Imprink.Domain.Common.Models; +using Imprink.Domain.Models; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Imprink.WebApi/Controllers/Users/UserController.cs b/src/Imprink.WebApi/Controllers/Users/UserController.cs index f41e26e..75fba37 100644 --- a/src/Imprink.WebApi/Controllers/Users/UserController.cs +++ b/src/Imprink.WebApi/Controllers/Users/UserController.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using Imprink.Application.Users; -using Imprink.Domain.Common.Models; +using Imprink.Domain.Models; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Imprink.WebApi/Filters/ValidationActionFilter.cs b/src/Imprink.WebApi/Filters/ValidationActionFilter.cs new file mode 100644 index 0000000..ee678b9 --- /dev/null +++ b/src/Imprink.WebApi/Filters/ValidationActionFilter.cs @@ -0,0 +1,41 @@ +namespace Imprink.WebApi.Filters; + +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +public class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + foreach (var parameter in context.ActionDescriptor.Parameters) + { + if (!context.ActionArguments.TryGetValue(parameter.Name, out var argument) || argument == null) continue; + var argumentType = argument.GetType(); + var validatorType = typeof(IValidator<>).MakeGenericType(argumentType); + + var validators = serviceProvider.GetServices(validatorType).Cast(); + + var validatorList = validators as IValidator[] ?? validators.ToArray(); + if (validatorList.Length == 0) continue; + var validationContext = new ValidationContext(argument); + var validationTasks = validatorList.Select(v => v.ValidateAsync(validationContext)); + var validationResults = await Task.WhenAll(validationTasks); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count <= 0) continue; + var errors = failures.Select(e => new { + e.PropertyName, e.ErrorMessage + }); + + context.Result = new BadRequestObjectResult(new { Errors = errors }); + return; + } + + await next(); + } +} \ No newline at end of file diff --git a/src/Imprink.WebApi/Imprink.WebApi.csproj b/src/Imprink.WebApi/Imprink.WebApi.csproj index a49bd5c..f880fdd 100644 --- a/src/Imprink.WebApi/Imprink.WebApi.csproj +++ b/src/Imprink.WebApi/Imprink.WebApi.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Imprink.WebApi/Startup.cs b/src/Imprink.WebApi/Startup.cs index 6cc1243..8e6e29d 100644 --- a/src/Imprink.WebApi/Startup.cs +++ b/src/Imprink.WebApi/Startup.cs @@ -1,10 +1,13 @@ using System.Security.Claims; +using FluentValidation; using Imprink.Application; using Imprink.Application.Products.Create; +using Imprink.Application.Validation.Models; using Imprink.Domain.Repositories; using Imprink.Infrastructure; using Imprink.Infrastructure.Database; using Imprink.Infrastructure.Repositories; +using Imprink.WebApi.Filters; using Imprink.WebApi.Middleware; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; @@ -37,11 +40,14 @@ public static class Startup cfg.RegisterServicesFromAssembly(typeof(CreateProductHandler).Assembly); }); + services.AddValidatorsFromAssembly(typeof(Auth0UserValidator).Assembly); + services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }).AddJwtBearer(options => + }) + .AddJwtBearer(options => { options.Authority = builder.Configuration["Auth0:Authority"]; options.Audience = builder.Configuration["Auth0:Audience"]; @@ -78,7 +84,11 @@ public static class Startup services.AddAuthorization(); - services.AddControllers(); + services.AddControllers(options => + { + options.Filters.Add(); + }); + services.AddSwaggerGen(); }