Add FluentValidation

This commit is contained in:
lumijiez
2025-06-10 00:23:11 +03:00
parent dd7eeb9eea
commit 10e3bedeea
18 changed files with 155 additions and 14 deletions

View File

@@ -8,8 +8,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper" Version="14.0.0" /> <PackageReference Include="AutoMapper" Version="14.0.0" />
<PackageReference Include="FluentValidation" Version="12.0.0-preview1" /> <PackageReference Include="FluentValidation" Version="12.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="MediatR" Version="12.5.0" /> <PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.4" />
</ItemGroup> </ItemGroup>
@@ -17,4 +19,8 @@
<ProjectReference Include="..\Imprink.Domain\Imprink.Domain.csproj" /> <ProjectReference Include="..\Imprink.Domain\Imprink.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Validation\Users\" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,5 +1,5 @@
using Imprink.Application.Products.Dtos; using Imprink.Application.Products.Dtos;
using Imprink.Domain.Common.Models; using Imprink.Domain.Models;
using MediatR; using MediatR;
namespace Imprink.Application.Products; namespace Imprink.Application.Products;

View File

@@ -1,4 +1,4 @@
using Imprink.Domain.Common.Models; using Imprink.Domain.Models;
using MediatR; using MediatR;
namespace Imprink.Application.Users; namespace Imprink.Application.Users;

View File

@@ -0,0 +1,23 @@
using FluentValidation;
using Imprink.Domain.Models;
namespace Imprink.Application.Validation.Models;
public class Auth0UserValidator : AbstractValidator<Auth0User>
{
public Auth0UserValidator()
{
RuleFor(x => x.Sub)
.NotEmpty();
RuleFor(x => x.Name)
.NotEmpty();
RuleFor(x => x.Nickname)
.NotEmpty();
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress();
}
}

View File

@@ -0,0 +1,45 @@
using FluentValidation;
using Imprink.Domain.Models;
namespace Imprink.Application.Validation.Models;
public class ProductFilterParametersValidator : AbstractValidator<ProductFilterParameters>
{
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"];
}

View File

@@ -0,0 +1,15 @@
using FluentValidation;
using Imprink.Application.Products;
using Imprink.Application.Validation.Models;
namespace Imprink.Application.Validation.Products;
public class GetProductsQueryValidator : AbstractValidator<GetProductsQuery>
{
public GetProductsQueryValidator()
{
RuleFor(x => x.FilterParameters)
.NotNull()
.SetValidator(new ProductFilterParametersValidator());
}
}

View File

@@ -1,4 +1,4 @@
namespace Imprink.Domain.Common.Models; namespace Imprink.Domain.Models;
public class Auth0User public class Auth0User
{ {

View File

@@ -1,4 +1,4 @@
namespace Imprink.Domain.Common.Models; namespace Imprink.Domain.Models;
public class PagedResult<T> public class PagedResult<T>
{ {

View File

@@ -1,4 +1,4 @@
namespace Imprink.Domain.Common.Models; namespace Imprink.Domain.Models;
public class ProductFilterParameters public class ProductFilterParameters
{ {

View File

@@ -1,5 +1,5 @@
using Imprink.Domain.Common.Models;
using Imprink.Domain.Entities.Product; using Imprink.Domain.Entities.Product;
using Imprink.Domain.Models;
namespace Imprink.Domain.Repositories; namespace Imprink.Domain.Repositories;

View File

@@ -1,5 +1,5 @@
using Imprink.Domain.Common.Models;
using Imprink.Domain.Entities.Users; using Imprink.Domain.Entities.Users;
using Imprink.Domain.Models;
namespace Imprink.Domain.Repositories; namespace Imprink.Domain.Repositories;

View File

@@ -1,5 +1,5 @@
using Imprink.Domain.Common.Models;
using Imprink.Domain.Entities.Product; using Imprink.Domain.Entities.Product;
using Imprink.Domain.Models;
using Imprink.Domain.Repositories; using Imprink.Domain.Repositories;
using Imprink.Infrastructure.Database; using Imprink.Infrastructure.Database;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;

View File

@@ -1,5 +1,5 @@
using Imprink.Domain.Common.Models;
using Imprink.Domain.Entities.Users; using Imprink.Domain.Entities.Users;
using Imprink.Domain.Models;
using Imprink.Domain.Repositories; using Imprink.Domain.Repositories;
using Imprink.Infrastructure.Database; using Imprink.Infrastructure.Database;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;

View File

@@ -1,6 +1,6 @@
using Imprink.Application.Products; using Imprink.Application.Products;
using Imprink.Application.Products.Dtos; using Imprink.Application.Products.Dtos;
using Imprink.Domain.Common.Models; using Imprink.Domain.Models;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View File

@@ -1,6 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Imprink.Application.Users; using Imprink.Application.Users;
using Imprink.Domain.Common.Models; using Imprink.Domain.Models;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View File

@@ -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<IValidator>();
var validatorList = validators as IValidator[] ?? validators.ToArray();
if (validatorList.Length == 0) continue;
var validationContext = new ValidationContext<object>(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();
}
}

View File

@@ -8,6 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation" Version="12.0.0" />
<PackageReference Include="MediatR" Version="12.5.0" /> <PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />

View File

@@ -1,10 +1,13 @@
using System.Security.Claims; using System.Security.Claims;
using FluentValidation;
using Imprink.Application; using Imprink.Application;
using Imprink.Application.Products.Create; using Imprink.Application.Products.Create;
using Imprink.Application.Validation.Models;
using Imprink.Domain.Repositories; using Imprink.Domain.Repositories;
using Imprink.Infrastructure; using Imprink.Infrastructure;
using Imprink.Infrastructure.Database; using Imprink.Infrastructure.Database;
using Imprink.Infrastructure.Repositories; using Imprink.Infrastructure.Repositories;
using Imprink.WebApi.Filters;
using Imprink.WebApi.Middleware; using Imprink.WebApi.Middleware;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -37,11 +40,14 @@ public static class Startup
cfg.RegisterServicesFromAssembly(typeof(CreateProductHandler).Assembly); cfg.RegisterServicesFromAssembly(typeof(CreateProductHandler).Assembly);
}); });
services.AddValidatorsFromAssembly(typeof(Auth0UserValidator).Assembly);
services.AddAuthentication(options => services.AddAuthentication(options =>
{ {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options => })
.AddJwtBearer(options =>
{ {
options.Authority = builder.Configuration["Auth0:Authority"]; options.Authority = builder.Configuration["Auth0:Authority"];
options.Audience = builder.Configuration["Auth0:Audience"]; options.Audience = builder.Configuration["Auth0:Audience"];
@@ -78,7 +84,11 @@ public static class Startup
services.AddAuthorization(); services.AddAuthorization();
services.AddControllers(); services.AddControllers(options =>
{
options.Filters.Add<ValidationActionFilter>();
});
services.AddSwaggerGen(); services.AddSwaggerGen();
} }