Mục lục
- Mục Tiêu Bài Học
- Extract vs Validate — Hai Bước Khác Nhau
- Cài
validatorCrate #[derive(Validate)]Cho DTO + Field RulesAppError::ValidationFailedVariant MớiValidatedJson<T>Wrapper Extractor- Refactor
create_productHandler DùngValidatedJson<T> - Verify Với Curl
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
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
validatorcrate v0.18 qua workspace dependency lock cross-crate. - Áp dụng
#[derive(Validate)]cho DTO với 4 field rule phổ biến trênCreateProductDto: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 composeAppJson+ validate sau extract — handler clean không phải gọidto.validate()?thủ công. - Thêm variant
AppError::ValidationFailed(ValidationErrors)+ extendimpl IntoResponsetrả 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 + patternLazy<Regex>compile once cho mọi regex constant Shop API.
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: 0parse được thànhu64như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ộ.
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)
#[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 choStringhoặcVec<T>— đếm số ký tự (UTF-8 grapheme cluster, không phải byte). Fieldnamegiớ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*EXPRderefLazy<Regex>. Phải dùng constantstaticcompile once không phải string literal — vìRegex::newmỗi request tốn ~10μs.range(min, max): dùng cho mọi số (integer + float). Fieldprice: u64giới hạn 1 (giá phải > 0) đến 100 triệu VND cap an toàn cho domain e-commerce VN; fieldstock: u32giớ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 choRegisterDto.email(B104).url: validate URL valid — dùng choUpdateProfileDto.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)
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 (vdpasswordvừa faillength(min=8)vừa failregex(must contain digit)). - Fallback
codenếumessageNone: validator cho phép omitmessageattribute — khi đó message trống, dùngcodemachine-readable như"length"/"range"/"regex". Shop API luôn set message tiếng Việt nên fallback hiếm khi hit. request_idplaceholdernull— middlewareenrich_error_responseB39 sẽ inject giá trị thật vì content-typeapplication/jsonmatch điều kiện enrich.
Verify compile:
cd shop && cargo build -p shop-common
# Output: Finished `dev` profile [unoptimized + debuginfo] target(s)
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 implementserde::de::DeserializeOwnedđể parse JSON. Lifetime'static(Owned) vì body consume hết một lần.T: Validate: yêu cầu type T implementvalidator::Validatetrait — 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ảiFromRequestParts<S>— vìAppJsonconsume body (lock B31),ValidatedJsonwrap nên cũng consume body → phải dùngFromRequest+ đặt CUỐI arg list handler.
Verify compile:
cd shop && cargo build -p shop-api
# Output: Finished `dev` profile [unoptimized + debuginfo] target(s)
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 handlerdto.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"}) →AppJsonreject 400BAD_REQUEST+ envelope{error, code, request_id}chuẩn B16. - Validation fail (vd
{"name":"x"}name quá ngắn) →value.validate()?fail →AppError::ValidationFailedquaFrom→ 422VALIDATION_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)
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 fields vì stock: 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"
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).
validatorcrate v0.18: derive macro#[derive(Validate)]+ field attribute đa dạnglength/regex/range/email/url/must_match/contains/custom.CreateProductDtoáp 4 rule:namelength 3-200,slugregex kebab-case,pricerange 1 đến 100 triệu VND,stockrange 0 đến 1 triệu — đặt ởshop-common::dtođể cross-crate share vớishop-coreservice G7.AppError::ValidationFailed(ValidationErrors)variant mới +impl From<ValidationErrors>cho operator?+ match arm trongIntoResponsebuild envelope 422.- 422 envelope structure lock vĩnh viễn:
{error, code: "VALIDATION_FAILED", request_id, fields: {field_name: [messages]}}vớifieldslàHashMap<String, Vec<String>>(một field có thể fail nhiều rule cùng lúc). ValidatedJson<T>wrapper extractor composeAppJson(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).
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- 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.
- Field rule
regex(path = *SLUG_REGEX)cần regex compile khi nào? Tại sao dùngLazy<Regex>thayRegex::newmỗi request? Cost benchmark thực tế. ValidatedJson<T>wrapper extractor cần generic bound nào trên T? Tại saoDeserializeOwned + Validateđầy đủ? Phân biệt với boundFromRequestPartsvsFromRequest.- DTO
CreateProductDtođặt ởshop-commonthayshop-api. Lý do cụ thể với dependency graph và 3 use case cross-crate cụ thể. - 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
- 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 đọcfieldstừ envelope 422 map sang state form UI. Anti-pattern phổ biến: trả 400 cho validation fail làm client phải parseerrortext để 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). - Regex compile timing + Lazy benefit. Khi nào regex compile:
Regex::new(r"...")parse pattern string + build NFA/DFA state machine trong memory + returnResult<Regex, regex::Error>. Compile chỉ chạy lúc gọiRegex::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::newtố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:Regexstruct 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 inlineRegex::newtrong 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ự: compileRegex::new~12μs ± 1μs single-threaded, executeis_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::LazyLocktrong stdlib làm equivalent — Shop API dùngonce_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ùngLazy<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). - 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_requestinternally gọiserde_json::from_slice::<T>(&bytes)requireT: DeserializeOwnedvì JSON parse cần allocate String/Vec/HashMap (owned types) không thể borrow zero-copy.DeserializeOwnedaliasfor<'de> Deserialize<'de>nghĩa "deserialize được với mọi lifetime'de" — implies'staticoutput. Tại sao Validate cần: extractor gọivalue.validate()?requireTimplvalidator::Validatetrait có methodfn 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:AppStategenericSphải share giữa nhiều handler đồng thời trên multi-thread runtime tokio multi_thread — extractor receivestate: &Sreference; boundSend + 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ậphttp::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 quaRequest::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ì wrapAppJsonconsume body → MUST implFromRequest<S>không thể implFromRequestParts<S>. Bound KHÔNG cần:T: Send(vìFromRequesttrả Future không Send + 'static nếu state KHÔNG Send),T: 'static(implied byDeserializeOwned). Pattern lock: mọi wrapper extractor compose body-consuming Shop API tương lai dùng same bound template — vd futureValidatedForm<T>,ValidatedMultipart<T>. - 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-corepure domain logic không phụ thuộc HTTP framework. Vấn đề nếu đặt CreateProductDto ở shop-api: từ G7 onwardshop-core::service::ProductServicecó methodasync fn create(dto: CreateProductDto) -> Result<Product, ServiceError>nhận DTO làm input; nếuCreateProductDtoởshop-api,shop-coreKHÔNG import được (đảo chiều dependency graph — vi phạm Clean Architecture). Có 3 workaround tệ: (a) đặtshop-apiphụ thuộcshop-coređảo chiều → circular dep → compile fail; (b) define lại DTO trongshop-corenhưProductInputrồi convert ở handlerlet input = ProductInput::from(dto); service.create(input).await— duplicate type + boilerplate convert mọi endpoint; (c) đặt service trongshop-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 quaValidatedJson) 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: handlercreate_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ó jobBulkImportProductsJob { 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 (vdHealthCheckResponse,VersionResponseinfrastructure) đặt ởshop-api::dtofolder placeholder B17 — phân biệt qua scope sử dụng.ProductDtooutput (sau create) cũng đặtshop-common::dtođể consistent. - Vec<String> thay String đơn lẻ — một field nhiều rule fail. Validator crate semantic:
validatorchạ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ớiVecchứ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êmlength(min=3, max=100)bên cạnhregexhiện tại → input"X"fail cả length lẫn regex. Implementation detail:errors.field_errors()trả ref tớiValidationErrorstruct chứacode: 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ỉ extractmessagehoặc fallbackcodenếu None, không exposeparamsđể giữ envelope minimal cho client. Future extend G7: có thể expose thêmcodefield bên cạnhmessagetrong envelope nếu client cần i18n switch language (frontend tự lookup translation table qua code) — sẽ lock B53 i18n strategy.
Bài Tiếp Theo
Bài 42: JSON Field Pitfalls — Optional, Default, Null — đ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.
