Validation trong FastEndpoints: Pipeline, cạm bẫy và các mẫu triển khai

Trước khi chạm đến phần cốt lõi của mã nguồn, trước các quy tắc nghiệp vụ và logic miền, mọi thứ đều bắt đầu từ request. Dữ liệu hoặc một command đi vào endpoint. Trước khi làm bất cứ điều gì khác, bạn cần bảo đảm request đó hợp lệ. FastEndpoints giúp việc kiểm tra hợp lệ cho request trở nên dễ dàng.

Mọi hướng dẫn FastEndpoints đều đưa ra cùng một ví dụ: kế thừa Validator<TRequest>, thêm vài dòng RuleFor, rồi nhận phản hồi 400. Xong!

Nhưng khi triển khai phần mềm thực tế, hàng loạt câu hỏi bắt đầu xuất hiện: Làm thế nào để inject repository? Làm thế nào để tùy biến response thay vì dùng mặc định của FastEndpoints? Làm thế nào để triển khai những kiểm tra phức tạp hơn?

Bài viết này tập trung vào những vấn đề xuất hiện sau các bài hướng dẫn nhập môn. Viết một validator cơ bản rất dễ. Nhưng nếu bạn muốn làm nhiều hơn thì sao?

Trong suốt bài viết, ta sẽ dùng một ví dụ thống nhất: endpoint RegisterCharacterRequest dành cho một công cụ quản lý chiến dịch Dungeons & Dragons.

Pipeline

Điều quan trọng là phải hiểu vị trí của việc validation request trong tương quan với các phần khác của pipeline, chẳng hạn model binding, pre-processor và handler. Ta cần biết chính xác điều gì xảy ra khi một request đi vào endpoint.

FastEndpoints dựa nhiều vào FluentValidation cho quy trình validation. Phần lớn công việc được xử lý bên trong. Bạn không cần cài FluentValidation riêng và cũng không cần tự đăng ký validator. Chúng được tự động phát hiện. Validator<TRequest> của bạn chỉ là một lớp mỏng phủ lên AbstractValidator<TRequest> của FluentValidation. Vì vậy, nếu bạn đã quen với FluentValidation thì những kiến thức đó vẫn được áp dụng.

public class RegisterCharacterRequest
{
    public string CampaignId { get; set; }
    public string Name { get; set; }
    public string PlayerEmail { get; set; }
    public string Class { get; set; }
    public AbilityScores Scores { get; set; }
}

public class RegisterCharacterValidator : Validator<RegisterCharacterRequest>
{
    public RegisterCharacterValidator()
    {
        RuleFor(x => x.CampaignId).NotEmpty();
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.PlayerEmail).NotEmpty().EmailAddress();
        RuleFor(x => x.Class).NotEmpty();
    }
}

Luồng xử lý của một request là bind -> validate -> handle. Binding được thực hiện trước, khi payload đầu vào được ánh xạ vào request object, query, body và header. Nếu bước này hoàn tất, dữ liệu DTO đã ánh xạ sẽ được validation. Theo mặc định, khi validation thất bại, luồng bị dừng, handler không được gọi, FastEndpoints ném ValidationFailureException và trả về lỗi 400 cho bên gọi. Tất cả diễn ra ngay sau khi cài FastEndpoints; bạn không cần viết thêm mã.

Bạn cũng không phải tự đăng ký validation. Khi gọi AddFastEndpoints() lúc khởi động, mã nguồn được quét để tìm validator và chúng được tự động kết nối. FastEndpoints cũng hỗ trợ các thuộc tính System.ComponentModel.DataAnnotations như một phương án dự phòng. Nếu không tìm thấy Validator<TRequest> cho kiểu request, các annotation trên request class sẽ được dùng ở bước tiếp theo.

Một cạm bẫy quan trọng: mỗi kiểu request chỉ được có một validator. Nếu có nhiều hơn một, hệ thống sẽ ném exception lúc khởi động. Bạn cần chỉ rõ validator được dùng trong hàm Configure của request:

public override void Configure()
{
    Post("/characters");
    Validator<RegisterCharacterValidator>(); // chọn validator này
}

Bằng cách khai báo tường minh như vậy, bạn có thể có nhiều validator cùng kiểu nhưng dùng các validator khác nhau cho những endpoint request khác nhau.

Một điểm cần nhớ khác là FastEndpoints không trực tiếp sử dụng các validator FluentValidation kiểu AbstractValidator<T> có trong mã nguồn. Bạn vẫn có thể dùng chúng, nhưng phải yêu cầu FluentValidation thêm chúng vào collection:

bld.Services.AddFastEndpoints(o => o.IncludeAbstractValidators = true);

Lỗi mặc định

Theo mặc định, FastEndpoints trả về thông báo lỗi với định dạng sau:

{
  "statusCode": 400,
  "message": "One or more errors occurred!",
  "errors": {
    "playerEmail": ["'Player Email' is not a valid email address."],
    "class": ["'Class' must not be empty."]
  }
}

Trường errors là một dictionary, trong đó tên thuộc tính là key và mảng thông báo lỗi là value. Cách viết hoa, viết thường và tên key tuân theo chính sách đặt tên của ứng dụng, đồng thời khớp với cách DTO được serialize.

Đây là định dạng ổn và là điểm khởi đầu tốt. Tuy nhiên, phần sau sẽ trình bày cách thay đổi nó.

Đôi khi validator là chưa đủ

Có những quy tắc không thể xử lý trong validator. Kiểm tra một địa chỉ email có hợp lệ hay không là việc của validator. Kiểm tra trong party đã có nhân vật cùng tên hay chưa lại là câu hỏi về dữ liệu. Nó không nên nằm trong validator được xây dựng ở constructor. Với trường hợp đó, hãy thêm failure trong handler:

public class RegisterCharacterEndpoint
    : Endpoint<RegisterCharacterRequest, RegisterCharacterResponse>
{
    private readonly ICampaignRepository _campaigns;

    public RegisterCharacterEndpoint(ICampaignRepository campaigns)
        => _campaigns = campaigns;

    public override void Configure()
    {
        Post("/characters");
    }

    public override async Task HandleAsync(
        RegisterCharacterRequest req, CancellationToken ct)
    {
        if (await _campaigns.HasCharacterNamed(req.CampaignId, req.Name, ct))
            AddError(r => r.Name, "A character with that name is already in this campaign.");

        if (await _campaigns.PartyIsFull(req.CampaignId, ct))
            AddError(r => r.CampaignId, "This campaign's party is already full.");

        ThrowIfAnyErrors(); // dừng và trả về 400 nếu có lỗi được thêm ở trên

        var id = await _campaigns.AddCharacter(req, ct);
        await Send.OkAsync(new RegisterCharacterResponse { CharacterId = id });
    }
}

Lệnh AddError ghi nhận failure cho một thuộc tính nhưng chưa ném exception ngay. Nhờ vậy, bạn có thể thu thập nhiều lỗi và trả về đầy đủ vấn đề trong một lần. Có bao nhiêu lần bạn gọi endpoint, nhận một lỗi, sửa lỗi đó, rồi lần gọi tiếp theo lại nhận một lỗi khác? Thay vì buộc người dùng xử lý từng lỗi như trò đập chuột, hãy tập hợp toàn bộ vấn đề và gửi chúng trong cùng response.

Sau khi thu thập xong mọi vấn đề, gọi ThrowIfAnyErrors() để ném exception và trả về lỗi 400. Client sẽ đánh giá cao cách làm này.

Khi cần trả về lỗi ngay lập tức, bạn có thể gọi ThrowError() thay cho AddError().

Để chúng cùng chiến đấu

Thông thường, validator thất bại nghĩa là handler sẽ không được gọi. Tuy nhiên, đôi khi bạn muốn handler vẫn chạy, chẳng hạn để gộp lỗi từ validator và lỗi logic nghiệp vụ vào cùng một response. Khi đó, hãy dùng DontThrowIfValidationFails(). Sau đó vẫn cần gọi ThrowIfAnyErrors() để xử lý đúng cách.

public override void Configure()
{
    Post("/characters");
    DontThrowIfValidationFails();
}

public override async Task HandleAsync(
    RegisterCharacterRequest req, CancellationToken ct)
{
    // Lỗi validator đã nằm trong ValidationFailures nhưng chưa trả về 400.
    // Luôn kiểm tra quy tắc nghiệp vụ để tích lũy toàn bộ lỗi.
    if (await _campaigns.HasCharacterNamed(req.CampaignId, req.Name, ct))
        AddError(r => r.Name, "A character with that name is already in this campaign.");

    ThrowIfAnyErrors(); // gửi toàn bộ lỗi trong một lần

    // ... luồng thành công
}

ValidationFailed là giá trị boolean cho biết tại thời điểm đó có failure hay không. Danh sách failure hiện tại nằm trong biến ValidationFailures, có kiểu List<ValidationFailure>. Bạn cũng có thể thêm thủ công vào ValidationFailures nếu muốn bổ sung lỗi tùy chỉnh.

Đi sâu hơn

Có một điểm về validator trong FastEndpoints khiến rất nhiều người nhầm lẫn: validator trong FastEndpoints là singleton.

Validator trong FastEndpoints là singleton.

Chỉ có một instance được tạo và instance đó được tái sử dụng cho mọi request. Điều này có nghĩa là:

  • Validator không được chứa state có thể thay đổi và không có field riêng cho từng request.
  • Bạn không thể inject service có lifetime scoped. Nếu cố inject một thứ như DbContext, ứng dụng sẽ ném exception.

Vì phần lớn dependency là scoped, bạn phải resolve chúng bên trong rule, nơi có thể lấy scope của request hiện tại:

public class RegisterCharacterValidator : Validator<RegisterCharacterRequest>
{
    public RegisterCharacterValidator()
    {
        RuleFor(x => x.CampaignId)
            .MustAsync(async (campaignId, ct) =>
            {
                var campaigns = Resolve<ICampaignRepository>(); // scoped, theo từng request
                return await campaigns.Exists(campaignId, ct);
            })
            .WithMessage("That campaign does not exist.");
    }
}

Resolve<T> bên trong rule sử dụng cùng scope với HTTP request hiện tại. Bạn không thể dùng nó trong constructor nếu không tự tạo scope bằng CreateScope(), và việc đó phức tạp hơn nhiều so với lợi ích nhận được. Nếu bạn đang cân nhắc làm vậy, hãy dừng lại và tự hỏi liệu kiểm tra đó có thực sự nên nằm trong validator hay không. Sau đó, hãy chuyển nó vào handler.

Cẩn thận với N+1

Mỗi tác vụ async là một lượt đi-về. Đặt một tác vụ như vậy trong rule áp dụng cho một collection lớn là cách nhanh chóng khiến data store quá tải. Đây là tình huống N+1 mà bạn không muốn áp lên hạ tầng. Hãy kiểm tra các điều kiện đồng bộ, rẻ trước, rồi dùng Cascade(CascadeMode.Stop) để tránh truy vấn cơ sở dữ liệu cho dữ liệu mà bạn đã biết là không hợp lệ:

RuleFor(x => x.CampaignId)
    .Cascade(CascadeMode.Stop)
    .NotEmpty()
    .MustAsync(async (campaignId, ct) =>
        await Resolve<ICampaignRepository>().Exists(campaignId, ct))
    .WithMessage("That campaign does not exist.");

NotEmpty sẽ thất bại sớm. Lệnh gọi cơ sở dữ liệu chỉ được thực hiện khi thực sự có campaign ID để tra cứu.

Validation có điều kiện

Bạn có thể dễ dàng thiết kế validation chỉ chạy trong một số tình huống bằng rule When và Unless. Ví dụ, cần kiểm tra điểm Intelligence của wizard có đủ cao để niệm phép hay không:

When(x => x.Class == "Wizard", () =>
{
    RuleFor(x => x.Scores.Intelligence)
        .GreaterThanOrEqualTo(13)
        .WithMessage("Wizards need an Intelligence of at least 13 to cast spells.");
});

RuleSet của FluentValidation cho phép chia nhỏ rule khi một request model được dùng lại ở nhiều endpoint có nhu cầu khác nhau. Nó hữu ích trong một số trường hợp. Tuy nhiên, nếu bạn thường xuyên phải dùng các biến thể như vậy, có lẽ nên tách thành các request model và endpoint riêng.

Tái sử dụng và kết hợp validator

Cũng như nhiều phần khác trong mã nguồn, đôi khi việc gom các luồng bị lặp vào một function chung là hợp lý. Tính năng Include giúp dễ dàng tạo các rule dùng chung cho validator của FastEndpoints.

public class AbilityScoresValidator : Validator<AbilityScores>
{
    public AbilityScoresValidator()
    {
        RuleFor(x => x.Strength).InclusiveBetween(3, 18);
        RuleFor(x => x.Dexterity).InclusiveBetween(3, 18);
        RuleFor(x => x.Constitution).InclusiveBetween(3, 18);
        RuleFor(x => x.Intelligence).InclusiveBetween(3, 18);
        RuleFor(x => x.Wisdom).InclusiveBetween(3, 18);
        RuleFor(x => x.Charisma).InclusiveBetween(3, 18);
    }
}

public class RegisterCharacterValidator : Validator<RegisterCharacterRequest>
{
    public RegisterCharacterValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(50);
        RuleFor(x => x.PlayerEmail).NotEmpty().EmailAddress();

        RuleFor(x => x.Scores)
            .Cascade(CascadeMode.Stop)
            .NotNull()
            .SetValidator(new AbilityScoresValidator());
    }
}

Trong ví dụ này, điểm năng lực là validation tốt cho câu hỏi “dữ liệu có đúng định dạng không”. Điểm khởi đầu ngoài khoảng 3-18 không phải vấn đề của quy tắc nghiệp vụ; đó đơn giản là một nhân vật không hợp lệ.

Tùy biến response

Với một API chuyên nghiệp, bạn gần như luôn muốn có một envelope lỗi thống nhất. Có một chuẩn được định nghĩa với tên Problem Details. Dù chọn định dạng nào, FastEndpoints vẫn cung cấp ResponseBuilder toàn cục để bạn định nghĩa một lần:

app.UseFastEndpoints(c =>
{
    c.Errors.ResponseBuilder = (failures, ctx, statusCode) =>
    {
        return new ValidationProblemDetails(
            failures
                .GroupBy(f => f.PropertyName)
                .ToDictionary(
                    g => g.Key,
                    g => g.Select(f => f.ErrorMessage).ToArray()))
        {
            Type = "https://www.rfc-editor.org/rfc/rfc9457",
            Title = "One or more validation errors occurred.",
            Status = statusCode,
            Instance = ctx.Request.Path,
            Extensions = { { "traceId", ctx.TraceIdentifier } }
        };
    };
});

Như một câu nói quen thuộc: “Thiết lập một lần rồi quên nó đi!”

Builder nhận các failure, HttpContext và status code, sau đó serialize rồi gửi response. Nhưng nếu cung cấp builder riêng như trên, đừng quên cho FastEndpoints biết kiểu trả về để metadata OpenAPI “produces 400” mô tả đúng API:

app.UseFastEndpoints(c =>
{
    c.Errors.ProducesMetadataType = typeof(ValidationProblemDetails);
});

Kiểm thử

Validator chỉ là một class, vì vậy unit test cũng được thực hiện giống như với mọi class khác. Bạn có thể dùng helper TestValidate của FluentValidation cùng xUnit, Shouldly và các công cụ khác:

public class RegisterCharacterValidatorTests
{
    private readonly RegisterCharacterValidator _validator = new();

    [Fact]
    public void Fails_when_player_email_is_missing()
    {
        var request = new RegisterCharacterRequest
        {
            CampaignId = "CMP-1",
            Name = "Tordek",
            PlayerEmail = "",
            Class = "Fighter",
            Scores = StandardArray()
        };

        var result = _validator.TestValidate(request);

        result.ShouldHaveValidationErrorFor(x => x.PlayerEmail);
    }

    [Fact]
    public void Passes_for_a_well_formed_request()
    {
        var request = new RegisterCharacterRequest
        {
            CampaignId = "CMP-1",
            Name = "Tordek",
            PlayerEmail = "player@example.com",
            Class = "Fighter",
            Scores = StandardArray()
        };

        var result = _validator.TestValidate(request);

        result.ShouldNotHaveAnyValidationErrors();
    }

    // Mảng chuẩn của D&D 5e, để fixture là một character sheet hợp lệ
    private static AbilityScores StandardArray() => new()
    {
        Strength = 15, Dexterity = 14, Constitution = 13,
        Intelligence = 12, Wisdom = 10, Charisma = 8
    };
}

Các test trên kiểm tra cả trường hợp thất bại lẫn thành công. ShouldHaveValidationErrorFor giúp xác minh một thuộc tính cụ thể, nhờ đó test thất bại vì đúng nguyên nhân thay vì tình cờ.

Một điểm cần lưu ý: nếu validator dùng Resolve<T>, việc test sẽ khó hơn. Đây là nguồn gây khó chịu thường gặp với người dùng FastEndpoints. Resolve<T> truy cập service resolver của FastEndpoints, nên bạn không thể dễ dàng truyền một fake vào đó như trong nhiều cách tiếp cận test khác.

Constructor injection dễ test hơn: chỉ cần truyền mock trực tiếp:

var campaigns = Substitute.For<ICampaignRepository>();
campaigns.Exists("CMP-1", Arg.Any<CancellationToken>()).Returns(false);

var validator = new RegisterCharacterValidator(campaigns);
var result = await validator.TestValidateAsync(request);

result.ShouldHaveValidationErrorFor(x => x.CampaignId);

Vấn đề là, như phần trước đã giải thích, validator là singleton. Vì thế service scoped được inject qua constructor sẽ bị giữ lại trong toàn bộ vòng đời ứng dụng. Điều đó có thể gây dữ liệu cũ và lỗi liên quan đến đa luồng trong môi trường production. Cách dùng constructor an toàn cho test vì bạn cung cấp dependency trực tiếp và không có DI container tham gia. Trong production, hãy dùng Resolve<T> cho service scoped.

Thực sự không có câu trả lời hoàn hảo. Constructor injection dễ test nhưng nguy hiểm trong production với dependency scoped. Resolve<T> an toàn khi chạy thật nhưng khó test. Thành thật mà nói, với validator gọi Resolve<T>, tôi thường bỏ qua unit test và chạy integration test đầy đủ trên instance thật. Cách đó đơn giản hơn.

Validation nào thuộc về đâu?

Ta dùng từ validation cho nhiều khía cạnh khác nhau trong phần mềm. Vì vậy, một chút phân biệt sẽ hữu ích.

Request validation

Câu hỏi ở đây là: “Input có đúng định dạng không?” Trường email có thực sự là email không? Các điểm năng lực có nằm trong khoảng hợp lệ không? Tất cả field bắt buộc đã có chưa? Đó là nhiệm vụ của validator trong FastEndpoints. Chúng canh cửa và chỉ quan tâm đến request cùng dữ liệu bên trong request.

Domain validation

Sau khi request đi qua cánh cửa, câu hỏi tiếp theo là: “Với trạng thái hiện tại của hệ thống, thao tác này có được phép không?” Campaign có tồn tại không? Party đã đầy chưa? Đã có nhân vật cùng tên chưa? Đây đều là quy tắc nghiệp vụ. Chúng cần dữ liệu hoặc workflow được định nghĩa để kiểm tra, vì vậy thuộc các tầng sâu hơn của hệ thống, như application hoặc domain. Đây không phải những câu hỏi nên giao cho validator của FastEndpoints.

Ranh giới giữa hai loại đôi khi không rõ ràng và có thể khó quyết định nên validation ở đâu. Tuy nhiên, càng giữ ranh giới này rõ ràng, validator càng duy trì được vai trò “người gác cửa” nhanh và nhẹ, còn quy tắc nghiệp vụ sẽ tiếp tục dễ test trong tầng sở hữu chúng.

Một nguyên tắc thực tế:

Nếu việc kiểm tra cần cơ sở dữ liệu, aggregate hoặc ý nghĩa nghiệp vụ liên quan đến nhiều field, nó thuộc tầng domain và application. Nếu chỉ kiểm tra request có đúng định dạng hay không, nó thuộc validator.

Kết luận

Hãy ghi nhớ mô hình tư duy cốt lõi: bind trước, validate sau, handle cuối. Bạn có thể thay đổi luồng bằng DontThrowIfValidationFails, hoặc bổ sung lỗi thủ công bằng AddError và ThrowError. Giữ validator stateless khi có thể và luôn nhớ chúng là singleton. Ngoài ra, constructor injection và Resolve<T> đều có ưu điểm cũng như hạn chế riêng khi kiểm thử.

Nắm được những điểm này, validation sẽ không còn là phần plumbing khó chịu mà trở thành một ranh giới rõ ràng quanh endpoint. Đó chính là mục tiêu. Vì vậy, hãy để validator của FastEndpoints trở thành người gác cửa cho API của bạn.