From 9cd27e213b4c9f46c833c1b31ae1234616877021 Mon Sep 17 00:00:00 2001 From: "cubecraft-agents[bot]" <3458173+cubecraft-agents[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:26:26 +0000 Subject: [PATCH] CUB-30: Implement PUT /filaments/{id} update endpoint - Add FluentValidation validators for CreateFilamentRequest and UpdateFilamentRequest with comprehensive validation rules (required fields, string lengths, hex color format, weight constraints including WeightRemainingGrams <= WeightTotalGrams, purchase price range) - Add FluentValidationFilter action filter that auto-runs FluentValidation validators for all API controller actions before execution, returning 400 with structured error details - Register FluentValidationFilter in DI and add it to MVC controller filters in Program.cs - PUT endpoint was already implemented in FilamentsController with proper validation, 404 handling, FK existence checks, serial uniqueness check, and weight constraint check - This change ensures FluentValidation rules are enforced consistently via the pipeline --- backend/API/Filters/FluentValidationFilter.cs | 69 +++++++++++ backend/API/Validators/FilamentValidators.cs | 108 ++++++++++++++++++ backend/Program.cs | 10 +- 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 backend/API/Filters/FluentValidationFilter.cs create mode 100644 backend/API/Validators/FilamentValidators.cs diff --git a/backend/API/Filters/FluentValidationFilter.cs b/backend/API/Filters/FluentValidationFilter.cs new file mode 100644 index 0000000..58d89f0 --- /dev/null +++ b/backend/API/Filters/FluentValidationFilter.cs @@ -0,0 +1,69 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Extrudex.API.Filters; + +/// +/// Action filter that automatically validates request DTOs using FluentValidation +/// validators registered in DI. Runs before the controller action executes. +/// Returns 400 Bad Request with validation errors if validation fails. +/// +public class FluentValidationFilter : IAsyncActionFilter +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public FluentValidationFilter(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + foreach (var argument in context.ActionArguments.Values) + { + if (argument is null) continue; + + var argumentType = argument.GetType(); + var validatorType = typeof(IValidator<>).MakeGenericType(argumentType); + + // Try to resolve a validator for this argument type + var validator = _serviceProvider.GetService(validatorType) as IValidator; + if (validator is null) continue; + + _logger.LogDebug("Validating {Type} with {Validator}", argumentType.Name, validator.GetType().Name); + + var validationResult = await validator.ValidateAsync( + new ValidationContext(argument), context.HttpContext.RequestAborted); + + if (!validationResult.IsValid) + { + foreach (var error in validationResult.Errors) + { + context.ModelState.AddModelError(error.PropertyName, error.ErrorMessage); + } + } + } + + if (!context.ModelState.IsValid) + { + var errors = context.ModelState + .Where(kvp => kvp.Value?.Errors.Count > 0) + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()); + + context.Result = new BadRequestObjectResult(new + { + title = "Validation failed", + status = 400, + errors + }); + return; + } + + await next(); + } +} \ No newline at end of file diff --git a/backend/API/Validators/FilamentValidators.cs b/backend/API/Validators/FilamentValidators.cs new file mode 100644 index 0000000..8fe0f18 --- /dev/null +++ b/backend/API/Validators/FilamentValidators.cs @@ -0,0 +1,108 @@ +using Extrudex.API.DTOs.Filaments; +using FluentValidation; + +namespace Extrudex.API.Validators; + +/// +/// Validation rules for creating a Filament (Spool) via the /filaments route. +/// Mirrors the domain rules enforced in the controller and ensures consistent +/// validation regardless of the request pipeline entry point. +/// +public class CreateFilamentRequestValidator : AbstractValidator +{ + public CreateFilamentRequestValidator() + { + RuleFor(x => x.MaterialBaseId) + .NotEmpty().WithMessage("MaterialBaseId is required."); + + RuleFor(x => x.MaterialFinishId) + .NotEmpty().WithMessage("MaterialFinishId is required."); + + RuleFor(x => x.Brand) + .NotEmpty().WithMessage("Brand is required.") + .MaximumLength(200).WithMessage("Brand must not exceed 200 characters."); + + RuleFor(x => x.ColorName) + .NotEmpty().WithMessage("ColorName is required.") + .MaximumLength(200).WithMessage("ColorName must not exceed 200 characters."); + + RuleFor(x => x.ColorHex) + .NotEmpty().WithMessage("ColorHex is required.") + .Matches(@"^#[0-9A-Fa-f]{6}$").WithMessage("ColorHex must be a valid hex color code (e.g., #FF0000)."); + + RuleFor(x => x.WeightTotalGrams) + .GreaterThan(0).WithMessage("Total weight must be greater than zero."); + + RuleFor(x => x.WeightRemainingGrams) + .GreaterThanOrEqualTo(0).WithMessage("Remaining weight must be non-negative."); + + RuleFor(x => x.WeightRemainingGrams) + .LessThanOrEqualTo(x => x.WeightTotalGrams) + .WithMessage("WeightRemainingGrams cannot exceed WeightTotalGrams."); + + RuleFor(x => x.FilamentDiameterMm) + .GreaterThan(0).WithMessage("Filament diameter must be greater than zero."); + + RuleFor(x => x.SpoolSerial) + .NotEmpty().WithMessage("SpoolSerial is required.") + .MaximumLength(200).WithMessage("SpoolSerial must not exceed 200 characters."); + + When(x => x.PurchasePrice.HasValue, () => + { + RuleFor(x => x.PurchasePrice!.Value) + .GreaterThanOrEqualTo(0).WithMessage("Purchase price must be non-negative."); + }); + } +} + +/// +/// Validation rules for updating a Filament (Spool) via the /filaments route. +/// Enforces the same domain rules as creation, plus ensures the updated +/// WeightRemainingGrams does not exceed the updated WeightTotalGrams. +/// +public class UpdateFilamentRequestValidator : AbstractValidator +{ + public UpdateFilamentRequestValidator() + { + RuleFor(x => x.MaterialBaseId) + .NotEmpty().WithMessage("MaterialBaseId is required."); + + RuleFor(x => x.MaterialFinishId) + .NotEmpty().WithMessage("MaterialFinishId is required."); + + RuleFor(x => x.Brand) + .NotEmpty().WithMessage("Brand is required.") + .MaximumLength(200).WithMessage("Brand must not exceed 200 characters."); + + RuleFor(x => x.ColorName) + .NotEmpty().WithMessage("ColorName is required.") + .MaximumLength(200).WithMessage("ColorName must not exceed 200 characters."); + + RuleFor(x => x.ColorHex) + .NotEmpty().WithMessage("ColorHex is required.") + .Matches(@"^#[0-9A-Fa-f]{6}$").WithMessage("ColorHex must be a valid hex color code (e.g., #FF0000)."); + + RuleFor(x => x.WeightTotalGrams) + .GreaterThan(0).WithMessage("Total weight must be greater than zero."); + + RuleFor(x => x.WeightRemainingGrams) + .GreaterThanOrEqualTo(0).WithMessage("Remaining weight must be non-negative."); + + RuleFor(x => x.WeightRemainingGrams) + .LessThanOrEqualTo(x => x.WeightTotalGrams) + .WithMessage("WeightRemainingGrams cannot exceed WeightTotalGrams."); + + RuleFor(x => x.FilamentDiameterMm) + .GreaterThan(0).WithMessage("Filament diameter must be greater than zero."); + + RuleFor(x => x.SpoolSerial) + .NotEmpty().WithMessage("SpoolSerial is required.") + .MaximumLength(200).WithMessage("SpoolSerial must not exceed 200 characters."); + + When(x => x.PurchasePrice.HasValue, () => + { + RuleFor(x => x.PurchasePrice!.Value) + .GreaterThanOrEqualTo(0).WithMessage("Purchase price must be non-negative."); + }); + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index 86ff1c4..9e9fb53 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Extrudex.API.Filters; using Extrudex.API.Hubs; using Extrudex.Domain.Interfaces; using Extrudex.Infrastructure.Data; @@ -23,7 +24,10 @@ builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); // ── API Services ─────────────────────────────────────────── -builder.Services.AddControllers(); +builder.Services.AddControllers(options => +{ + options.Filters.AddService(); +}); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { @@ -50,6 +54,10 @@ builder.Services.AddSingleton(); // Registers all validators from the API assembly into DI. builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); +// Register the FluentValidation action filter so validators run automatically +// on all API controller actions before the action executes. +builder.Services.AddScoped(); + // ── CORS (kiosk + remote browser) ───────────────────────── // AllowAnyOrigin disallows credentials by spec; this is fine for // REST API calls. SignalR WebSockets negotiate without credentials -- 2.53.0