مقالات › ASP.NET Core

اعتبارسنجی ورودی در ASP.NET Core؛ Data Annotation در برابر FluentValidation

ورودی API را در ASP.NET Core اصولی اعتبارسنجی کنید: Data Annotation، IValidatableObject، قوانین FluentValidation، اعتبارسنجی async، فیلتر Minimal API و خطاهای یکدست ProblemDetails.

هر API داده‌ای دریافت می‌کند که نمی‌تواند به آن اعتماد کند. کاربران اشتباه تایپی دارند، کلاینت‌ها باگ دارند و مهاجم‌ها Payload مخرب می‌فرستند. اعتبارسنجی (Validation) اولین خط دفاع شماست: ورودی بد را زود و با پیام روشن رد می‌کند، قبل از اینکه به منطق بیزینس یا دیتابیس برسد. ASP.NET Core چند روش برای اعتبارسنجی دارد؛ این راهنما نشان می‌دهد کِی از کدام استفاده کنید.

اعتبارسنجی کجا قرار می‌گیرد؟

لایه‌ای فکر کنید:

  1. اعتبارسنجی ورودی (لایه‌ی API) — شکل و قالب: فیلدهای الزامی، طول، بازه، قالب ایمیل.
  2. قوانین بیزینس (لایه‌ی Application/Domain) — «سفارش بعد از ارسال قابل لغو نیست»، «کد تخفیف منقضی شده».
  3. محدودیت‌های دیتابیس — آخرین تور ایمنی: ایندکس یکتا، کلید خارجی، NOT NULL.

اعتبارسنجی ورودی باید سریع و اعلانی باشد؛ قوانین بیزینس اغلب به دیتابیس نیاز دارند و جایشان در سرویس‌هاست.

Data Annotation

روش داخلی ASP.NET Core از Attributeها استفاده می‌کند:

public class RegisterRequest
{
    [Required, StringLength(50, MinimumLength = 3)]
    public string UserName { get; set; } = "";

    [Required, EmailAddress]
    public string Email { get; set; } = "";

    [Required, MinLength(8)]
    public string Password { get; set; } = "";

    [Compare(nameof(Password))]
    public string ConfirmPassword { get; set; } = "";

    [Range(13, 120)]
    public int Age { get; set; }

    [RegularExpression(@"^09\d{9}$", ErrorMessage = "شماره موبایل معتبر وارد کنید.")]
    public string? Mobile { get; set; }
}

با [ApiController]، ASP.NET Core مدل را خودکار اعتبارسنجی می‌کند و حتی قبل از اجرای اکشن، 400 Bad Request را همراه با بدنه‌ی ValidationProblemDetails برمی‌گرداند:

{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": ["The Email field is not a valid e-mail address."],
    "Password": ["The field Password must be a string or array type with a minimum length of '8'."]
  }
}

قوانین چندفیلدی با IValidatableObject

public class BookingRequest : IValidatableObject
{
    public DateTime CheckIn { get; set; }
    public DateTime CheckOut { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (CheckOut <= CheckIn)
            yield return new ValidationResult(
                "تاریخ خروج باید بعد از تاریخ ورود باشد.", [nameof(CheckOut)]);
    }
}

Data Annotation برای DTOهای ساده عالی است، اما وقتی قوانین شرطی هستند، به سرویس نیاز دارند یا باید دوباره استفاده شوند، دست‌وپاگیر می‌شود.

FluentValidation

FluentValidation قوانین را با یک API روان در کد بیان می‌کند و DTOها را تمیز نگه می‌دارد:

dotnet add package FluentValidation.DependencyInjectionExtensions
public record CreateProductRequest(string Name, string? Sku, decimal Price, int Stock, string Category);

public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
    private static readonly string[] Categories = ["laptop", "phone", "accessory"];

    public CreateProductValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("نام محصول الزامی است.")
            .MaximumLength(150);

        RuleFor(x => x.Price)
            .GreaterThan(0)
            .PrecisionScale(18, 2, ignoreTrailingZeros: true);

        RuleFor(x => x.Stock).GreaterThanOrEqualTo(0);

        RuleFor(x => x.Category)
            .Must(c => Categories.Contains(c))
            .WithMessage("دسته‌بندی باید یکی از laptop، phone یا accessory باشد.");

        RuleFor(x => x.Sku)
            .Matches("^[A-Z]{3}-\\d{4}$")
            .When(x => !string.IsNullOrEmpty(x.Sku));
    }
}

همه‌ی Validatorهای اسمبلی را ثبت کنید:

builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();

اعتبارسنجی در کنترلر یا سرویس

یکپارچه‌سازی خودکار قدیمی با MVC (FluentValidation.AspNetCore) منسوخ شده است؛ روش پیشنهادی اعتبارسنجی صریح است:

[HttpPost]
public async Task<IActionResult> Create(
    CreateProductRequest request,
    IValidator<CreateProductRequest> validator,
    CancellationToken ct)
{
    var result = await validator.ValidateAsync(request, ct);
    if (!result.IsValid)
        return ValidationProblem(result.ToModelState()); // تابع کمکی پایین‌تر

    // ... ساخت محصول
    return Created();
}

یک تابع کمکی کوچک کنترلرها را مرتب نگه می‌دارد:

public static class ValidationExtensions
{
    public static ModelStateDictionary ToModelState(this ValidationResult result)
    {
        var state = new ModelStateDictionary();
        foreach (var error in result.Errors)
            state.AddModelError(error.PropertyName, error.ErrorMessage);
        return state;
    }
}

قوانین async که به سرویس نیاز دارند

public class RegisterValidator : AbstractValidator<RegisterRequest>
{
    public RegisterValidator(AppDbContext db)
    {
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(async (email, ct) => !await db.Users.AnyAsync(u => u.Email == email, ct))
            .WithMessage("این ایمیل قبلاً ثبت شده است.");
    }
}

نکته: حتی با بررسی یکتایی async، یک ایندکس یکتا در دیتابیس داشته باشید. دو درخواست می‌توانند هم‌زمان از اعتبارسنجی عبور کنند؛ فقط محدودیت دیتابیس در برابر Race Condition مقاوم است.

قوانین قابل استفاده‌ی مجدد و تودرتو

public class AddressValidator : AbstractValidator<Address>
{
    public AddressValidator()
    {
        RuleFor(a => a.City).NotEmpty();
        RuleFor(a => a.PostalCode).Matches(@"^\d{10}$");
    }
}

public class OrderValidator : AbstractValidator<CreateOrder>
{
    public OrderValidator()
    {
        RuleFor(o => o.ShippingAddress).SetValidator(new AddressValidator());
        RuleForEach(o => o.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
        });
        RuleFor(o => o.Items).NotEmpty().WithMessage("سفارش باید حداقل یک کالا داشته باشد.");
    }
}

اعتبارسنجی در Minimal API

از یک Endpoint Filter برای اعتبارسنجی خودکار هر نوع درخواستی استفاده کنید:

public class ValidationFilter<T> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        var validator = ctx.HttpContext.RequestServices.GetService<IValidator<T>>();
        var arg = ctx.Arguments.OfType<T>().FirstOrDefault();

        if (validator is not null && arg is not null)
        {
            var result = await validator.ValidateAsync(arg);
            if (!result.IsValid)
                return Results.ValidationProblem(result.ToDictionary());
        }
        return await next(ctx);
    }
}

app.MapPost("/products", (CreateProductRequest req) => Results.Ok())
   .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();

در ‎.NET 10 هم اعتبارسنجی داخلی Data Annotation برای Minimal API با builder.Services.AddValidation() اضافه شده است.

Data Annotation یا FluentValidation؟

Data Annotation FluentValidation
راه‌اندازی داخلی پکیج NuGet
قوانین ساده ✅ کوتاه ✅
قوانین شرطی دست‌وپاگیر ✅ When، Unless
قوانین نیازمند سرویس/دیتابیس پشتیبانی نمی‌شود ✅ MustAsync
تمیز ماندن DTO ❌ Attribute همه‌جا ✅
تست واحد قوانین سخت‌تر ✅ TestValidate

یک انتخاب عملی: Data Annotation برای پروژه‌های خیلی کوچک و FluentValidation به‌محض اینکه قوانین غیربدیهی شوند.

تست Validatorها

[Fact]
public void Price_must_be_positive()
{
    var validator = new CreateProductValidator();
    var result = validator.TestValidate(new CreateProductRequest("Mouse", null, 0, 5, "accessory"));
    result.ShouldHaveValidationErrorFor(x => x.Price);
}

پیام خطای خوب

  • بگویید چه چیزی اشتباه است و چطور درستش کنند: «رمز عبور باید حداقل ۸ کاراکتر باشد.»
  • از نام فیلدهایی استفاده کنید که کلاینت می‌شناسد (در JSON معمولاً camelCase).
  • اگر چندزبانه هستید، پیام‌ها را بومی‌سازی کنید.
  • ورودی خام کاربر را بدون Encode کردن در پیام برنگردانید.

جمع‌بندی

ورودی را در لبه‌ی API، قوانین بیزینس را در لایه‌ی Application اعتبارسنجی کنید و محدودیت‌های دیتابیس را به‌عنوان آخرین تور ایمنی نگه دارید. Data Annotation برای DTOهای ساده عالی است و FluentValidation برای قوانین شرطی، قابل استفاده‌ی مجدد و async می‌درخشد. هرکدام را انتخاب کنید، یک قالب یکسان ValidationProblemDetails برگردانید تا کلاینت بتواند خطاها را کنار فیلد درست نمایش دهد.

پرسش‌های متداول

آیا در سمت کلاینت هم اعتبارسنجی کنم؟

بله، برای تجربه‌ی کاربری بهتر؛ اما هرگز به آن تکیه نکنید. اعتبارسنجی کلاینت قابل دور زدن است و اعتبارسنجی سرور الزامی است.

بررسی «این منبع متعلق به کاربر است» کجا انجام شود؟

این مجوزدهی است، نه اعتبارسنجی. آن را با Authorization Policy یا در سرویس اپلیکیشن انجام دهید.

آیا اعتبارسنجی می‌تواند جای محدودیت‌های دیتابیس را بگیرد؟

نه. اعتبارسنجی پیام دوستانه می‌دهد؛ محدودیت‌ها حتی در شرایط هم‌زمانی، یکپارچگی داده را تضمین می‌کنند.