Mục lục
- Mục Tiêu Bài Học
- Vấn Đề: Default Rejection Không Có Envelope
- Pattern Wrapper Extractor — 3 Bước
- Step 1: extractors/path.rs — AppPath<T>
- Step 2: extractors/query.rs — AppQuery<T>
- Step 3: extractors/json.rs — AppJson<T>
- Step 4: extractors/mod.rs — Re-Export
- Refactor Handler list_products Dùng AppQuery
- CurrentUser Pattern Preview (B112)
- 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ẽ:
- Implement đầy đủ 3 custom wrapper extractor cho Shop API:
AppPath<T>,AppQuery<T>,AppJson<T>trong foldercrates/shop-api/src/extractors/(placeholder ready từ B17). - Map rejection default của axum (
PathRejection/QueryRejection/JsonRejection) sangAppError::BadRequesthoặcAppError::Validationđể có envelope chuẩn{error, code, request_id}lock B3/B16. - Hiểu pattern wrap built-in extractor + override rejection — 3 bước cố định áp dụng cho mọi custom extractor tương lai (
CurrentUserB112,IdempotencyKeyB197). - Biết skeleton
CurrentUserextractor preview B112: extract Bearer token từ Authorization header, verify JWT, build struct user identity, map rejection sangAppError::Unauthenticated. - Refactor handler
list_productstrongroutes/products.rsđổiQuery<Pagination>sangAppQuery<Pagination>+ returnAppResult<Json<Value>>, verify qua curl: invalid query (vd?page=abc) trả 400 + envelope JSON chuẩn thay text/plain raw default. - Lock convention G7+ MANDATORY: mọi handler từ Group 7 CRUD Cơ Bản onward dùng
AppPath/AppQuery/AppJsonwrapper, KHÔNG dùngaxum::Path/Query/Jsondefault.
Vấn Đề: Default Rejection Không Có Envelope
State sau B31: handler list_products dùng Query<Pagination> default của axum. Khi client gửi query string không parse được, axum tự dispatch rejection ra HTTP error — nhưng format response KHÔNG đồng nhất với Shop API envelope chuẩn. Test nhanh với cargo run -p shop-api rồi curl:
$ curl -v 'http://localhost:3000/api/v1/products?page=abc'
< HTTP/1.1 400 Bad Request
< content-type: text/plain; charset=utf-8
< content-length: 66
Failed to deserialize query string: invalid digit found in string
Vấn đề thấy ngay:
- Content-Type sai loại —
text/plainraw, không phảiapplication/jsonnhư mọi endpoint khác Shop API. Client phải xử lý 2 format khác nhau cho cùng endpoint (success Json, error text/plain). - Mất envelope chuẩn — Shop API lock B3/B16 envelope error
{ "error": "...", "code": "BAD_REQUEST", "request_id": null }. Default rejection bypass envelope này, client không có code machine-readable để switch logic, không có request_id để correlate log. - Message generic — "invalid digit found in string" không nói rõ field nào sai. Cần wrap thành "invalid query string: ..." giàu context hơn.
Mục tiêu: response invalid query phải giống mọi error response khác — JSON envelope đầy đủ. Pattern giải: custom wrapper extractor bọc bên ngoài extractor gốc, override type Rejection = AppError, delegate parse logic cho extractor gốc rồi map rejection sang AppError variant phù hợp. Endpoint thành công sau wrap:
$ curl -v 'http://localhost:3000/api/v1/products?page=abc'
< HTTP/1.1 400 Bad Request
< content-type: application/json
{"error":"bad request: invalid query string: invalid digit found in string","code":"BAD_REQUEST","request_id":null}
Đây mới là envelope nhất quán với mọi error response Shop API. B32 implement đầy đủ pattern này cho 3 extractor phổ biến nhất.
Pattern Wrapper Extractor — 3 Bước
Workflow tạo custom wrapper extractor gồm 3 bước cố định, áp dụng nhất quán cho mọi extractor mới tương lai:
- Bước 1: Tạo tuple struct wrapper —
pub struct AppXxx<T>(pub T);generic theoTđể wrap mọi kiểu inner. Field public cho phép destructure trong handlerAppXxx(value): AppXxx<Pagination>tự nhiên như extractor gốc. - Bước 2: Impl trait tương ứng —
FromRequestParts<S>cho extractor không đụng body (Path/Query/State/TypedHeader), hoặcFromRequest<S>cho extractor consume body (Json/Form/Bytes).type Rejection = AppErroroverride rejection mặc định. Trait boundT: DeserializeOwned + Sendgiữ generic flexible,S: Send + Synctheo yêu cầu axum. - Bước 3: Delegate + map rejection — trong method
from_request_parts(hoặcfrom_request), gọi extractor gốc làm parse logic chính (vdPath::<T>::from_request_parts(parts, state).await),matchtrên kết quả:Okunwrap inner và wrap lại bằngAppXxx,Err(rejection)map sangAppErrorvariant phù hợp qua hàm helpermap_xxx_rejection.
Lý do delegate thay vì viết parse logic from scratch:
- Extractor gốc của axum đã xử lý mọi edge case (URL encoding, multi-value query, content-type negotiation) — viết lại tốn công và dễ sót case.
- Khi axum nâng version có cải tiến parse (vd hỗ trợ thêm content-type), wrapper tự động hưởng lợi mà không cần sửa code.
- Pattern đồng nhất — đọc 1 extractor hiểu 3 extractor, code review nhanh.
3 step tiếp theo implement đầy đủ pattern cho Path, Query, Json. Folder crates/shop-api/src/extractors/ đã có sẵn từ B17 (placeholder với mod.rs rỗng) — chỉ cần thêm 3 file mới + cập nhật mod.rs re-export.
Step 1: extractors/path.rs — AppPath<T>
Tạo file mới crates/shop-api/src/extractors/path.rs chứa AppPath<T> wrap axum::Path<T>. Pattern này dùng cho mọi path parameter Shop API (vd /products/:slug, /products/:slug/related/:related_slug):
// File: crates/shop-api/src/extractors/path.rs
use axum::{
extract::{rejection::PathRejection, FromRequestParts, Path},
http::request::Parts,
};
use serde::de::DeserializeOwned;
use shop_common::error::AppError;
/// Wrapper extractor cho path parameter — rejection map sang AppError::BadRequest
/// với envelope chuẩn Shop API (lock B3/B16) thay text/plain raw default.
pub struct AppPath<T>(pub T);
impl<T, S> FromRequestParts<S> for AppPath<T>
where
T: DeserializeOwned + Send,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
match Path::<T>::from_request_parts(parts, state).await {
Ok(Path(value)) => Ok(AppPath(value)),
Err(rejection) => Err(map_path_rejection(rejection)),
}
}
}
fn map_path_rejection(rejection: PathRejection) -> AppError {
match rejection {
PathRejection::FailedToDeserializePathParams(err) => {
AppError::BadRequest(format!("invalid path parameter: {}", err))
}
PathRejection::MissingPathParams(_) => {
AppError::BadRequest("missing path parameter".to_string())
}
_ => AppError::BadRequest("invalid path".to_string()),
}
}
Điểm chú ý code:
- Import
PathRejectiontừaxum::extract::rejection— đây là enum tổng tập hợp mọi cách Path extract có thể fail. axum 0.8 (lock B10) định nghĩa 3 variant chính:FailedToDeserializePathParams(parse fail vd:idrequirei64nhưng client gửiabc),MissingPathParams(route khai báo:slugnhưng URL không có), và variant_non-exhaustive cho future-compat (axum giữ quyền thêm variant mới ở minor version sau, code wildcard match đảm bảo wrapper không break khi upgrade). - Helper function
map_path_rejectiontách riêng — không inline trongmatcharm vì 2 lý do: (a) test unit-able cô lập logic mapping; (b) dễ extend khi cần thêm variant mới hoặc đổi message format. - Trait bound
T: DeserializeOwned + Sendidentical với extractor gốc — wrapper không thêm constraint nào ngoài cái axum yêu cầu. - Generic
S: Send + Sync— state type không cố địnhAppStatevì wrapper là thư viện reuse cross-resource, không tie cụ thể vào kiểu state nào.
Mọi rejection của AppPath giờ trả 400 + envelope chuẩn qua impl IntoResponse for AppError lock B16 — flow tự động không cần handler xử lý.
Step 2: extractors/query.rs — AppQuery<T>
Tạo file mới crates/shop-api/src/extractors/query.rs chứa AppQuery<T> wrap axum::Query<T>. Pattern cấu trúc giống hệt AppPath, chỉ khác kiểu extractor gốc và enum rejection:
// File: crates/shop-api/src/extractors/query.rs
use axum::{
extract::{rejection::QueryRejection, FromRequestParts, Query},
http::request::Parts,
};
use serde::de::DeserializeOwned;
use shop_common::error::AppError;
/// Wrapper extractor cho query string — rejection map sang AppError::BadRequest
/// với envelope chuẩn Shop API (lock B3/B16) thay text/plain raw default.
pub struct AppQuery<T>(pub T);
impl<T, S> FromRequestParts<S> for AppQuery<T>
where
T: DeserializeOwned + Send,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
match Query::<T>::from_request_parts(parts, state).await {
Ok(Query(value)) => Ok(AppQuery(value)),
Err(rejection) => Err(map_query_rejection(rejection)),
}
}
}
fn map_query_rejection(rejection: QueryRejection) -> AppError {
match rejection {
QueryRejection::FailedToDeserializeQueryString(err) => {
AppError::BadRequest(format!("invalid query string: {}", err))
}
_ => AppError::BadRequest("invalid query".to_string()),
}
}
So với AppPath:
QueryRejectionchỉ có 1 variant chính —FailedToDeserializeQueryStringbao trùm mọi case parse fail (field thiếu mặc dùPaginationcó#[serde(default)]nên thường không hit case này, type mismatch như?page=abchit nhiều nhất, format URL-encoded sai). Variant_giữ cho future-compat.- Không có
MissingPathParamstương đương — query string optional theo spec, missing không phải lỗi (serde default kick in). - Message giàu context —
format!("invalid query string: {}", err)wrap message gốc của serde (vd "invalid digit found in string") để client biết đây là lỗi query parse, không phải logic.
Pattern identical đến nỗi có thể trừu tượng hóa qua macro (vd declare_wrapper!(AppQuery, Query, QueryRejection)), nhưng Shop API chọn implement explicit 3 file riêng vì rõ ràng cho người đọc + dễ thêm logic riêng (vd field-level validation cho AppJson) khi cần.
Step 3: extractors/json.rs — AppJson<T>
Tạo file mới crates/shop-api/src/extractors/json.rs chứa AppJson<T> wrap axum::Json<T>. Điểm khác biệt LỚN so với 2 extractor trên: Json consume body nên impl FromRequest chứ KHÔNG phải FromRequestParts (lock B31):
// File: crates/shop-api/src/extractors/json.rs
use axum::{
extract::{rejection::JsonRejection, FromRequest, Request},
Json,
};
use serde::de::DeserializeOwned;
use shop_common::error::AppError;
/// Wrapper extractor cho JSON body — rejection map sang AppError::Validation
/// (JsonDataError 422 schema sai) hoặc AppError::BadRequest (JsonSyntaxError 400
/// parse fail, MissingJsonContentType 400, BytesRejection 400) với envelope
/// chuẩn Shop API (lock B3/B16) thay text/plain raw default.
pub struct AppJson<T>(pub T);
impl<T, S> FromRequest<S> for AppJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
match Json::<T>::from_request(req, state).await {
Ok(Json(value)) => Ok(AppJson(value)),
Err(rejection) => Err(map_json_rejection(rejection)),
}
}
}
fn map_json_rejection(rejection: JsonRejection) -> AppError {
match rejection {
JsonRejection::JsonDataError(err) => {
AppError::Validation(format!("JSON data error: {}", err))
}
JsonRejection::JsonSyntaxError(err) => {
AppError::BadRequest(format!("JSON syntax error: {}", err))
}
JsonRejection::MissingJsonContentType(_) => {
AppError::BadRequest("missing Content-Type: application/json".to_string())
}
JsonRejection::BytesRejection(err) => {
AppError::BadRequest(format!("body read error: {}", err))
}
_ => AppError::BadRequest("invalid JSON".to_string()),
}
}
Điểm khác biệt code so với Path/Query:
- Trait
FromRequestthayFromRequestParts— vì Json consume body. Method nhậnRequestowned (consume cả parts lẫn body), không phải&mut Partsmutable reference. - Bỏ trait bound
T: Send—FromRequestkhông yêu cầuT: Sendtường minh (associated future tự đảm bảo qua bound implicit), chỉ cầnT: DeserializeOwned. - 4 variant
JsonRejectionmap sang 2AppErrorkhác nhau — phân biệt quan trọng giữa lỗi schema và lỗi parse:JsonDataError→AppError::Validation422 Unprocessable Entity — JSON parse OK (cú pháp đúng) nhưng schema sai (vd missing required field, type mismatch). Đây là semantic "server hiểu request nhưng data không hợp lệ", đúng nghĩa 422 (lock B3).JsonSyntaxError→AppError::BadRequest400 Bad Request — JSON parse fail (cú pháp sai vd{name: "x"}thiếu quote). Đây là "client gửi rác", đúng nghĩa 400.MissingJsonContentType→ 400 Bad Request — request thiếu headerContent-Type: application/json. Map 400 thay 415 Unsupported Media Type cho consistent với mọi parse error còn lại của Shop API (Shop API JSON-only policy lock B5, content-type sai là client bug rõ ràng).BytesRejection→ 400 Bad Request — body stream read fail (network drop, body size vượt limit). Map 400 dù root cause có thể là 413, nhưng đa số case là client malformed.
Phân biệt JsonDataError (422) vs JsonSyntaxError (400) cực quan trọng cho client. Form validation UI thường switch logic theo code: 422 → hiển thị inline field error, 400 → hiển thị toast "request format sai liên hệ admin". Wire này khớp với plan B41 (validator crate) sẽ thêm field-level errors vào AppError::Validation details.
Step 4: extractors/mod.rs — Re-Export
Cập nhật crates/shop-api/src/extractors/mod.rs (file placeholder rỗng từ B17) declare 3 submodule + re-export 3 type top-level:
// File: crates/shop-api/src/extractors/mod.rs
pub mod path;
pub mod query;
pub mod json;
pub use path::AppPath;
pub use query::AppQuery;
pub use json::AppJson;
Pattern re-export này cho phép handler import 1 dòng ngắn gọn:
// Ngắn — preferred
use crate::extractors::{AppPath, AppQuery, AppJson};
// Thay vì dài như nếu không re-export
use crate::extractors::path::AppPath;
use crate::extractors::query::AppQuery;
use crate::extractors::json::AppJson;
Cấu trúc folder sau B32:
crates/shop-api/src/extractors/
├── mod.rs (re-export top-level)
├── path.rs (AppPath<T>)
├── query.rs (AppQuery<T>)
└── json.rs (AppJson<T>)
Module extractors đã được declare trong main.rs từ B17 (mod extractors;), không cần sửa main.rs. File crates/shop-api/Cargo.toml giữ nguyên — dep shop-common, axum, serde đã có sẵn từ B10/B16. Verify build:
$ cargo build -p shop-api
Compiling shop-api v0.1.0 (crates/shop-api)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.21s
Build pass tức 3 extractor compile đúng trait bound — chưa wire vào handler nào nên runtime behavior chưa đổi. Bước tiếp theo refactor list_products để verify end-to-end.
Refactor Handler list_products Dùng AppQuery
Cập nhật crates/shop-api/src/routes/products.rs: đổi handler list_products từ Query<Pagination> sang AppQuery<Pagination> và return type từ Json<Value> sang AppResult<Json<Value>>. Đây là endpoint FIRST của Shop API verify wrapper end-to-end:
// File: crates/shop-api/src/routes/products.rs (đoạn thay đổi)
use crate::extractors::AppQuery;
use crate::state::AppState;
use axum::{extract::State, Json};
use shop_common::error::AppResult;
use shop_common::pagination::{ListResponse, Pagination};
async fn list_products(
State(state): State<AppState>,
AppQuery(pagination): AppQuery<Pagination>, // ← thay Query bằng AppQuery
) -> AppResult<Json<serde_json::Value>> { // ← return AppResult
tracing::info!(
port = state.config.port,
page = pagination.page,
size = pagination.size,
"listing products"
);
let response = ListResponse::<serde_json::Value>::new(vec![], 0, &pagination);
Ok(Json(serde_json::to_value(response).unwrap()))
}
Thay đổi cụ thể so với B28 state:
- Import
use crate::extractors::AppQuery;thêm dòng đầu file (extractors module đã declare ởmain.rs). - Import
use shop_common::error::AppResult;để dùng cho return type. - Bỏ
use axum::extract::Query;nếu không còn handler nào dùngQuerydefault — nhưng tạm thời giữ vì 7 handler còn lại của file vẫn dùng (sẽ refactor tiếp ở G7). - Arg signature:
AppQuery(pagination): AppQuery<Pagination>thayQuery(pagination): Query<Pagination>. Destructure vẫn pattern tuple struct field 0. - Return type:
AppResult<Json<serde_json::Value>>thayJson<serde_json::Value>để rejection củaAppQueryflow quaAppError::into_responsetự động (lock B16). Body cuối cần wrapOk(...). - Convention order arg vẫn lock B22/B31:
State → Path → Query/AppQuery → Json/AppJson.AppQueryđứng sauStateđúng vị trí cũ củaQuery.
Verify end-to-end với cargo run -p shop-api + curl:
# Valid query — normal response 200
$ curl 'http://localhost:3000/api/v1/products?page=2&size=10'
{"items":[],"total":0,"page":2,"size":10,"hasNext":false}
# Default query (missing page/size dùng serde default)
$ curl 'http://localhost:3000/api/v1/products'
{"items":[],"total":0,"page":1,"size":20,"hasNext":false}
# Invalid query — rejection thành AppError envelope chuẩn
$ curl -v 'http://localhost:3000/api/v1/products?page=abc'
< HTTP/1.1 400 Bad Request
< content-type: application/json
{"error":"bad request: invalid query string: Failed to deserialize query string: invalid digit found in string","code":"BAD_REQUEST","request_id":null}
So sánh trước/sau wrap:
- Trước (B23 state):
content-type: text/plain, body raw "Failed to deserialize query string: invalid digit found in string" — không envelope. - Sau B32:
content-type: application/json, body envelope{error, code, request_id}vớicode: "BAD_REQUEST"machine-readable để client switch logic,request_idplaceholder (sẽ inject ở B39 Extension RequestId middleware).
Endpoint thành công verify pattern wrap end-to-end. 7 handler còn lại của routes/products.rs (create_product, list_popular, get_product, replace_product, update_product, delete_product, get_related_product) hiện vẫn dùng Path/Query/Json default — sẽ refactor đồng loạt ở G7 (B62-B67) khi implement business logic thật với sqlx. Convention lock G7+ MANDATORY: mọi handler từ Group 7 onward dùng wrapper.
CurrentUser Pattern Preview (B112)
Pattern 3 bước trên không giới hạn ở wrap built-in extractor — áp dụng được cho mọi extract logic tùy biến. Preview tiêu biểu nhất: CurrentUser extractor cho JWT auth, implement đầy đủ ở B112 sau khi JWT verify infra wire vào AppState. Skeleton minh họa pattern:
// File: crates/shop-api/src/extractors/current_user.rs (preview B112 — KHÔNG tạo ở B32)
use axum::{
extract::FromRequestParts,
http::{request::Parts, HeaderMap},
};
use shop_common::error::AppError;
pub struct CurrentUser {
pub id: i64,
pub role: String,
}
impl<S> FromRequestParts<S> for CurrentUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
// Bước 1: extract Bearer token từ Authorization header
let token = extract_bearer_token(&parts.headers)?;
// Bước 2: verify JWT + decode claims (B112 implement đầy đủ)
let claims = verify_jwt(&token)?;
Ok(CurrentUser {
id: claims.sub,
role: claims.role,
})
}
}
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
let auth = headers
.get("authorization")
.ok_or(AppError::Unauthenticated)?;
let s = auth
.to_str()
.map_err(|_| AppError::Unauthenticated)?;
s.strip_prefix("Bearer ")
.map(str::to_string)
.ok_or(AppError::Unauthenticated)
}
// Placeholder B112 — thật sẽ dùng jsonwebtoken crate verify với
// state.config.jwt_secret + algorithm HS256/RS256 (lock G12).
fn verify_jwt(_token: &str) -> Result<JwtClaims, AppError> {
Err(AppError::Unauthenticated)
}
struct JwtClaims {
sub: i64,
role: String,
}
Handler dùng CurrentUser chuẩn G12 onward:
// File: crates/shop-api/src/routes/me.rs (preview G12 — KHÔNG implement B32)
async fn get_me(
State(state): State<AppState>,
user: CurrentUser, // ← extract trực tiếp, không wrap tuple
) -> AppResult<Json<UserDto>> {
let dto = state.user_repo.find_by_id(user.id).await?;
Ok(Json(dto))
}
async fn admin_only(
State(state): State<AppState>,
CurrentUser { id, role }: CurrentUser, // ← destructure trong arg
) -> AppResult<Json<Value>> {
if role != "admin" {
return Err(AppError::Forbidden);
}
...
}
Điểm tinh tế của CurrentUser so với 3 wrapper trên:
- KHÔNG tuple struct generic — struct domain với field cụ thể
{id, role}, không wrapT. Đây là pattern cho extractor build từ scratch chứ không delegate. - Rejection map
AppError::Unauthenticated— không phảiBadRequest, vì thiếu/sai token là vấn đề auth (401) không phải parse (400).impl IntoResponse for AppErrortự thêm headerWWW-Authenticate: Bearer realm="shop-api"theo RFC 6750 (lock B16). - 2 bước workflow tách biệt — (1)
extract_bearer_tokenparse header, (2)verify_jwtdecode + validate signature/expiry. Tách giúp test cô lập từng bước. B112 implement đầy đủverify_jwtvớijsonwebtokencrate + state.config.jwt_secret. - State
_statekhông dùng ở skeleton — B112 sẽ dùng để lấyjwt_secrettừAppState::config, có thể thêm Redis check cho blacklist token (B118).
B32 KHÔNG tạo file current_user.rs — chỉ giới thiệu pattern. File thật sẽ tạo ở B112 sau khi jsonwebtoken crate add vào Cargo.toml + AppState wire encoding/decoding key. Pattern preview ở đây để bạn thấy mọi extractor Shop API tương lai (IdempotencyKey B197, RateLimitKey B158) theo cùng template 3 bước.
Tổng Kết
- Pattern wrapper extractor 3 bước cố định: tạo tuple struct generic, impl
FromRequestParts/FromRequestvớitype Rejection = AppError, delegate parse logic cho extractor gốc rồi map rejection sang variant phù hợp. AppPath<T>(fileextractors/path.rs) wrapaxum::Path, implFromRequestParts; rejectionPathRejection::FailedToDeserializePathParams/MissingPathParams→AppError::BadRequest400.AppQuery<T>(fileextractors/query.rs) wrapaxum::Query, implFromRequestParts; rejectionQueryRejection::FailedToDeserializeQueryString→AppError::BadRequest400.AppJson<T>(fileextractors/json.rs) wrapaxum::Json, implFromRequest(consume body); rejectionJsonRejection::JsonDataError→AppError::Validation422 (schema sai),JsonSyntaxError/MissingJsonContentType/BytesRejection→AppError::BadRequest400 (parse fail).- File path lock vĩnh viễn:
crates/shop-api/src/extractors/{mod,path,query,json}.rs— folder placeholder ready từ B17. extractors/mod.rsre-export top-levelpub use path::AppPath; pub use query::AppQuery; pub use json::AppJson;cho phép handler import 1 dònguse crate::extractors::{AppPath, AppQuery, AppJson};.- Handler refactor lock:
list_productstrongroutes/products.rsđổiQuery<Pagination>→AppQuery<Pagination>, returnJson<Value>→AppResult<Json<Value>>để rejection envelope chuẩn flow quaAppError::into_responselock B16. Verify curl:?page=abctrả 400 + JSON envelope thay text/plain raw default. CurrentUserskeleton preview B112: struct{id, role}implFromRequestPartsvớitype Rejection = AppError, 2 bước extract Bearer token từ Authorization header + verify JWT, map fail sangAppError::Unauthenticated401 (header phụWWW-Authenticatetự inject quaimpl IntoResponse for AppErrorlock B16). Full implement G12 sau khijsonwebtokencrate wire vào AppState.- Convention G7+ MANDATORY: mọi handler từ Group 7 CRUD Cơ Bản onward dùng
AppPath/AppQuery/AppJsonwrapper, KHÔNG dùngaxum::Path/Query/Jsondefault — code review reject PR vi phạm. - Pattern cho extractor mới tương lai (
CurrentUserB112,IdempotencyKeyB197,RateLimitKeyB158): cùng template 3 bước wrapper + impl FromRequestParts/FromRequest + map rejection sangAppErrorvariant phù hợp.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Wrapper extractor 3 bước nào? Mô tả pattern đầy đủ áp dụng cho mọi custom extractor tương lai.
AppPath<T>dùng traitFromRequestPartshayFromRequest? Tại sao? Trait bound generic của impl là gì?AppJson<T>map rejectionJsonDataErrorvsJsonSyntaxErrorthànhAppErrorvariant nào? Status code khác nhau gì? Semantic phân biệt 2 trường hợp này.- Handler refactor
list_productsthayQuery<Pagination>bằngAppQuery<Pagination>+ return type đổi gì? Khác biệt response khi gặp invalid query?page=abctrước vs sau B32? CurrentUserextractor preview B112 thực hiện 2 bước nào trongfrom_request_parts? Rejection map sangAppErrorvariant nào và tại sao? Header phụ trợ tự inject là gì?
Đáp án
- Pattern 3 bước cố định cho mọi custom wrapper extractor: Bước 1: Tạo tuple struct wrapper generic
pub struct AppXxx<T>(pub T);với field public cho phép destructure trong handler (vdAppXxx(value): AppXxx<Pagination>). Bước 2: Impl trait tương ứng — chọnFromRequestParts<S>cho extractor không đụng body (Path/Query/State/TypedHeader/Extension) hoặcFromRequest<S>cho extractor consume body (Json/Form/Bytes/Multipart) — quyết định dựa trên việc extractor có cần đọc body hay không (lock B31). Settype Rejection = AppErroroverride rejection mặc định để có envelope chuẩn. Trait bound:T: DeserializeOwned + Send(Path/Query) hoặcT: DeserializeOwned(Json),S: Send + Synctheo yêu cầu axum, genericSkhông tie cụ thểAppStatevì wrapper là thư viện reuse cross-resource. Bước 3: Delegate + map rejection — trong methodfrom_request_parts(hoặcfrom_request), gọi extractor gốc làm parse logic chính quaPath::<T>::from_request_parts(parts, state).await/Query::<T>::from_request_parts(parts, state).await/Json::<T>::from_request(req, state).await,matchtrên kết quả:Ok(Path(value))unwrap inner và wrap lại bằngAppXxx(value)trảOk(...),Err(rejection)gọi helper functionmap_xxx_rejection(rejection)tách riêng (test unit-able + dễ extend) map từng variant củaPathRejection/QueryRejection/JsonRejectionsangAppErrorvariant phù hợp. Lý do delegate thay vì viết parse logic from scratch: (a) extractor gốc của axum đã xử lý mọi edge case (URL encoding, multi-value query, content-type negotiation), viết lại tốn công + dễ sót case; (b) khi axum nâng version có cải tiến parse, wrapper tự hưởng lợi không cần sửa; (c) pattern đồng nhất, đọc 1 extractor hiểu 3 extractor, code review nhanh. Áp dụng cho extractor mới tương lai (CurrentUserB112,IdempotencyKeyB197,RateLimitKeyB158) theo cùng template — chỉ khác bước 3 logic cụ thể. AppPath<T>dùng traitFromRequestParts<S>, KHÔNG phảiFromRequest<S>. Lý do (lock B31): Path parameter parse từurinằm trongPartscủa request, KHÔNG cần đọc body.FromRequestPartsmethod nhận&mut Partsmutable reference (cho phép sửa headers nếu cần) +&Sstate, không touch body — cho phép handler có nhiều extractor loại này cùng lúc vì parts vẫn còn cho extractor sau (parts không bị consume). Ngược lạiFromRequestnhậnRequestowned consume cả parts lẫn body, dùng cho Json/Form/Bytes/Multipart cần đọc body, mỗi handler chỉ được 1 extractor loại này. Trait bound generic của implAppPath:T: DeserializeOwned + Send(identical với extractor gốc axum::Path),S: Send + Sync(theo yêu cầu axum cho mọi state type).DeserializeOwnedbắt buộc vì path parameter parse từ string URL deserialize thành kiểu Rust qua serde;Sendđảm bảo value có thể move giữa async task (tokio multi-thread runtime).S: Send + Syncđảm bảo state có thể share immutable cross-thread. Implementation:match Path::<T>::from_request_parts(parts, state).await→Ok(Path(value)) => Ok(AppPath(value))unwrap + rewrap,Err(rejection) => Err(map_path_rejection(rejection))map sangAppError. Helpermap_path_rejectionxử lý 3 variant:PathRejection::FailedToDeserializePathParams(err)→AppError::BadRequest(format!("invalid path parameter: {}", err))400 (parse fail vd:idrequirei64nhưng client gửiabc),PathRejection::MissingPathParams(_)→AppError::BadRequest("missing path parameter".to_string())400 (route khai báo:slugnhưng URL không có — hiếm gặp vì axum match route trước), variant_wildcard cho future-compat (axum giữ quyền thêm variant ở minor version sau, wildcard đảm bảo wrapper không break khi upgrade) →AppError::BadRequest("invalid path".to_string())400. Tương tựAppQueryimplFromRequestParts,AppJsonimplFromRequest.AppJson<T>map rejectionJsonRejection::JsonDataError→AppError::Validation422 Unprocessable Entity, cònJsonRejection::JsonSyntaxError→AppError::BadRequest400 Bad Request. Status code khác nhau 422 vs 400 — phân biệt cực quan trọng cho client. Semantic phân biệt:JsonDataErrornghĩa là "JSON parse OK (cú pháp đúng) nhưng schema sai" — vd request body{"name": 123}đúng JSON cú pháp nhưng fieldnamerequire String mà gửi number, hoặc{"price": 10}thiếu required fieldname. Đây là "server hiểu request nhưng data không hợp lệ", đúng nghĩa 422 theo RFC 9110 (lock B3).JsonSyntaxErrornghĩa là "JSON parse fail (cú pháp sai)" — vd{name: "x"}thiếu quote,{"a":}missing value,{không đóng. Đây là "client gửi rác", đúng nghĩa 400. Phân biệt giúp form validation UI client switch logic: 422 → hiển thị inline field error "Tên không được để trống", 400 → hiển thị toast "Request format sai, liên hệ admin" hoặc "Mất kết nối, thử lại". Wire này khớp plan B41 (validator crate) sẽ thêm field-level errors vàoAppError::Validationdetails trong body envelope. Ngoài 2 variant chính,AppJsoncòn mapMissingJsonContentType(_)→AppError::BadRequest400 (request thiếu headerContent-Type: application/json; map 400 thay 415 Unsupported Media Type cho consistent với mọi parse error còn lại Shop API — JSON-only policy lock B5 nên content-type sai là client bug rõ ràng) vàBytesRejection(err)→AppError::BadRequest400 (body stream read fail — network drop, body size vượt limit; map 400 dù root cause có thể là 413 nhưng đa số case là client malformed). Variant_wildcard future-compat →BadRequest("invalid JSON").- Handler refactor
list_productsthayQuery<Pagination>bằngAppQuery<Pagination>: (a) import thêmuse crate::extractors::AppQuery;+use shop_common::error::AppResult;; (b) signature arg đổiQuery(pagination): Query<Pagination>→AppQuery(pagination): AppQuery<Pagination>— destructure vẫn pattern tuple struct field 0; (c) return type đổiJson<serde_json::Value>→AppResult<Json<serde_json::Value>>— bắt buộc để rejection củaAppQuery(kiểuAppError) flow quaAppError::into_responsetự động (lock B16) trả envelope chuẩn; (d) body cuối wrapOk(Json(...)). Convention order arg vẫn lock B22/B31:State → Path → Query/AppQuery → Json/AppJson,AppQueryđứng sauStateđúng vị trí cũ củaQuery. Khác biệt response invalid query?page=abctrước vs sau B32: Trước (B23/B28 state) dùngQuery<Pagination>default —HTTP/1.1 400 Bad Request+content-type: text/plain; charset=utf-8+ body raw "Failed to deserialize query string: invalid digit found in string", KHÔNG envelope, client phải xử lý 2 format khác nhau cho cùng endpoint (success Json, error text/plain), không có code machine-readable để switch logic, không có request_id correlate log. Sau B32 dùngAppQuery<Pagination>+AppResult—HTTP/1.1 400 Bad Request+content-type: application/json+ body envelope{"error":"bad request: invalid query string: Failed to deserialize query string: invalid digit found in string","code":"BAD_REQUEST","request_id":null}, nhất quán với mọi error response Shop API (success Json envelope, error Json envelope),codeSCREAMING_SNAKE_CASE machine-readable client switch logic theo error class,request_idplaceholder null sẽ inject ở B39 (Extension RequestId middleware) cho correlation log. Valid query?page=2&size=10trả 200 envelope{items, total, page, size, hasNext}không đổi — wrap chỉ ảnh hưởng error path. CurrentUserextractor preview B112 (skeleton) thực hiện 2 bước workflow trongfrom_request_parts: Bước 1:extract_bearer_token(&parts.headers)— parse headerAuthorizationtừparts.headers(kiểuHeaderMap), check tồn tại (headers.get("authorization").ok_or(AppError::Unauthenticated)?), convert sang str (auth.to_str().map_err(|_| AppError::Unauthenticated)?), strip prefix"Bearer "(s.strip_prefix("Bearer ").map(str::to_string).ok_or(AppError::Unauthenticated)?) trả ra token string. Mọi bước fail đều trảAppError::Unauthenticatedgeneric — KHÔNG leak chi tiết "missing header" vs "wrong scheme" cho client (security best practice tránh enumeration attack). Bước 2:verify_jwt(&token)— verify JWT signature + decode claims (B112 implement đầy đủ vớijsonwebtokencrate, dùngstate.config.jwt_secret+ algorithm HS256/RS256 lock G12). Skeleton trảErr(AppError::Unauthenticated)placeholder. Sau verify OK trảJwtClaims { sub: i64, role: String }, build structCurrentUser { id: claims.sub, role: claims.role }trảOk(...). Rejection map sangAppError::Unauthenticated(KHÔNG phảiBadRequest) vì thiếu/sai token là vấn đề auth (401 Unauthorized) không phải parse (400 Bad Request) — semantic RFC 9110: 401 dành cho "credentials missing/invalid", 400 dành cho "request malformed". Header phụ trợ tự inject quaimpl IntoResponse for AppErrorlock B16:WWW-Authenticate: Bearer realm="shop-api"theo RFC 6750 — bắt buộc cho response 401 để client biết scheme auth cần dùng (vd browser hiển thị popup login, SDK biết phải refresh token). Convention Shop API lock B4: header này gắn cứng cho mọiAppError::Unauthenticated. Handler dùngCurrentUser:async fn get_me(State(state), user: CurrentUser) -> AppResult<Json<UserDto>>hoặc destructure trực tiếpCurrentUser { id, role }: CurrentUser. Pattern preview B32, fileextractors/current_user.rssẽ tạo ở B112 sau khijsonwebtokencrate add vàoCargo.toml+AppStatewire encoding/decoding key. Mọi extractor Shop API tương lai (IdempotencyKeyB197,RateLimitKeyB158) theo cùng template 3 bước.
Bài Tiếp Theo
Bài 33: Header Extractor Typed (TypedHeader) — đi sâu axum-extra::TypedHeader<T> với Authorization<Bearer>, ContentType, UserAgent, Host typed-parsed thay HeaderMap raw; pattern implement custom typed header (Idempotency-Key chuẩn bị B197) qua trait Header của headers crate.
