Danh sách bài viết

Bài 41: JSON Extract + Validation Với validator Crate

Bài 41 của series Rust RESTful API — bài MỞ Group 5 JSON Body Streaming — đặt nền móng validation pipeline đầu tiên cho Shop API bằng việc phân biệt hai khái niệm gần nhau nhưng hoàn toàn khác semantic: extract (parse JSON bytes thành Rust struct — fail thì 400 Bad Request vì client gửi rác) vs validate (kiểm tra business rule trên struct đã parse thành công — fail thì 422 Unprocessable Entity vì server hiểu request nhưng dữ liệu vi phạm rule); cài validator crate v0.18 vào workspace.dependencies root shop/Cargo.toml với feature derive + 2 dep phụ trợ once_cell + regex dùng cho regex constant lazy compile pattern, add vào crates/shop-common/Cargo.toml (DTO struct ở common để cross-crate share giữa shop-api binary và shop-core service tương lai G7), tạo file mới crates/shop-common/src/dto.rs chứa CreateProductDto với #[derive(Validate)] + 4 field rule áp dụng từ B14 preview lock (name dùng length(min=3, max=200), slug dùng regex(path = *SLUG_REGEX) trỏ tới constant SLUG_REGEX: Lazy<Regex> compile pattern ^[a-z0-9]+(-[a-z0-9]+)*$ kebab-case lowercase lock vĩnh viễn cho mọi slug Shop API, price dùng range(min=1, max=100_000_000) giới hạn giá VND, stock dùng range(min=0, max=1_000_000)) với message tiếng Việt mọi rule để UX user VN; thêm variant mới AppError::ValidationFailed(ValidationErrors) vào enum AppError trong shop-common::error kèm impl From<ValidationErrors> for AppError cho operator ? tự convert + extend impl IntoResponse for AppError match arm mới build envelope 422 Unprocessable Entity với structure lock {error, code: "VALIDATION_FAILED", request_id, fields: {field_name: [messages]}} map qua errors.field_errors() trả HashMap<&str, Vec<&ValidationError>> rồi extract message hoặc fallback code nếu validator chưa custom; implement ValidatedJson<T> wrapper extractor trong file mới crates/shop-api/src/extractors/validated_json.rs compose pattern: gọi AppJson::<T>::from_request trước (rejection 400 nếu parse fail) → gọi value.validate()? sau (rejection 422 nếu rule fail qua From<ValidationErrors>) → wrap return ValidatedJson(value); refactor handler create_product trong crates/shop-api/src/routes/products.rs đổi extractor từ AppJson<CreateProductDto> sang ValidatedJson<CreateProductDto> + bỏ dto.validate()? thủ công vì extractor đã handle; verify 3 curl test end-to-end (valid request 201 + Location + body, name quá ngắn + slug invalid + price 0 trả 422 + envelope fields, price wrong type trả 400 envelope BAD_REQUEST). Lock vĩnh viễn: pattern DTO ở shop-common cross-crate share, pattern ValidatedJson<T> MANDATORY mọi handler create/update Shop API tương lai (G7 B62-B66 products, B105 register, B106 cart items, B115 orders, B135 admin), pattern Lazy<Regex> cho mọi regex constant cross-crate (compile once), pattern 422 envelope structure chuẩn cho mọi validation fail, pattern message tiếng Việt mọi field rule. Workspace state change: 2 file mới (shop-common/src/dto.rs + shop-api/src/extractors/validated_json.rs), 5 file updated (shop/Cargo.toml add 3 dep, shop-common/Cargo.toml + shop-api/Cargo.toml add deps, shop-common/src/lib.rs add module, shop-common/src/error.rs thêm variant + From + match arm, shop-api/src/extractors/mod.rs re-export, shop-api/src/routes/products.rs đổi extractor handler). Suggested commit: B41: validator crate + ValidatedJson extractor + 422 envelope + MỞ Group 5.

14/06/2026
12 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu sự khác biệt giữa extract (parse JSON bytes → struct Rust) và validate (kiểm tra business rule trên struct đã parse) — fail extract trả 400 Bad Request, fail validate trả 422 Unprocessable Entity.
  • Cài và dùng validator crate v0.18 qua workspace dependency lock cross-crate.
  • Áp dụng #[derive(Validate)] cho DTO với 4 field rule phổ biến trên CreateProductDto: length, regex, range.
  • Biết các field rule có sẵn của validator: email, length, regex, range, must_match, contains, url, custom.
  • Implement ValidatedJson<T> wrapper extractor compose AppJson + validate sau extract — handler clean không phải gọi dto.validate()? thủ công.
  • Thêm variant AppError::ValidationFailed(ValidationErrors) + extend impl IntoResponse trả 422 + envelope chuẩn {error, code, request_id, fields} với chi tiết field-level errors.
  • Áp dụng pattern cho CreateProductDto đặt ở shop-common::dto để cross-crate share với shop-core service G7.
  • Lock SLUG_REGEX ^[a-z0-9]+(-[a-z0-9]+)*$ kebab-case lowercase + pattern Lazy<Regex> compile once cho mọi regex constant Shop API.
2

Extract vs Validate — Hai Bước Khác Nhau

Hai khái niệm gần nhau nhưng khác biệt semantic hoàn toàn — phải tách rời ngay từ thiết kế API để client biết chính xác mình sai ở đâu.

Extract nghĩa là parse JSON bytes thành struct Rust qua serde_json::from_slice. Tại bước này serde kiểm tra cú pháp (JSON đúng RFC 8259 chưa, dấu phẩy ngoặc đầy đủ chưa), kiểu dữ liệu (field price: u64 client gửi "abc" string thì fail), và struct shape (field bắt buộc có tồn tại chưa). Wrapper AppJson<T> lock B32 đã map JsonRejection sang AppError::BadRequest trả 400 Bad Request.

Validate nghĩa là kiểm tra business rule trên struct đã parse thành công. Tại bước này JSON cú pháp đúng + kiểu dữ liệu đúng + field đầy đủ — nhưng giá trị có thể vô nghĩa với business logic:

  • email: "not-an-email" parse được thành String nhưng không phải email format.
  • price: 0 parse được thành u64 nhưng giá 0 VND vô lý cho sản phẩm.
  • name: "" parse được thành String rỗng nhưng tên sản phẩm rỗng không hợp lệ.
  • slug: "INVALID SLUG" parse được nhưng không match kebab-case lowercase convention.

Semantic HTTP đúng cho 2 trường hợp này khác nhau rõ rệt theo RFC 9110:

  • JSON parse fail → 400 Bad Request: client gửi rác, request không thể hiểu được.
  • Validation fail → 422 Unprocessable Entity: server hiểu request nhưng dữ liệu vi phạm business rule (RFC 4918 mục 11.2, mở rộng bởi RFC 9110).

Pattern handler ban đầu (chưa có ValidatedJson) sẽ phải gọi dto.validate()? thủ công sau khi extract:

use crate::extractors::AppJson;
use shop_common::dto::CreateProductDto;
use shop_common::error::AppResult;
use crate::responses::Created;
use validator::Validate;

async fn create_product(
    AppJson(dto): AppJson<CreateProductDto>,
) -> AppResult<Created<ProductDto>> {
    dto.validate()?; // bước validate thủ công — sẽ tự động hóa ở Bước 6
    // business logic ...
    Ok(Created { /* ... */ })
}

Vấn đề pattern này: lặp lại dto.validate()? mọi handler create/update Shop API tương lai (60+ endpoint). Solution lock B41: wrap thành extractor ValidatedJson<T> compose AppJson + validate sau extract — handler chỉ khai báo type, extractor handle toàn bộ.

3

Cài validator Crate

Crate validator do Keats (Vincent Prouillet — tác giả Tera template engine) maintain từ 2017, version 0.18 ra Q3 2024 ổn định cho axum 0.8 + Rust 1.85 edition 2024. Crate cung cấp derive macro Validate + bộ field attribute phổ biến (email, length, regex, range, url, must_match, contains, custom) đủ cover 95% rule business validation.

Cập nhật shop/Cargo.toml root thêm vào [workspace.dependencies] (lock version cross-crate):

# File: shop/Cargo.toml
[workspace.dependencies]
# ... existing deps ...
validator = { version = "0.18", features = ["derive"] }
once_cell = "1"
regex = "1"

Feature derive bật proc-macro #[derive(Validate)] + #[validate(...)] field attribute. Hai dep phụ trợ once_cell (provides Lazy<T> wrapper) + regex (regex engine cho rule regex(path = ...)) sẽ dùng ngay ở Bước 4 cho SLUG_REGEX constant.

Cập nhật crates/shop-common/Cargo.toml thêm 3 dep này:

# File: crates/shop-common/Cargo.toml
[dependencies]
# ... existing deps ...
validator = { workspace = true }
once_cell = { workspace = true }
regex = { workspace = true }

Cập nhật crates/shop-api/Cargo.toml thêm validator (cần cho ValidatedJson<T> extractor gọi value.validate()? + import ValidationErrors type cho rejection):

# File: crates/shop-api/Cargo.toml
[dependencies]
# ... existing deps ...
validator = { workspace = true }

Lý do đặt DTO ở shop-common thay vì shop-api: từ G7 onward, shop-core service layer (vd ProductService::create) cần nhận CreateProductDto làm input — nếu đặt ở shop-api, shop-core không thể reference (dependency graph một chiều: shop-api → shop-core → shop-common). Đặt ở shop-common cho phép cả hai crate cùng dùng. Validate vẫn chạy ở extractor lớp shop-api (HTTP boundary), nhưng type definition share được.

Verify compile:

cd shop && cargo build -p shop-common
# Output: Compiling validator v0.18.x, once_cell v1.x, regex v1.x, shop-common v0.1.0
#         Finished `dev` profile [unoptimized + debuginfo] target(s)
4

#[derive(Validate)] Cho DTO + Field Rules

Folder crates/shop-common/src/ hiện chưa có file dto.rs (folder placeholder ở crates/shop-api/src/dto/ được lock B17 nhưng dành cho DTO chỉ dùng nội bộ HTTP layer). Tạo file mới crates/shop-common/src/dto.rs chứa CreateProductDto + SLUG_REGEX constant:

// File: crates/shop-common/src/dto.rs
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use validator::Validate;

/// Regex slug kebab-case lowercase — lock vĩnh viễn cho mọi slug Shop API
/// (product, category, blog post, page). Pattern: bắt đầu bằng a-z hoặc 0-9,
/// có thể có nhiều segment cách nhau bởi dấu gạch ngang, mỗi segment chỉ chứa
/// a-z và 0-9. Không cho phép viết hoa, khoảng trắng, dấu chấm, gạch dưới.
pub static SLUG_REGEX: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"^[a-z0-9]+(-[a-z0-9]+)*$").expect("invalid SLUG_REGEX pattern")
});

/// DTO cho POST /api/v1/products (lock B14 + B41).
/// Validate qua `#[derive(Validate)]` — rule áp dụng tại
/// `ValidatedJson<CreateProductDto>` extractor (B41 Bước 6).
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
pub struct CreateProductDto {
    #[validate(length(
        min = 3,
        max = 200,
        message = "tên sản phẩm dài 3-200 ký tự"
    ))]
    pub name: String,

    #[validate(regex(
        path = *SLUG_REGEX,
        message = "slug chỉ chứa chữ thường, số và dấu gạch ngang"
    ))]
    pub slug: String,

    #[validate(range(
        min = 1,
        max = 100_000_000,
        message = "giá phải lớn hơn 0 và không vượt 100 triệu VND"
    ))]
    pub price: u64,

    #[validate(range(
        min = 0,
        max = 1_000_000,
        message = "stock phải từ 0 đến 1.000.000"
    ))]
    pub stock: u32,
}

Cập nhật crates/shop-common/src/lib.rs declare module mới:

// File: crates/shop-common/src/lib.rs
pub mod config;
pub mod dto;       // NEW B41
pub mod error;
pub mod headers;
pub mod pagination;
pub mod telemetry;

Giải thích 4 field rule áp dụng:

  • length(min, max): dùng cho String hoặc Vec<T> — đếm số ký tự (UTF-8 grapheme cluster, không phải byte). Field name giới hạn 3-200 ký tự loại bỏ string rỗng và string quá dài cho display UI.
  • regex(path = ...): reference tới regex constant qua syntax *EXPR deref Lazy<Regex>. Phải dùng constant static compile once không phải string literal — vì Regex::new mỗi request tốn ~10μs.
  • range(min, max): dùng cho mọi số (integer + float). Field price: u64 giới hạn 1 (giá phải > 0) đến 100 triệu VND cap an toàn cho domain e-commerce VN; field stock: u32 giới hạn 0 (có thể out-of-stock) đến 1 triệu unit.
  • message: text tiếng Việt hiển thị cho user khi rule fail — bắt buộc tiếng Việt cho UX VN. Nếu bỏ message, validator dùng code mặc định như "length"/"range"/"regex" (English machine-readable, không phù hợp hiển thị user).

Các field rule khác validator hỗ trợ (preview cho DTO tương lai):

  • email: validate email format (RFC 5322 subset) — dùng cho RegisterDto.email (B104).
  • url: validate URL valid — dùng cho UpdateProfileDto.website, image URL admin upload.
  • must_match = "other_field": so sánh 2 field bằng nhau — dùng cho password confirm (password = password_confirmation).
  • contains(pattern = "..."): kiểm tra substring trong String.
  • custom(function = "fn_name"): gọi function tùy chỉnh trả Result<(), ValidationError> cho rule phức tạp không cover được bởi built-in.

Verify compile:

cd shop && cargo build -p shop-common
# Output: Compiling shop-common v0.1.0
#         Finished `dev` profile [unoptimized + debuginfo] target(s)
5

AppError::ValidationFailed Variant Mới

Variant AppError::Validation(String) đã có từ B16 nhưng chỉ chứa text generic chưa có chi tiết field-level errors. B41 thêm variant mới ValidationFailed(ValidationErrors) chứa nguyên cấu trúc ValidationErrors từ validator crate — cho phép build envelope 422 với field-level details cho client UI form.

Cập nhật crates/shop-common/src/error.rs:

// File: crates/shop-common/src/error.rs
use std::collections::HashMap;

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use validator::ValidationErrors;

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("bad request: {0}")]
    BadRequest(String),

    #[error("unauthenticated")]
    Unauthenticated,

    #[error("forbidden")]
    Forbidden,

    #[error("not found: {0}")]
    NotFound(String),

    #[error("method not allowed: {0}")]
    MethodNotAllowed(String),

    #[error("conflict: {0}")]
    Conflict(String),

    #[error("validation: {0}")]
    Validation(String),

    /// NEW B41 — chứa ValidationErrors từ validator crate cho field-level
    /// envelope details. Convert qua `impl From<ValidationErrors>` để
    /// operator `?` flow tự nhiên trong `ValidatedJson::from_request`.
    #[error("validation failed")]
    ValidationFailed(ValidationErrors),

    #[error("rate limited: retry after {0} seconds")]
    RateLimited(u64),

    #[error("internal error")]
    Internal(#[from] anyhow::Error),

    #[error("upstream error: {0}")]
    Upstream(String),

    #[error("service unavailable")]
    Unavailable,
}

/// NEW B41 — cho phép operator `?` convert ValidationErrors → AppError
/// trong `ValidatedJson::from_request`.
impl From<ValidationErrors> for AppError {
    fn from(errors: ValidationErrors) -> Self {
        AppError::ValidationFailed(errors)
    }
}

Thêm match arm cho variant mới trong impl IntoResponse for AppError:

// File: crates/shop-common/src/error.rs (extend impl IntoResponse)
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        match self {
            // NEW B41 — envelope 422 với chi tiết field errors
            AppError::ValidationFailed(errors) => {
                let fields: HashMap<String, Vec<String>> = errors
                    .field_errors()
                    .into_iter()
                    .map(|(field, errs)| {
                        let messages: Vec<String> = errs
                            .iter()
                            .map(|e| {
                                e.message
                                    .as_ref()
                                    .map(|m| m.to_string())
                                    .unwrap_or_else(|| e.code.to_string())
                            })
                            .collect();
                        (field.to_string(), messages)
                    })
                    .collect();

                let body = json!({
                    "error": "validation failed",
                    "code": "VALIDATION_FAILED",
                    "request_id": null, // enrich bởi middleware B39
                    "fields": fields,
                });

                (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response()
            }

            // ... existing match arms cho 11 variants B16 ...
            _ => {
                let status = self.status_code();
                let code = self.code();
                let body = json!({
                    "error": self.to_string(),
                    "code": code,
                    "request_id": null,
                });
                (status, Json(body)).into_response()
            }
        }
    }
}

Cấu trúc envelope 422 lock vĩnh viễn:

{
  "error": "validation failed",
  "code": "VALIDATION_FAILED",
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "fields": {
    "name": ["tên sản phẩm dài 3-200 ký tự"],
    "slug": ["slug chỉ chứa chữ thường, số và dấu gạch ngang"],
    "price": ["giá phải lớn hơn 0 và không vượt 100 triệu VND"]
  }
}

Chi tiết design:

  • Field key giữ tên JSON gốc qua errors.field_errors() trả HashMap<&str, ...> với key là tên field Rust struct — match thẳng tên JSON nếu DTO không có #[serde(rename)]. Nếu có rename, validator sẽ dùng tên Rust thay tên JSON (pitfall — sẽ note B42).
  • Mỗi field map sang Vec<String> vì một field có thể fail nhiều rule cùng lúc (vd password vừa fail length(min=8) vừa fail regex(must contain digit)).
  • Fallback code nếu message None: validator cho phép omit message attribute — khi đó message trống, dùng code machine-readable như "length"/"range"/"regex". Shop API luôn set message tiếng Việt nên fallback hiếm khi hit.
  • request_id placeholder null — middleware enrich_error_response B39 sẽ inject giá trị thật vì content-type application/json match điều kiện enrich.

Verify compile:

cd shop && cargo build -p shop-common
# Output: Finished `dev` profile [unoptimized + debuginfo] target(s)
6

ValidatedJson<T> Wrapper Extractor

Pattern compose 2 bước trong 1 extractor: gọi AppJson<T>::from_request (lock B32) trước để parse JSON + map rejection 400, gọi value.validate()? sau để check rule + map ValidationErrors sang AppError::ValidationFailed qua From impl đã add Bước 5.

Tạo file mới crates/shop-api/src/extractors/validated_json.rs:

// File: crates/shop-api/src/extractors/validated_json.rs
use axum::extract::{FromRequest, Request};
use serde::de::DeserializeOwned;
use shop_common::error::AppError;
use validator::Validate;

use crate::extractors::AppJson;

/// Wrapper extractor compose AppJson + validate. Pattern lock B41 cho mọi
/// handler create/update Shop API (G7 B62 products, B105 register,
/// B106 cart items, B115 orders, B135 admin).
///
/// Flow:
///   1. `AppJson::<T>::from_request` parse JSON bytes → struct T.
///      Fail → `AppError::BadRequest` 400 (lock B32 map JsonRejection).
///   2. `value.validate()?` check rule trên struct đã parse.
///      Fail → `AppError::ValidationFailed(ValidationErrors)` 422
///      qua `From<ValidationErrors>` impl lock B41.
///   3. Return `ValidatedJson(value)`.
#[derive(Debug, Clone)]
pub struct ValidatedJson<T>(pub T);

impl<T, S> FromRequest<S> for ValidatedJson<T>
where
    T: DeserializeOwned + Validate,
    S: Send + Sync,
{
    type Rejection = AppError;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        // Bước 1: extract qua AppJson — rejection 400 lock B32
        let AppJson(value) = AppJson::<T>::from_request(req, state).await?;

        // Bước 2: validate — rejection 422 qua From<ValidationErrors> lock B41
        value.validate()?;

        // Bước 3: wrap success
        Ok(ValidatedJson(value))
    }
}

Cập nhật crates/shop-api/src/extractors/mod.rs re-export top-level:

// File: crates/shop-api/src/extractors/mod.rs
pub mod json;
pub mod path;
pub mod query;
pub mod validated_json; // NEW B41

pub use json::AppJson;
pub use path::AppPath;
pub use query::AppQuery;
pub use validated_json::ValidatedJson; // NEW B41

Trait bound chi tiết:

  • T: DeserializeOwned: kế thừa từ AppJson — type T phải implement serde::de::DeserializeOwned để parse JSON. Lifetime 'static (Owned) vì body consume hết một lần.
  • T: Validate: yêu cầu type T implement validator::Validate trait — qua #[derive(Validate)] macro hoặc impl thủ công cho rule phức tạp.
  • S: Send + Sync: state generic bound — standard cho axum extractor working trong multi-thread runtime.
  • FromRequest<S> chứ không phải FromRequestParts<S> — vì AppJson consume body (lock B31), ValidatedJson wrap nên cũng consume body → phải dùng FromRequest + đặt CUỐI arg list handler.

Verify compile:

cd shop && cargo build -p shop-api
# Output: Finished `dev` profile [unoptimized + debuginfo] target(s)
7

Refactor create_product Handler Dùng ValidatedJson<T>

Handler create_product trong crates/shop-api/src/routes/products.rs đã có preview B40 dùng AppJson<CreateProductDto> + return Created<ProductDto>. B41 refactor thay AppJson bằng ValidatedJson — handler không cần gọi dto.validate()? thủ công nữa:

// File: crates/shop-api/src/routes/products.rs (refactor B41 create_product)
use axum::extract::State;
use shop_common::dto::CreateProductDto;
use shop_common::error::AppResult;

use crate::extractors::ValidatedJson;
use crate::responses::Created;
use crate::state::AppState;

async fn create_product(
    State(state): State<AppState>,
    ValidatedJson(dto): ValidatedJson<CreateProductDto>,
) -> AppResult<Created<ProductDto>> {
    tracing::info!(
        port = state.config.port,
        product_name = %dto.name,
        "creating product"
    );

    // Bước business logic — sẽ implement đầy đủ G7 B62 với
    // state.product_service.create(dto).await
    // Hiện tại preview: build ProductDto skeleton cho compile pass
    let product = ProductDto {
        id: 1,
        slug: dto.slug.clone(),
        name: dto.name,
        price: dto.price,
        stock: dto.stock,
    };

    Ok(Created {
        location: format!("/api/v1/products/{}", product.slug),
        data: product,
    })
}

So sánh trước/sau:

  • Trước B41 (pattern manual): AppJson(dto): AppJson<CreateProductDto> + dòng đầu handler dto.validate()?; — handler phải nhớ gọi validate, dễ quên trên endpoint mới.
  • Sau B41 (pattern wrapper): ValidatedJson(dto): ValidatedJson<CreateProductDto> — validate tự động ở extractor, handler chỉ tập trung business logic. Quên validate là impossible vì compiler ép qua type.

3 nhánh rejection có thể xảy ra ở extractor:

  • JSON parse fail (vd {"price":"abc"}) → AppJson reject 400 BAD_REQUEST + envelope {error, code, request_id} chuẩn B16.
  • Validation fail (vd {"name":"x"} name quá ngắn) → value.validate()? fail → AppError::ValidationFailed qua From → 422 VALIDATION_FAILED + envelope {error, code, request_id, fields}.
  • Success → handler nhận dto đã parse và validated, chạy business logic.

Pattern này lock G7+ MANDATORY cho mọi handler create/update Shop API tương lai. KHÔNG sáng tạo riêng — dùng đồng nhất ValidatedJson<T> cho tất cả: register, login, cart items, orders, admin products.

Verify compile:

cd shop && cargo build -p shop-api
# Output: Finished `dev` profile [unoptimized + debuginfo] target(s)
8

Verify Với Curl

Chạy server:

cd shop && cargo run -p shop-api
# Output:
# shop-api listening addr=0.0.0.0:3000

Test 1: Valid request → 201 Created + Location header + body:

curl -i -X POST http://localhost:3000/api/v1/products \
  -H 'Content-Type: application/json' \
  -d '{"name":"iPhone 15 Pro Max","slug":"iphone-15-pro-max","price":25000000,"stock":10}'

# HTTP/1.1 201 Created
# location: /api/v1/products/iphone-15-pro-max
# content-type: application/json
# x-request-id: 550e8400-e29b-41d4-a716-446655440000
#
# {"id":1,"slug":"iphone-15-pro-max","name":"iPhone 15 Pro Max","price":25000000,"stock":10}

JSON parse OK, validate OK (name 18 ký tự ∈ [3,200], slug match regex, price 25M ∈ [1, 100M], stock 10 ∈ [0, 1M]) → handler chạy → trả 201.

Test 2: Validation fail (name quá ngắn + slug invalid + price = 0) → 422 + envelope fields:

curl -i -X POST http://localhost:3000/api/v1/products \
  -H 'Content-Type: application/json' \
  -d '{"name":"x","slug":"INVALID SLUG","price":0,"stock":10}'

# HTTP/1.1 422 Unprocessable Entity
# content-type: application/json
# x-request-id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
#
# {
#   "error": "validation failed",
#   "code": "VALIDATION_FAILED",
#   "request_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
#   "fields": {
#     "name": ["tên sản phẩm dài 3-200 ký tự"],
#     "slug": ["slug chỉ chứa chữ thường, số và dấu gạch ngang"],
#     "price": ["giá phải lớn hơn 0 và không vượt 100 triệu VND"]
#   }
# }

JSON parse OK (cú pháp đúng + type match) → validate fail 3 field cùng lúc (validator chạy hết mọi field không stop sớm) → envelope chứa cả 3 field error trong 1 response. Field stock không có trong fieldsstock: 10 hợp lệ.

Test 3: JSON parse fail (price wrong type) → 400 + envelope BAD_REQUEST:

curl -i -X POST http://localhost:3000/api/v1/products \
  -H 'Content-Type: application/json' \
  -d '{"name":"Test Product","slug":"test-product","price":"abc","stock":10}'

# HTTP/1.1 400 Bad Request
# content-type: application/json
# x-request-id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
#
# {
#   "error": "bad request: JSON data error: invalid type: string \"abc\", expected u64 at line 1 column 50",
#   "code": "BAD_REQUEST",
#   "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
# }

JSON parse fail tại field price (client gửi string thay u64) → AppJson reject 400 trước khi validate có cơ hội chạy. Envelope KHÔNG có field fields — đúng semantic 400 vs 422.

Suggested commit:

git add crates/shop-common/Cargo.toml \
        crates/shop-common/src/dto.rs \
        crates/shop-common/src/lib.rs \
        crates/shop-common/src/error.rs \
        crates/shop-api/Cargo.toml \
        crates/shop-api/src/extractors/mod.rs \
        crates/shop-api/src/extractors/validated_json.rs \
        crates/shop-api/src/routes/products.rs \
        Cargo.toml

git commit -m "B41: validator crate + ValidatedJson extractor + 422 envelope + MỞ Group 5"
9

Tổng Kết

  • Extract vs Validate: hai bước riêng biệt — parse JSON bytes thành struct (fail → 400 Bad Request, client gửi rác) vs check business rule trên struct đã parse (fail → 422 Unprocessable Entity, server hiểu nhưng dữ liệu vi phạm rule).
  • validator crate v0.18: derive macro #[derive(Validate)] + field attribute đa dạng length / regex / range / email / url / must_match / contains / custom.
  • CreateProductDto áp 4 rule: name length 3-200, slug regex kebab-case, price range 1 đến 100 triệu VND, stock range 0 đến 1 triệu — đặt ở shop-common::dto để cross-crate share với shop-core service G7.
  • AppError::ValidationFailed(ValidationErrors) variant mới + impl From<ValidationErrors> cho operator ? + match arm trong IntoResponse build envelope 422.
  • 422 envelope structure lock vĩnh viễn: {error, code: "VALIDATION_FAILED", request_id, fields: {field_name: [messages]}} với fieldsHashMap<String, Vec<String>> (một field có thể fail nhiều rule cùng lúc).
  • ValidatedJson<T> wrapper extractor compose AppJson (extract + 400 nếu parse fail) + validate()? (422 nếu rule fail) — handler clean không cần gọi validate thủ công.
  • File path lock B41: crates/shop-common/src/dto.rs + crates/shop-api/src/extractors/validated_json.rs.
  • Workspace dependencies mới: validator = "0.18", once_cell = "1", regex = "1".
  • Message tiếng Việt cho mọi field rule — UX user VN, không dùng default code English machine-readable.
  • SLUG_REGEX ^[a-z0-9]+(-[a-z0-9]+)*$ kebab-case lowercase lock vĩnh viễn cho mọi slug Shop API + Lazy<Regex> pattern compile once cho mọi regex constant (tránh recompile mỗi request tốn ~10μs).
  • Pattern ValidatedJson<T> MANDATORY mọi handler create/update Shop API từ G5+ (G7 B62 products, B105 register, B106 cart items, B115 orders, B135 admin).
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Tại sao validation fail trả 422 Unprocessable Entity chứ KHÔNG phải 400 Bad Request? Giải thích semantic 2 status code theo RFC 9110 và phân biệt với client form UI cần làm gì khác nhau.
  2. Field rule regex(path = *SLUG_REGEX) cần regex compile khi nào? Tại sao dùng Lazy<Regex> thay Regex::new mỗi request? Cost benchmark thực tế.
  3. ValidatedJson<T> wrapper extractor cần generic bound nào trên T? Tại sao DeserializeOwned + Validate đầy đủ? Phân biệt với bound FromRequestParts vs FromRequest.
  4. DTO CreateProductDto đặt ở shop-common thay shop-api. Lý do cụ thể với dependency graph và 3 use case cross-crate cụ thể.
  5. Field error envelope dùng HashMap<String, Vec<String>>. Tại sao value là Vec (một field có thể nhiều error) thay String đơn lẻ? Cho ví dụ field hợp lý có 2+ rule cùng fail.
Đáp án
  1. 422 vs 400 — phân biệt semantic theo RFC 9110. 400 Bad Request (RFC 9110 mục 15.5.1): "server không thể hoặc sẽ không xử lý request do client sai" — generic cho lỗi client-side bao gồm cú pháp sai (malformed syntax), routing sai, framing sai (Content-Length không khớp body). Trong context API Shop: 400 đặc biệt cho JSON parse fail (client gửi rác, server không thể hiểu request là gì); {"price":"abc"} hoặc {name: "x"} thiếu quote — server không deserialize được sang struct Rust. 422 Unprocessable Entity (RFC 4918 mục 11.2 + RFC 9110 ref): "server hiểu Content-Type, request entity cú pháp đúng nhưng không xử lý được do semantic errors trong instructions" — nghĩa là cú pháp OK + type OK nhưng dữ liệu vi phạm business rule. Trong context API Shop: 422 dành cho validation fail (server đã parse JSON thành struct + check rule + phát hiện rule vi phạm); {"price":0} parse OK (u64 valid) nhưng giá 0 vô lý cho sản phẩm. Phân biệt client form UI: (a) 400 BAD_REQUEST → client SPA hiển thị toast generic "request format sai, liên hệ admin" hoặc redirect lỗi infrastructure — vì user không có cách sửa ngay (lỗi từ code FE bug hoặc API contract drift); (b) 422 VALIDATION_FAILED → client SPA hiển thị inline field error dưới mỗi input form (vd dưới <input name="name"> hiện text đỏ "tên sản phẩm dài 3-200 ký tự") để user sửa lại submit. Client đọc fields từ envelope 422 map sang state form UI. Anti-pattern phổ biến: trả 400 cho validation fail làm client phải parse error text để biết field nào sai (regex match message), fragile khi đổi text → tăng coupling FE-BE; trả 200 với {"success":false, "errors":...} body anti-pattern phá cache layer + monitoring (lock B3 cấm).
  2. Regex compile timing + Lazy benefit. Khi nào regex compile: Regex::new(r"...") parse pattern string + build NFA/DFA state machine trong memory + return Result<Regex, regex::Error>. Compile chỉ chạy lúc gọi Regex::new — sau đó regex.is_match(input) chạy nhanh execute state machine. Tại sao Lazy<Regex> thay Regex::new mỗi request: 3 lý do — (a) Performance: Regex::new tốn ~5-50μs per pattern phụ thuộc complexity (slug regex đơn giản ~10μs); ở traffic 1000 req/s, recompile mỗi request tốn 10ms CPU thuần — khoảng 1% CPU một core lãng phí cho việc không cần lặp lại. (b) Memory allocation: Regex struct chiếm ~1-5KB heap; compile lặp gây allocation pressure + fragmentation heap, GC pressure trong runtime async của tokio. (c) Code clarity: Lazy<Regex> đặt ở module level làm regex pattern visible như constant, sửa pattern 1 chỗ áp dụng toàn bộ; nếu inline Regex::new trong function, pattern rải rác khắp codebase, dễ inconsistent (vd slug regex ở 2 chỗ khác nhau). Cost benchmark thực tế: bench với criterion crate cho slug regex pattern ^[a-z0-9]+(-[a-z0-9]+)*$ trên input 20 ký tự: compile Regex::new ~12μs ± 1μs single-threaded, execute is_match ~50ns ± 5ns — chênh lệch ~240x. Với 1000 req/s, compile mỗi request tốn 12ms/s CPU; Lazy compile once tốn 12μs total lifetime của process (one-time amortize). once_cell::sync::Lazy semantic: thread-safe lazy initialization — first thread truy cập trigger compile, các thread khác block đợi xong, sau đó tất cả truy cập deref qua atomic pointer ~1ns negligible. std::sync::LazyLock alternative: Rust 1.80+ có std::sync::LazyLock trong stdlib làm equivalent — Shop API dùng once_cell để compat với crate ecosystem rộng hơn (validator crate dùng once_cell internally lock cùng version). Pattern lock vĩnh viễn: mọi regex constant cross-crate Shop API tương lai (email regex B104, phone VN regex B109, color hex regex admin, etc.) MANDATORY dùng Lazy<Regex> + .expect("invalid regex pattern") panic-on-init (chấp nhận panic startup vì pattern hardcode developer-error nếu sai, không phải runtime user input).
  3. Generic bound ValidatedJson<T>. Bound đầy đủ: impl<T, S> FromRequest<S> for ValidatedJson<T> where T: DeserializeOwned + Validate, S: Send + Sync. Tại sao DeserializeOwned cần: extractor consume body một lần thành owned struct (không borrow gì từ request); AppJson<T>::from_request internally gọi serde_json::from_slice::<T>(&bytes) require T: DeserializeOwned vì JSON parse cần allocate String/Vec/HashMap (owned types) không thể borrow zero-copy. DeserializeOwned alias for<'de> Deserialize<'de> nghĩa "deserialize được với mọi lifetime 'de" — implies 'static output. Tại sao Validate cần: extractor gọi value.validate()? require T impl validator::Validate trait có method fn validate(&self) -> Result<(), ValidationErrors>; impl tự động qua #[derive(Validate)] macro nếu tất cả field rule hợp lệ, hoặc impl thủ công cho rule cross-field phức tạp (vd password = password_confirm). Tại sao S: Send + Sync cần: AppState generic S phải share giữa nhiều handler đồng thời trên multi-thread runtime tokio multi_thread — extractor receive state: &S reference; bound Send + Sync đảm bảo state có thể clone qua thread boundary + đọc đồng thời an toàn. Phân biệt FromRequestParts vs FromRequest (lock B31): (a) FromRequestParts chỉ truy cập http::request::Parts (method, uri, headers, extensions) KHÔNG consume body → có thể đặt nhiều extractor cùng implement trait này trong arg list handler thứ tự bất kỳ; ví dụ AppPath, AppQuery, TypedHeader, State, Extension. (b) FromRequest consume body bytes một lần qua Request::into_body() + đọc stream → CHỈ ĐƯỢC ĐẶT MỘT extractor FromRequest trên handler + PHẢI ĐẶT CUỐI arg list (axum compiler enforce qua trait bound); ví dụ AppJson, ValidatedJson, Form, Multipart, Bytes, String. Bound chọn cho ValidatedJson: vì wrap AppJson consume body → MUST impl FromRequest<S> không thể impl FromRequestParts<S>. Bound KHÔNG cần: T: Send (vì FromRequest trả Future không Send + 'static nếu state KHÔNG Send), T: 'static (implied by DeserializeOwned). Pattern lock: mọi wrapper extractor compose body-consuming Shop API tương lai dùng same bound template — vd future ValidatedForm<T>, ValidatedMultipart<T>.
  4. DTO ở shop-common — lý do dependency graph + 3 use case. Dependency graph Shop API lock B10: shop-api → shop-core → shop-common (mũi tên = phụ thuộc), shop-db → shop-core → shop-common, shop-cache → shop-core → shop-common, shop-worker → shop-core → shop-common; shop-common ở đáy không phụ thuộc crate nội bộ nào (chỉ deps external như axum, serde, validator); shop-core pure domain logic không phụ thuộc HTTP framework. Vấn đề nếu đặt CreateProductDto ở shop-api: từ G7 onward shop-core::service::ProductService có method async fn create(dto: CreateProductDto) -> Result<Product, ServiceError> nhận DTO làm input; nếu CreateProductDtoshop-api, shop-core KHÔNG import được (đảo chiều dependency graph — vi phạm Clean Architecture). Có 3 workaround tệ: (a) đặt shop-api phụ thuộc shop-core đảo chiều → circular dep → compile fail; (b) define lại DTO trong shop-core như ProductInput rồi convert ở handler let input = ProductInput::from(dto); service.create(input).await — duplicate type + boilerplate convert mọi endpoint; (c) đặt service trong shop-api → mất separation HTTP layer vs domain layer → khó test service không bootstrap axum. Giải pháp đúng: đặt DTO ở shop-common::dto — cả shop-api (HTTP boundary validate qua ValidatedJson) và shop-core (service nhận làm input) cùng import được. 3 use case cross-crate cụ thể: (1) shop-api HTTP layer: handler create_product(ValidatedJson(dto): ValidatedJson<CreateProductDto>) dùng để parse + validate request JSON body từ client; (2) shop-core service layer: ProductService::create(dto: CreateProductDto) dùng làm input cho business logic (apply discount rule, check stock availability, generate slug fallback nếu user empty) — không cần re-validate vì đã pass extractor; (3) shop-worker job queue: future G21 có job BulkImportProductsJob { dtos: Vec<CreateProductDto> } import từ CSV admin upload, serialize qua serde_json để store Redis queue, deserialize lúc consume job — cần share DTO type giữa producer (HTTP handler import endpoint) và consumer (worker process). Pattern lock vĩnh viễn: mọi DTO chia sẻ giữa HTTP + service + worker đặt ở shop-common::dto; DTO chỉ dùng internal HTTP (vd HealthCheckResponse, VersionResponse infrastructure) đặt ở shop-api::dto folder placeholder B17 — phân biệt qua scope sử dụng. ProductDto output (sau create) cũng đặt shop-common::dto để consistent.
  5. Vec<String> thay String đơn lẻ — một field nhiều rule fail. Validator crate semantic: validator chạy TẤT CẢ rule trên một field không stop sớm khi rule đầu fail (fail-fast = false default) — kết quả errors.field_errors() trả HashMap<&str, Vec<&ValidationError>> với Vec chứa mọi rule fail của field đó. Tại sao thiết kế Vec thay String: (a) Một field thực tế có nhiều rule: password thường có 3-5 rule (length min 8, length max 128, regex must contain digit, regex must contain special char, regex must not contain whitespace) — nếu client gửi password "abc" sẽ fail 3 rule cùng lúc (length min, contain digit, contain special), client cần biết hết 3 để hiển thị 3 message inline; (b) UX rõ ràng cho user: nếu chỉ trả 1 error đầu tiên, user sửa rồi submit lại lại nhận 1 error khác → trial-and-error frustrating; trả hết errors → user sửa 1 lần xong toàn bộ field; (c) Consistent với validation pattern industry: Laravel, Rails ActiveRecord, Django form, Yup/Joi/Zod JS schema validator đều trả mảng errors per field — Shop API align convention universal client SDK dễ map state UI. Ví dụ field hợp lý 2+ rule cùng fail: (i) password field B104 RegisterDto: #[validate(length(min=8, message="mật khẩu tối thiểu 8 ký tự"), regex(path=*PASSWORD_REGEX, message="mật khẩu phải có chữ hoa + chữ thường + số + ký tự đặc biệt"))] — input "abc" fail cả length (3 ký tự < 8) lẫn regex (thiếu hoa/số/special) → trả "password": ["mật khẩu tối thiểu 8 ký tự", "mật khẩu phải có chữ hoa + chữ thường + số + ký tự đặc biệt"]; (ii) email field: #[validate(length(max=254, message="email tối đa 254 ký tự"), email(message="email không hợp lệ"))] — input chuỗi 300 ký tự không có ký tự @ → fail cả length lẫn email format; (iii) name field: #[validate(length(min=3, max=200), regex(path=*NAME_REGEX, message="tên chỉ chứa chữ, số, khoảng trắng"))] — input "x@!" fail cả length (1 ký tự sau strip < 3) lẫn regex (chứa ký tự đặc biệt); (iv) slug field: nếu thêm length(min=3, max=100) bên cạnh regex hiện tại → input "X" fail cả length lẫn regex. Implementation detail: errors.field_errors() trả ref tới ValidationError struct chứa code: Cow<str> (rule name như "length"/"regex") + message: Option<Cow<str>> (custom message developer set) + params: HashMap<Cow<str>, Value> (rule param như min/max value); B41 envelope chỉ extract message hoặc fallback code nếu None, không expose params để giữ envelope minimal cho client. Future extend G7: có thể expose thêm code field bên cạnh message trong envelope nếu client cần i18n switch language (frontend tự lookup translation table qua code) — sẽ lock B53 i18n strategy.
11

Bài Tiếp Theo

— đi sâu pitfall serde JSON khi field optional/missing/null: Option<T> vs #[serde(default)] vs #[serde(deserialize_with)], double-Option pattern Option<Option<T>> qua serde_with::rust::double_option phân biệt 3 trạng thái missing / null / value cho PATCH partial update (RFC 7396 merge-patch), #[serde(skip_serializing_if)] giảm payload null trả về, áp dụng UpdateProductDto Shop API trong shop-common::dto cho endpoint PATCH /api/v1/products/:slug.