Danh sách bài viết

Bài 19: axum-extra: Cookies, TypedHeader, Form, ...

Bài 19 của series Rust RESTful API — đi sâu axum-extra crate official tokio-rs (cùng team maintain axum) chứa các extension features không bundle trong axum core để giảm dependency tree mặc định; bài này giới thiệu 5 feature flag chính qua Cargo.toml opt-in pattern: cookie + cookie-signed + cookie-private + typed-header + form + query, ngoài ra mention erased-json; CookieJar 3 loại — Plain (cookie không bảo vệ, client đọc/ghi free, dùng cho preference UI như dark mode/language), SignedCookieJar (HMAC SHA256 tamper-proof, server verify integrity nhưng client vẫn đọc được giá trị, dùng cho CSRF token), PrivateCookieJar (AES-256-GCM encrypt + sign, client KHÔNG đọc được nội dung, dùng cho session ID admin dashboard); TypedHeader<T> strongly-typed extract với T impl trait headers::Header — built-in Authorization<Bearer>, Authorization<Basic>, ContentType, UserAgent, Host, Cookie — thay HeaderMap raw parse manual; Form extractor cho application/x-www-form-urlencoded (HTML form submit, Stripe webhook theo convention); erased-json cho serialize trait object qua Box<dyn ErasedSerialize> giải bài toán Box<dyn Trait> không impl Serialize derive; OptionalQuery<T> + pattern serde(default = "fn") cho pagination missing-as-None; Shop API decision lock: API client dùng Bearer JWT (đã lock B4), Signed/Private cookie chỉ cho admin dashboard internal không cho API endpoint; encryption key 32+ bytes random rotate lịch (B289); KHÔNG dùng erased-json vì tagged enum B15 đã đủ polymorphic cho Notification (Email/Push/SMS variant); bài này chỉ lock axum-extra = "0.10" với 6 feature flag opt-in vào workspace.dependencies root, KHÔNG add vào crates/shop-api/Cargo.toml ngay — feature enable dần theo handler thật (cookie B106, typed-header B112, form B105).

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

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

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

  • Hiểu vai trò axum-extra crate — chứa extension features không nằm trong axum core để giảm dependency tree mặc định, maintain bởi cùng team tokio-rs.
  • Biết 5 feature flag chính: cookie, cookie-signed, cookie-private, typed-header, form, ngoài ra erased-jsonquery helper.
  • Nắm CookieJar 3 loại: plain (không bảo vệ), SignedCookieJar (HMAC SHA256 tamper-proof), PrivateCookieJar (AES-256-GCM encrypted).
  • Hiểu TypedHeader<T> với strongly-typed Authorization<Bearer>, ContentType, UserAgent thay parse HeaderMap raw manual.
  • Biết Form<T> extractor cho application/x-www-form-urlencoded (HTML form login/register, Stripe webhook payload).
  • Hiểu erased-json giải bài toán serialize trait object qua Box<dyn ErasedSerialize>.
  • Lock axum-extra = "0.10" với 6 feature flag opt-in vào workspace.dependencies cho future use, enable dần theo handler thật.
2

axum-extra Là Gì?

axum-extra là crate official maintained bởi tokio-rs cùng team với axum, repo cùng workspace github.com/tokio-rs/axum/tree/main/axum-extra. Mục đích: chứa các extension features mà axum core không bundle để giữ dependency tree mặc định nhỏ gọn. Người dùng axum không cần cookie, không cần typed header, không cần form thì không kéo theo cookie crate, headers crate, serde_urlencoded.

Convention naming Cargo.toml dùng feature flag opt-in — chỉ enable feature thực sự cần:

axum-extra = { version = "0.10", features = [
    "cookie",
    "cookie-private",
    "cookie-signed",
    "typed-header",
    "form",
] }

Bảng feature flag chính trong axum-extra 0.10 ánh xạ sang module/type tương ứng:

Feature              | Module                          | Type chính
---------------------+---------------------------------+-----------------------
cookie               | extract::cookie                 | CookieJar
cookie-signed        | extract::cookie                 | SignedCookieJar
cookie-private       | extract::cookie                 | PrivateCookieJar
typed-header         | typed_header                    | TypedHeader<T>
form                 | extract                         | Form<T>
erased-json          | response                        | ErasedJson
query                | extract                         | OptionalQuery<T>

Version 0.10 align với axum 0.8 (đã lock workspace.dependencies từ B10). Khi axum bump major sang 0.9 thì axum-extra bump tương ứng — luôn pin cùng compatibility window.

Shop API lock axum-extra = "0.10" vào workspace.dependencies ngay ở bài này, nhưng chưa add vào crates/shop-api/Cargo.toml. Feature sẽ enable dần khi handler thật cần dùng — cookie ở B106 (admin login), typed-header ở B112 (JWT extract), form ở B105 (admin login page).

3

Cookie Jar — 3 Loại

axum-extra cung cấp ba loại jar đại diện ba mức bảo vệ cookie khác nhau, mỗi loại phục vụ một nhóm use case rõ ràng.

Plain CookieJar — cookie không bảo vệ, server lưu raw, client đọc/ghi tự do qua DevTools hoặc document.cookie. Use case: preference UI (dark mode, language, sidebar collapsed state), cookie consent banner — dữ liệu không nhạy cảm, không liên quan auth, client có thể chỉnh tay thoải mái không phá vỡ security.

use axum_extra::extract::cookie::{Cookie, CookieJar};

async fn set_theme(jar: CookieJar) -> CookieJar {
    jar.add(Cookie::new("theme", "dark"))
}

async fn get_theme(jar: CookieJar) -> String {
    jar.get("theme")
        .map(|c| c.value().to_string())
        .unwrap_or_else(|| "light".to_string())
}

SignedCookieJar — server ký HMAC SHA256 phía sau giá trị, mỗi cookie thành <value>.<hmac>. Client đọc được giá trị nhưng không thay đổi được — sửa giá trị mà không tính lại HMAC thì server verify fail. Use case: CSRF token (client cần đọc để gửi vào header X-CSRF-Token), feature flag không bí mật nhưng cấm sửa, flash message giữa request.

PrivateCookieJar — server AES-256-GCM encrypt + sign nội dung. Client KHÔNG đọc được giá trị (nhìn DevTools chỉ thấy bytes random base64), không sửa được. Use case: session ID admin dashboard, sensitive user data nhúng vào cookie.

use axum_extra::extract::cookie::{Cookie, Key, PrivateCookieJar};
use axum::extract::State;

#[derive(Clone)]
struct AppState {
    cookie_key: Key,  // 32+ bytes random
}

async fn login(
    State(state): State<AppState>,
    jar: PrivateCookieJar,
) -> PrivateCookieJar {
    let session_id = "sess_abc123";  // sinh từ Redis (B105)
    let cookie = Cookie::build(("session", session_id))
        .http_only(true)
        .secure(true)
        .same_site(axum_extra::extract::cookie::SameSite::Strict)
        .path("/")
        .build();
    jar.add(cookie)
}

Cả SignedCookieJarPrivateCookieJar đều cần Key instance — đối tượng wrap khóa bí mật server-side. Key tối thiểu 32 bytes random (không phải password user, không phải string ngắn) — sinh qua Key::generate() trong dev hoặc đọc từ cookie_key env variable trong prod. Key phải share cross-instance khi scale horizontal (nhiều pod K8s) — lưu trong secret manager (Vault, AWS Secrets Manager), không hard-code.

Decision lock cho Shop API:

  • SignedCookieJar cho CSRF token (B107) — token client cần đọc để inject header, nhưng không được sửa.
  • PrivateCookieJar cho session ID admin dashboard (B105) — admin login qua HTML form (không phải API client) → server set session cookie encrypted → request kế server decrypt + lookup Redis session.
  • API client KHÔNG dùng cookie — giữ pattern Bearer JWT (đã lock B4 + G12). API consumer là mobile app + SPA + partner integration — token-based đơn giản hơn cookie cho cross-origin và mobile.
  • Encryption key 32+ bytes random sinh qua Key::generate() lúc bootstrap dev, prod đọc từ env COOKIE_KEY base64. Rotation theo lịch ở B289 (key management).
4

TypedHeader — Strongly-Typed Header Extract

axum core extract header qua HeaderMap raw — handler tự lookup key bằng string, tự parse value, tự handle lỗi format:

use axum::http::HeaderMap;

async fn handler_raw(headers: HeaderMap) -> String {
    let auth = headers
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.strip_prefix("Bearer "))
        .unwrap_or("");
    auth.to_string()
}

Vấn đề: lookup string dễ sai chính tả, parse manual mỗi handler, không type-safe, không reuse validation logic cross-handler.

axum-extra cung cấp TypedHeader<T> với T implement trait headers::Header. Compiler check tại signature, parse + validate tự động, reject với 400 Bad Request nếu header missing hoặc sai format. Built-in typed header phổ biến:

  • Authorization<Bearer> — extract Authorization: Bearer <token>.
  • Authorization<Basic> — extract Basic auth (legacy, dùng cho admin internal).
  • ContentType — parse MIME type Content-Type.
  • UserAgent — get UA string typed thay parse manual.
  • Host — get Host header với port tách riêng.
  • Cookie — parse cookie header (alternative cho CookieJar khi chỉ cần đọc).

Handler tận dụng TypedHeader<T>:

use axum_extra::{
    headers::{authorization::Bearer, Authorization},
    TypedHeader,
};
use shop_common::error::{AppError, AppResult};

async fn protected(
    TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> AppResult<String> {
    let token = auth.token();  // &str đã strip prefix "Bearer "
    // verify JWT claims (B112)
    let claims = verify_jwt(token)
        .map_err(|_| AppError::Unauthenticated)?;
    Ok(format!("user_id = {}", claims.sub))
}

So với version raw, handler ngắn hơn nhiều, không quan tâm string lookup, parse, prefix strip — compiler enforce header tồn tại và đúng format. Nếu thiếu header, axum-extra trả 400 với body chuẩn (hoặc bạn map qua AppError ở B41 cho envelope { error, code, request_id } nhất quán).

Custom TypedHeader — impl trait headers::Header cho struct riêng (ví dụ Idempotency-Key theo convention Stripe đã lock B4). Implement 3 method name(), decode(), encode() — chi tiết deep dive ở B33 (Header Extractor Typed).

Apply Shop API:

  • B112 JWT extract — middleware/extractor CurrentUser dùng TypedHeader<Authorization<Bearer>> đọc token → verify claims qua jsonwebtoken crate → inject CurrentUser { id, role } vào handler.
  • B33 custom Idempotency-Key — implement headers::Header cho struct IdempotencyKey(Uuid), dùng cho POST /api/v1/checkoutPOST /api/v1/payments.
  • Content-Type negotiation ở webhook endpoint — dùng TypedHeader<ContentType> verify application/x-www-form-urlencoded trước khi parse Stripe payload (B197).
5

Form Extractor — application/x-www-form-urlencoded

Form<T> extractor parse request body theo định dạng application/x-www-form-urlencoded — format chuẩn HTML form submit, encoded kiểu email=alice%40shop.com&password=secret123. Khác Json<T> parse JSON body, Form<T> parse URL-encoded body qua serde_urlencoded crate dưới hood.

Use case chính:

  • HTML form submit — login page, register page, contact form render server-side (template askama/maud/tera). Browser submit form thì tự gửi application/x-www-form-urlencoded mặc định.
  • Webhook nhận form-encoded — Stripe webhook gửi event qua application/x-www-form-urlencoded theo convention (không phải JSON), GitHub webhook một số event cũng tương tự legacy.

Handler dùng Form<T>:

use axum_extra::extract::Form;
use serde::Deserialize;
use shop_common::error::{AppError, AppResult};

#[derive(Debug, Deserialize)]
struct LoginForm {
    email: String,
    password: String,
}

async fn admin_login(
    Form(form): Form<LoginForm>,
) -> AppResult<axum::response::Redirect> {
    // verify credentials với AuthService (B102+B105)
    let user = verify_admin(&form.email, &form.password)
        .await
        .map_err(|_| AppError::Unauthenticated)?;

    // set session cookie + redirect dashboard
    Ok(axum::response::Redirect::to("/admin"))
}

Quy tắc cùng Json<T>: Form<T> consume body → phải đặt cuối arg list trong signature (B13 đã lock). Không kết hợp Form<A> + Json<B> cùng handler — body chỉ đọc được một lần.

Decision lock cho Shop API:

  • API endpoints (mobile/SPA/partner) dùng Json<T> theo JSON-only policy (đã lock B5 + B6) — KHÔNG dùng Form cho endpoint /api/v1/*.
  • Form extractor CHỈ dùng cho hai use case riêng biệt: (a) admin dashboard login page render server-side template (B105) — POST /admin/login nhận application/x-www-form-urlencoded theo HTML form convention; (b) Stripe webhook POST /api/v1/webhooks/stripe (B197) — Stripe gửi application/x-www-form-urlencoded theo convention provider, không thay đổi được.
  • Cả hai use case này tách rõ ngoài resource API thường — không lẫn vào CRUD endpoint chính.
6

erased-json — Serialize Trait Object

Vấn đề Rust thường gặp: derive Serialize không work trực tiếp với Box<dyn Trait> (trait object). Trait serde::Serialize không object-safe — method serialize<S: Serializer>(...) generic, không cho phép virtual dispatch qua vtable.

// Compile error — Serialize không object-safe
let notifications: Vec<Box<dyn Serialize>> = vec![/* ... */];

Crate erased-serde giải bài toán này qua erased_serde::Serialize — phiên bản object-safe của trait. axum-extra cung cấp ErasedJson để response trait object qua JSON:

use axum_extra::response::ErasedJson;

async fn dynamic_response() -> ErasedJson {
    let value: Box<dyn erased_serde::Serialize> = make_payload();
    ErasedJson::new(&*value)
}

Use case: polymorphic response list chứa nhiều variant struct khác nhau hoàn toàn (Email + Push + SMS notification — mỗi loại field khác nhau hoàn toàn không gộp được qua enum).

Shop API decision: KHÔNG dùng erased-json. Lý do — đã lock pattern tagged enum ở B15 (JSON Serialization Với serde + axum::Json) cho polymorphism:

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Notification {
    Email { to: String, subject: String },
    Push { device_id: String, body: String },
    Sms { phone: String, text: String },
}

Tagged enum serialize sang shape {"type": "email", "to": "...", "subject": "..."} đủ cho mọi domain Shop API. Trade-off: phải list hết variant compile-time (closed set) — không thêm variant động runtime. Domain notification Shop API biết trước 3-5 variant không đổi → enum là lựa chọn đúng. Mention erased-json để bạn biết khi gặp use case plugin system hoặc dynamic variant runtime, không phải để áp dụng vào Shop API.

7

Query Với Defaults — Query<T> Extension

axum core Query<T> parse query string strict — field missing trong URL nghĩa là deserialize fail nếu type không phải Option<T>. axum-extra mở rộng pattern qua OptionalQuery<T> (entire query có thể missing → None) và một số helper khác.

Pattern dùng phổ biến nhất cho pagination Shop API là kết hợp Query<T> core + serde(default) attribute:

use axum::extract::Query;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct Pagination {
    #[serde(default = "default_page")]
    pub page: u32,
    #[serde(default = "default_size")]
    pub size: u32,
}

fn default_page() -> u32 { 1 }
fn default_size() -> u32 { 20 }

async fn list_products(
    Query(p): Query<Pagination>,
) -> String {
    format!("page={} size={}", p.page, p.size)
}

Behavior:

  • GET /productspage=1 size=20 (mặc định).
  • GET /products?page=3page=3 size=20 (size fallback default).
  • GET /products?size=50page=1 size=50 (page fallback default).
  • GET /products?page=3&size=50 → giá trị explicit.

Cần thêm constraint range thì validate ở B41 (JSON Extract + Validation Với validator Crate) — sửa #[derive(Validate)] với #[validate(range(min = 1, max = 1000))] trên field.

Pattern này lock cho mọi list endpoint Shop API ở B64 List Resources Với Pagination — DTO Pagination { page, size } đặt trong crates/shop-common/src/pagination.rs (chưa create, sẽ tạo B64), reuse cross-resource list_products, list_orders, list_reviews, admin_list_*.

8

Add axum-extra Vào workspace.dependencies

Cập nhật Cargo.toml root để lock version cross-workspace — pattern đã apply từ B10 cho mọi crate share. File shop/Cargo.toml thêm dòng axum-extra vào [workspace.dependencies]:

# File: shop/Cargo.toml (workspace root)
[workspace.dependencies]
# ... existing entries (axum 0.8, tokio, tower, ...)
axum = "0.8"
axum-extra = { version = "0.10", features = [
    "cookie",
    "cookie-private",
    "cookie-signed",
    "typed-header",
    "form",
    "query",
] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
# ... rest

Quan sát:

  • 6 feature flag opt-in đặt thẳng ở workspace root để mọi crate consume cùng version + cùng feature set. Member crate (shop-api, shop-worker tương lai) reference qua axum-extra.workspace = true — feature đã định sẵn ở root, không phải khai báo lại.
  • KHÔNG add vào crates/shop-api/Cargo.toml ngay — bài này chỉ lock workspace-level. Feature thực tế enable dần khi handler đầu tiên cần dùng: cookie ở B106 (admin login flow), typed-header ở B112 (JWT extract), form ở B105 (admin login page POST).
  • Pin version 0.10 align axum 0.8 đã lock từ B10. Khi axum bump major (0.9 hoặc 1.0), axum-extra bump tương ứng — luôn nâng đôi.

Verify workspace vẫn build sau khi thêm dep (chưa có handler dùng thì chỉ resolve dep, không link vào binary):

cd shop
cargo check --workspace
# Compiling axum-extra v0.10.x
# Finished `dev` profile [unoptimized + debuginfo] target(s)

Khi handler đầu tiên cần axum-extra (B106 admin login), crates/shop-api/Cargo.toml thêm dòng:

[dependencies]
axum-extra = { workspace = true }

Feature flag không phải lặp lại vì đã lock ở workspace root — đây là lý do dùng workspace.dependencies thay vì khai báo riêng từng crate.

9

Tổng Kết

  • axum-extra = extension features cho axum core (cookie, typed-header, form, query helper, erased-json) — official maintained bởi cùng team tokio-rs, repo cùng workspace với axum.
  • workspace.dependencies lock: axum-extra = "0.10" với 6 feature opt-in cookie + cookie-private + cookie-signed + typed-header + form + query.
  • CookieJar 3 loại: CookieJar plain (free, preference UI), SignedCookieJar HMAC SHA256 (tamper-proof, client đọc được), PrivateCookieJar AES-256-GCM (encrypted, client không đọc được).
  • Shop API: SignedCookieJar cho CSRF token (B107), PrivateCookieJar cho admin session (B105), API client dùng Bearer JWT (đã lock B4).
  • TypedHeader<T> strongly-typed extract: Authorization<Bearer>, Authorization<Basic>, ContentType, UserAgent, Host, Cookie — compiler enforce header tồn tại và format đúng, không lookup string raw qua HeaderMap.
  • Form<T> extractor cho application/x-www-form-urlencoded — Shop API chỉ dùng cho admin dashboard login page (B105) + Stripe webhook (B197), API endpoint giữ Json<T> JSON-only.
  • erased-json giải bài toán serialize trait object qua Box<dyn ErasedSerialize> — Shop API KHÔNG dùng vì tagged enum B15 đã đủ polymorphic cho domain.
  • Pattern pagination: Query<T> core + serde(default = "fn") + helper function trả default value (B64).
  • Khi nào enable feature: cookie-private + form ở B105 (admin login), cookie-signed ở B107 (CSRF), typed-header ở B112 (JWT extract), cookie standalone ở B106 (admin session).
  • Encryption key 32+ bytes random, sinh qua Key::generate() dev, prod đọc từ env COOKIE_KEY base64 — rotation theo lịch ở B289 (key management).
10

Bài Tập Củng Cố

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

  1. 3 loại CookieJar trong axum-extra khác nhau ở điểm gì (cơ chế bảo vệ, khả năng đọc/sửa từ client)? Khi nào dùng cái nào trong Shop API?
  2. TypedHeader<Authorization<Bearer>> extract gì từ request? Tại sao tốt hơn HeaderMap raw parse manual?
  3. Form<T> extractor khác Json<T> ra sao về Content-Type body parse? Shop API dùng Form cho 2 use case nào và tại sao API endpoint chính giữ Json<T>?
  4. axum-extra lock version 0.10 trong workspace.dependencies. Tại sao 0.10 mà không bản mới hơn? Khi nào nâng version?
  5. Shop API có dùng erased-json không? Pattern thay thế là gì và tại sao đủ cho domain Shop?
Đáp án
  1. Plain CookieJar — cookie không bảo vệ, server lưu raw value, client đọc và sửa tự do qua DevTools hoặc document.cookie JavaScript. Dùng cho preference UI không nhạy cảm: dark mode, language, sidebar collapsed state, cookie consent banner. Sửa tay không phá vỡ security vì dữ liệu không liên quan auth. SignedCookieJar — server ký HMAC SHA256 phía sau giá trị, cookie thành <value>.<hmac>. Client đọc được giá trị (vẫn plaintext) nhưng không sửa được — đổi giá trị mà không tính lại HMAC thì server verify fail và reject. Shop API dùng cho CSRF token (B107) — client cần đọc để inject header X-CSRF-Token khi submit form/AJAX, nhưng không được sửa. PrivateCookieJar — server AES-256-GCM encrypt + sign nội dung. Client KHÔNG đọc được (DevTools chỉ thấy bytes random base64), không sửa được. Shop API dùng cho session ID admin dashboard (B105) — admin login qua HTML form → server set session cookie encrypted → request kế server decrypt + lookup Redis session. Cả Signed và Private đều cần Key instance 32+ bytes random server-side, share cross-instance qua secret manager khi scale horizontal. API client (mobile/SPA/partner) KHÔNG dùng cookie — giữ Bearer JWT (đã lock B4) đơn giản hơn cho cross-origin và mobile.
  2. TypedHeader<Authorization<Bearer>> extract header Authorization: Bearer <token> từ request, strip prefix "Bearer " tự động, expose .token() -> &str trả token raw. Compiler check tại signature handler qua trait bound headers::Header — nếu header missing hoặc sai format (vd "Basic xxx" thay vì "Bearer xxx"), axum-extra reject với 400 Bad Request response trước khi handler chạy. So với HeaderMap raw, lợi điểm: (a) type-safe — không lookup string "authorization" dễ sai chính tả (vd "Authorisation" UK spelling); (b) parse + validate tự động — không phải .get().and_then(|v| v.to_str().ok()).and_then(|s| s.strip_prefix("Bearer ")) mỗi handler; (c) reuse cross-handler — validation logic một lần, dùng nhiều nơi; (d) document tự động OpenAPI qua utoipa derive (B8) — schema generate biết handler yêu cầu Authorization header; (e) compiler enforce signature đúng — quên header thì compile error tại get(handler), không phải runtime bug. Pattern lock Shop API: B112 dùng cho JWT verify, B33 cho custom Idempotency-Key theo Stripe convention (đã lock B4).
  3. Form<T> parse body Content-Type application/x-www-form-urlencoded qua crate serde_urlencoded dưới hood — format key1=value1&key2=value2 URL-encoded (space thành %20, ký tự đặc biệt thành %XX). Json<T> parse Content-Type application/json qua serde_json — format JSON object {"key1": "value1", "key2": "value2"} nested. Cả hai consume body một lần → cuối arg list signature handler (đã lock B13). Shop API dùng Form<T> CHỈ 2 use case: (a) Admin dashboard login page (B105) — POST /admin/login nhận form submit từ HTML page render server-side (template askama/maud); browser submit form thì tự gửi application/x-www-form-urlencoded mặc định, không phải JSON. (b) Stripe webhook (B197)POST /api/v1/webhooks/stripe nhận event payload từ Stripe theo convention application/x-www-form-urlencoded provider quy định, không thay đổi được. API endpoint chính (/api/v1/*) giữ Json<T> vì: (1) JSON-only policy đã lock B5 + B6 cho consumer mobile/SPA/partner — họ gửi JSON tự nhiên; (2) JSON cho phép nested object, array, typed value (boolean, number) — form chỉ flat key-value string; (3) ecosystem tool (Postman, curl với -H 'Content-Type: application/json') default JSON; (4) OpenAPI schema (B8) document JSON body chính xác hơn URL-encoded.
  4. Lock axum-extra = "0.10" vì align axum = "0.8" đã lock từ B10. axum-extra release theo cùng team tokio-rs với axum, version 0.10 là phiên bản tương thích với axum 0.8 — chéo version (vd axum 0.8 với axum-extra 0.9) gây compile error trait bound không match. Pattern chuẩn: luôn nâng cặp axum + axum-extra cùng nhau. Lý do không lấy bản mới hơn tại thời điểm B19: (a) axum 0.8 là latest stable Q4 2026, axum-extra 0.10 align; (b) workspace.dependencies pattern (lock B10) yêu cầu version stable + reproducible build cross-machine + cross-CI — bump tùy ý gây inconsistency. Khi nào nâng version: (1) CVE bảo mật — patch bắt buộc, audit changelog kỹ, bump kèm test e2e đầy đủ; (2) tokio-rs release major (axum 0.9 hoặc 1.0) — nâng kèm migration code theo upgrade guide, bump axum-extra tương ứng cùng PR; (3) feature mới cần thiết mà bản hiện tại không có — review trade-off với cost migrate. Minor version bump (0.10.x → 0.10.y) trong cùng minor được phép trong PR audit; major bump cần spike riêng đánh giá impact toàn workspace.
  5. Shop API KHÔNG dùng erased-json. Pattern thay thế đã lock từ B15 (JSON Serialization Với serde + axum::Json) là tagged enum với serde attribute #[serde(tag = "type")] hoặc #[serde(tag = "type", content = "data")] — dùng cho Notification với 3-5 variant (Email, Push, Sms, In-App, Webhook) khai báo compile-time. Tagged enum serialize sang JSON shape {"type": "email", "to": "...", "subject": "..."} đủ cho polymorphic response. Đủ cho domain Shop API vì: (a) tập variant closed set biết trước compile-time — domain notification cố định 3-5 loại, không thêm runtime; (b) type-safe — pattern matching exhaustive ở mọi nơi consume, miss variant compile error chỉ thẳng vào dòng match; (c) serde derive built-in — không kéo thêm crate erased-serde, dependency tree nhỏ hơn; (d) OpenAPI schema (B8) generate chính xác — utoipa biết list variant + field từng variant, document đẹp; (e) performance tốt hơn — không virtual dispatch qua vtable, serialize trực tiếp. Use case duy nhất cần erased-jsonplugin system runtime hoặc dynamic variant runtime không biết trước compile-time (vd CMS field schema editor, workflow engine custom step) — Shop API không có nhu cầu này. Mention erased-json trong bài để bạn biết khi gặp domain plugin-based ở dự án khác, không phải áp dụng vào Shop.
11

Bài Tiếp Theo

— bài cuối Group 2: layer stack hyper → tower → axum, tower-http middleware (cors, trace, compression), tonic gRPC cùng base, ecosystem map cho hiểu axum nằm đâu trong Rust async web ecosystem.