Danh sách bài viết

Bài 32: Custom Extractor

Bài 32 của series Rust RESTful API — bài code-heavy implement 3 custom wrapper extractor (bộ trích xuất tùy biến) cho Shop API trong folder crates/shop-api/src/extractors/ (placeholder ready từ B17): AppPath<T> wrap axum::Path impl FromRequestParts với rejection map sang AppError::BadRequest; AppQuery<T> wrap axum::Query impl FromRequestParts rejection map sang AppError::BadRequest; AppJson<T> wrap axum::Json impl FromRequest (consume body) rejection map sang AppError::Validation (JsonDataError 422 schema sai) hoặc AppError::BadRequest (JsonSyntaxError 400 parse fail, MissingJsonContentType 400, BytesRejection 400 body read error). Vấn đề bài giải: default rejection của axum trả text/plain raw với message generic "Failed to deserialize query string: invalid digit found in string", không khớp với envelope chuẩn Shop API {error, code, request_id} lock B3/B16. Pattern 3 bước cố định: tạo tuple struct wrapper, impl trait tương ứng với type Rejection = AppError, delegate parse logic cho extractor gốc rồi map PathRejection/QueryRejection/JsonRejection sang variant AppError phù hợp. Tạo 4 file mới: extractors/mod.rs re-export top-level cho handler import 1 dòng use crate::extractors::{AppPath, AppQuery, AppJson};, extractors/path.rs chứa AppPath<T>, extractors/query.rs chứa AppQuery<T>, extractors/json.rs chứa AppJson<T>. Refactor handler list_products trong crates/shop-api/src/routes/products.rs đổi từ Query<Pagination> sang AppQuery<Pagination>, return type từ Json<Value> sang AppResult<Json<Value>> để rejection envelope chuẩn flow qua AppError::into_response lock B16. Verify qua curl: valid query ?page=2&size=10 trả 200 envelope {items, total, page, size, hasNext}, invalid query ?page=abc trả 400 envelope {error: "bad request: invalid query string...", code: "BAD_REQUEST", request_id: null} với Content-Type: application/json thay text/plain. Demo skeleton CurrentUser extractor preview B112: tuple struct {id, role} impl FromRequestParts với type Rejection = AppError, extract Bearer token từ Authorization header rồi verify JWT (chi tiết full implement G12 sau khi jsonwebtoken crate wire vào AppState — B32 chỉ skeleton 2 bước workflow, KHÔNG complete). Convention lock G7+ MANDATORY: mọi handler từ Group 7 CRUD Cơ Bản onward dùng AppPath/AppQuery/AppJson wrapper, KHÔNG dùng axum::Path/Query/Json default — code review reject PR vi phạm. File path lock vĩnh viễn: crates/shop-api/src/extractors/{mod,path,query,json}.rs; rejection mapping lock vĩnh viễn cho mọi extractor mới tương lai (CurrentUser B112, IdempotencyKey B197).

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

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 folder crates/shop-api/src/extractors/ (placeholder ready từ B17).
  • Map rejection default của axum (PathRejection/QueryRejection/JsonRejection) sang AppError::BadRequest hoặc AppError::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 (CurrentUser B112, IdempotencyKey B197).
  • Biết skeleton CurrentUser extractor preview B112: extract Bearer token từ Authorization header, verify JWT, build struct user identity, map rejection sang AppError::Unauthenticated.
  • Refactor handler list_products trong routes/products.rs đổi Query<Pagination> sang AppQuery<Pagination> + return AppResult<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/AppJson wrapper, KHÔNG dùng axum::Path/Query/Json default.
2

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ạitext/plain raw, không phải application/json như 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.

3

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 wrapperpub struct AppXxx<T>(pub T); generic theo T để wrap mọi kiểu inner. Field public cho phép destructure trong handler AppXxx(value): AppXxx<Pagination> tự nhiên như extractor gốc.
  • Bước 2: Impl trait tương ứngFromRequestParts<S> cho extractor không đụng body (Path/Query/State/TypedHeader), hoặc FromRequest<S> cho extractor consume body (Json/Form/Bytes). type Rejection = AppError override rejection mặc định. Trait bound T: DeserializeOwned + Send giữ generic flexible, S: Send + Sync theo yêu cầu axum.
  • Bước 3: Delegate + map rejection — trong method from_request_parts (hoặc from_request), gọi extractor gốc làm parse logic chính (vd Path::<T>::from_request_parts(parts, state).await), match trên kết quả: Ok unwrap inner và wrap lại bằng AppXxx, Err(rejection) map sang AppError variant phù hợp qua hàm helper map_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.

4

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 PathRejection từ 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 :id require i64 nhưng client gửi abc), MissingPathParams (route khai báo :slug như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_rejection tách riêng — không inline trong match arm 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 + Send identical 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ố định AppState vì 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ý.

5

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:

  • QueryRejection chỉ có 1 variant chínhFailedToDeserializeQueryString bao trùm mọi case parse fail (field thiếu mặc dù Pagination#[serde(default)] nên thường không hit case này, type mismatch như ?page=abc hit nhiều nhất, format URL-encoded sai). Variant _ giữ cho future-compat.
  • Không có MissingPathParams tương đương — query string optional theo spec, missing không phải lỗi (serde default kick in).
  • Message giàu contextformat!("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.

6

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 FromRequest thay FromRequestParts — vì Json consume body. Method nhận Request owned (consume cả parts lẫn body), không phải &mut Parts mutable reference.
  • Bỏ trait bound T: SendFromRequest không yêu cầu T: Send tường minh (associated future tự đảm bảo qua bound implicit), chỉ cần T: DeserializeOwned.
  • 4 variant JsonRejection map sang 2 AppError khác nhau — phân biệt quan trọng giữa lỗi schema và lỗi parse:
    • JsonDataErrorAppError::Validation 422 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).
    • JsonSyntaxErrorAppError::BadRequest 400 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.
    • MissingJsonContentType400 Bad Request — request thiếu header Content-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).
    • BytesRejection400 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.

7

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.

8

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ùng Query default — 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> thay Query(pagination): Query<Pagination>. Destructure vẫn pattern tuple struct field 0.
  • Return type: AppResult<Json<serde_json::Value>> thay Json<serde_json::Value> để rejection của AppQuery flow qua AppError::into_response tự động (lock B16). Body cuối cần wrap Ok(...).
  • Convention order arg vẫn lock B22/B31: State → Path → Query/AppQuery → Json/AppJson. AppQuery đứng sau State đúng vị trí cũ của Query.

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ới code: "BAD_REQUEST" machine-readable để client switch logic, request_id placeholder (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.

9

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 wrap T. Đây là pattern cho extractor build từ scratch chứ không delegate.
  • Rejection map AppError::Unauthenticated — không phải BadRequest, vì thiếu/sai token là vấn đề auth (401) không phải parse (400). impl IntoResponse for AppError tự thêm header WWW-Authenticate: Bearer realm="shop-api" theo RFC 6750 (lock B16).
  • 2 bước workflow tách biệt — (1) extract_bearer_token parse header, (2) verify_jwt decode + validate signature/expiry. Tách giúp test cô lập từng bước. B112 implement đầy đủ verify_jwt với jsonwebtoken crate + state.config.jwt_secret.
  • State _state không dùng ở skeleton — B112 sẽ dùng để lấy jwt_secret từ 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.

10

Tổng Kết

  • Pattern wrapper extractor 3 bước cố định: tạo tuple struct generic, impl FromRequestParts/FromRequest với type Rejection = AppError, delegate parse logic cho extractor gốc rồi map rejection sang variant phù hợp.
  • AppPath<T> (file extractors/path.rs) wrap axum::Path, impl FromRequestParts; rejection PathRejection::FailedToDeserializePathParams/MissingPathParamsAppError::BadRequest 400.
  • AppQuery<T> (file extractors/query.rs) wrap axum::Query, impl FromRequestParts; rejection QueryRejection::FailedToDeserializeQueryStringAppError::BadRequest 400.
  • AppJson<T> (file extractors/json.rs) wrap axum::Json, impl FromRequest (consume body); rejection JsonRejection::JsonDataErrorAppError::Validation 422 (schema sai), JsonSyntaxError/MissingJsonContentType/BytesRejectionAppError::BadRequest 400 (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.rs re-export top-level pub use path::AppPath; pub use query::AppQuery; pub use json::AppJson; cho phép handler import 1 dòng use crate::extractors::{AppPath, AppQuery, AppJson};.
  • Handler refactor lock: list_products trong routes/products.rs đổi Query<Pagination>AppQuery<Pagination>, return Json<Value>AppResult<Json<Value>> để rejection envelope chuẩn flow qua AppError::into_response lock B16. Verify curl: ?page=abc trả 400 + JSON envelope thay text/plain raw default.
  • CurrentUser skeleton preview B112: struct {id, role} impl FromRequestParts với type Rejection = AppError, 2 bước extract Bearer token từ Authorization header + verify JWT, map fail sang AppError::Unauthenticated 401 (header phụ WWW-Authenticate tự inject qua impl IntoResponse for AppError lock B16). Full implement G12 sau khi jsonwebtoken crate wire vào AppState.
  • Convention G7+ MANDATORY: mọi handler từ Group 7 CRUD Cơ Bản onward dùng AppPath/AppQuery/AppJson wrapper, KHÔNG dùng axum::Path/Query/Json default — code review reject PR vi phạm.
  • Pattern cho extractor mới tương lai (CurrentUser B112, IdempotencyKey B197, RateLimitKey B158): cùng template 3 bước wrapper + impl FromRequestParts/FromRequest + map rejection sang AppError variant phù hợp.
11

Bài Tập Củng Cố

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

  1. Wrapper extractor 3 bước nào? Mô tả pattern đầy đủ áp dụng cho mọi custom extractor tương lai.
  2. AppPath<T> dùng trait FromRequestParts hay FromRequest? Tại sao? Trait bound generic của impl là gì?
  3. AppJson<T> map rejection JsonDataError vs JsonSyntaxError thành AppError variant nào? Status code khác nhau gì? Semantic phân biệt 2 trường hợp này.
  4. Handler refactor list_products thay Query<Pagination> bằng AppQuery<Pagination> + return type đổi gì? Khác biệt response khi gặp invalid query ?page=abc trước vs sau B32?
  5. CurrentUser extractor preview B112 thực hiện 2 bước nào trong from_request_parts? Rejection map sang AppError variant nào và tại sao? Header phụ trợ tự inject là gì?
Đáp án
  1. 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 (vd AppXxx(value): AppXxx<Pagination>). Bước 2: Impl trait tương ứng — chọn FromRequestParts<S> cho extractor không đụng body (Path/Query/State/TypedHeader/Extension) hoặc FromRequest<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). Set type Rejection = AppError override rejection mặc định để có envelope chuẩn. Trait bound: T: DeserializeOwned + Send (Path/Query) hoặc T: DeserializeOwned (Json), S: Send + Sync theo yêu cầu axum, generic S không tie cụ thể AppState vì wrapper là thư viện reuse cross-resource. Bước 3: Delegate + map rejection — trong method from_request_parts (hoặc from_request), gọi extractor gốc làm parse logic chính qua Path::<T>::from_request_parts(parts, state).await / Query::<T>::from_request_parts(parts, state).await / Json::<T>::from_request(req, state).await, match trên kết quả: Ok(Path(value)) unwrap inner và wrap lại bằng AppXxx(value) trả Ok(...), Err(rejection) gọi helper function map_xxx_rejection(rejection) tách riêng (test unit-able + dễ extend) map từng variant của PathRejection/QueryRejection/JsonRejection sang AppError variant 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 (CurrentUser B112, IdempotencyKey B197, RateLimitKey B158) theo cùng template — chỉ khác bước 3 logic cụ thể.
  2. AppPath<T> dùng trait FromRequestParts<S>, KHÔNG phải FromRequest<S>. Lý do (lock B31): Path parameter parse từ uri nằm trong Parts của request, KHÔNG cần đọc body. FromRequestParts method nhận &mut Parts mutable reference (cho phép sửa headers nếu cần) + &S state, 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ại FromRequest nhận Request owned 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 impl AppPath: 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). DeserializeOwned bắ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).awaitOk(Path(value)) => Ok(AppPath(value)) unwrap + rewrap, Err(rejection) => Err(map_path_rejection(rejection)) map sang AppError. Helper map_path_rejection xử lý 3 variant: PathRejection::FailedToDeserializePathParams(err)AppError::BadRequest(format!("invalid path parameter: {}", err)) 400 (parse fail vd :id require i64 nhưng client gửi abc), PathRejection::MissingPathParams(_)AppError::BadRequest("missing path parameter".to_string()) 400 (route khai báo :slug như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ự AppQuery impl FromRequestParts, AppJson impl FromRequest.
  3. AppJson<T> map rejection JsonRejection::JsonDataErrorAppError::Validation 422 Unprocessable Entity, còn JsonRejection::JsonSyntaxErrorAppError::BadRequest 400 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: JsonDataError nghĩ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 field name require String mà gửi number, hoặc {"price": 10} thiếu required field name. Đây là "server hiểu request nhưng data không hợp lệ", đúng nghĩa 422 theo RFC 9110 (lock B3). JsonSyntaxError nghĩ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ào AppError::Validation details trong body envelope. Ngoài 2 variant chính, AppJson còn map MissingJsonContentType(_)AppError::BadRequest 400 (request thiếu header Content-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::BadRequest 400 (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").
  4. Handler refactor list_products thay Query<Pagination> bằng AppQuery<Pagination>: (a) import thêm use crate::extractors::AppQuery; + use shop_common::error::AppResult;; (b) signature arg đổi Query(pagination): Query<Pagination>AppQuery(pagination): AppQuery<Pagination> — destructure vẫn pattern tuple struct field 0; (c) return type đổi Json<serde_json::Value>AppResult<Json<serde_json::Value>> — bắt buộc để rejection của AppQuery (kiểu AppError) flow qua AppError::into_response tự động (lock B16) trả envelope chuẩn; (d) body cuối wrap Ok(Json(...)). Convention order arg vẫn lock B22/B31: State → Path → Query/AppQuery → Json/AppJson, AppQuery đứng sau State đúng vị trí cũ của Query. Khác biệt response invalid query ?page=abc trước vs sau B32: Trước (B23/B28 state) dùng Query<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ùng AppQuery<Pagination> + AppResultHTTP/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), code SCREAMING_SNAKE_CASE machine-readable client switch logic theo error class, request_id placeholder null sẽ inject ở B39 (Extension RequestId middleware) cho correlation log. Valid query ?page=2&size=10 trả 200 envelope {items, total, page, size, hasNext} không đổi — wrap chỉ ảnh hưởng error path.
  5. CurrentUser extractor preview B112 (skeleton) thực hiện 2 bước workflow trong from_request_parts: Bước 1: extract_bearer_token(&parts.headers) — parse header Authorization từ parts.headers (kiểu HeaderMap), 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::Unauthenticated generic — 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ới jsonwebtoken crate, dùng state.config.jwt_secret + algorithm HS256/RS256 lock G12). Skeleton trả Err(AppError::Unauthenticated) placeholder. Sau verify OK trả JwtClaims { sub: i64, role: String }, build struct CurrentUser { id: claims.sub, role: claims.role } trả Ok(...). Rejection map sang AppError::Unauthenticated (KHÔNG phải BadRequest) 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 qua impl IntoResponse for AppError lock 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ọi AppError::Unauthenticated. Handler dùng CurrentUser: async fn get_me(State(state), user: CurrentUser) -> AppResult<Json<UserDto>> hoặc destructure trực tiếp CurrentUser { id, role }: CurrentUser. Pattern preview B32, file extractors/current_user.rs sẽ tạo ở B112 sau khi jsonwebtoken crate add vào Cargo.toml + AppState wire encoding/decoding key. Mọi extractor Shop API tương lai (IdempotencyKey B197, RateLimitKey B158) theo cùng template 3 bước.
12

Bài Tiếp Theo

— đ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.