Danh sách bài viết

Bài 35: Form Extractor Cho HTML Form

Bài 35 của series Rust RESTful API — chi tiết axum::Form<T> extractor parse body Content-Type application/x-www-form-urlencoded từ HTML form submit native browser, phân biệt rõ với application/json structured nested của Json<T>: form-urlencoded có format key=value&key2=value2 percent-encoded trong body tương tự query string nhưng nằm trong request body, browser tự encode (space → %20 hoặc +, ký tự đặc biệt → percent-encoding theo RFC 3986), use case chính là traditional HTML form server-rendered template với method="POST" mặc định submit. Shop API JSON-only policy lock B5 cho mọi API endpoint /api/v1/* (Bearer JWT auth lock B4, mobile/SPA/partner consume JSON structured) — Form<T> CHỈ dùng cho 2 use case lock B19 confirm B35: admin dashboard login B105 (HTML form POST /admin/login render server-side template, cookie session PrivateCookieJar lock B34) và Stripe webhook B197 (Stripe gửi event qua form-encoded payload, HMAC SHA256 signature verify với raw body bytes lock B37). Body extractor đặt CUỐI arg list theo lock B31 — FromRequest consume body one-shot, chỉ tối đa 1 body extractor per handler (Form + Json cùng handler → compile error trait bound conflict). Content-Type header phải application/x-www-form-urlencoded; sai content-type axum reject với FormRejection::InvalidFormContentType → default 415 Unsupported Media Type + plain text body không match envelope JSON Shop API. AppForm<T> wrapper pattern theo B32 cho rejection envelope chuẩn (lock vĩnh viễn align với AppJson<T> B32): InvalidFormContentType → AppError::BadRequest("expected application/x-www-form-urlencoded") 400 (map 400 thay 415 cho consistency với JSON-only policy B5 chỉ trả 400/422 cho client error parse), FailedToDeserializeForm/FailedToDeserializeFormBody → AppError::Validation 422 (schema sai — semantic "server hiểu request nhưng data không hợp lệ" align JsonDataError pattern lock B32), BytesRejection → BadRequest 400 (body read fail), variant _ future-compat → BadRequest("invalid form") 400. File path lock vĩnh viễn crates/shop-api/src/extractors/form.rs + re-export qua extractors/mod.rs với pub mod form; pub use form::AppForm; (B105 tạo cùng đợt admin login flow, B35 conceptual + preview pattern KHÔNG tạo file thực tế). Template engine integration so sánh 3 lựa chọn Rust 2026: askama (compile-time Jinja2-like type-safe, error báo tại lúc cargo build không runtime, performance cao gần native code, tradeoff re-compile khi đổi template phải cargo run lại), maud (macro inline HTML type-safe template trong Rust code không file riêng, refactor IDE-friendly với rename symbol, tradeoff mixed concern template + logic cùng file Rust, ecosystem nhỏ hơn askama), tera (runtime Jinja2-like template file load lúc startup, hot reload friendly cho dev, tradeoff error runtime không compile-time, performance thấp hơn askama do interpret thay native). Shop API decision lock B35 chọn askama cho admin dashboard (compile-time safe phù hợp production + performance + ecosystem mature + integration askama_axum sẵn impl IntoResponse cho template — B105 implement đầy đủ với templates/admin/login.html + #[derive(Template)]). CSRF risk MANDATORY khi serve HTML form + POST endpoint cookie-based (attacker tạo form trên evil.com submit POST tới shop.com với action="https://shop.com/admin/products", browser tự động gửi cookie session admin → server xử lý request như user hợp lệ, tạo product giả mạo); 3 protection strategy lock vĩnh viễn: SameSite=Strict cho CSRF cookie (lock B34 attribute, browser block cross-site cookie send), CSRF token hidden input trong form server verify match cookie value (double-submit pattern), SignedCookieJar HMAC tamper-proof (lock B34 — token random server sinh, set cookie Signed, form embed cùng value qua <input type="hidden" name="csrf_token" value="..." />, server verify form field == cookie value, B107 implement đầy đủ). MultiPart preview B36 cho file upload: khi form có <input type="file" /> Content-Type đổi thành multipart/form-data; boundary=..., Form<T> KHÔNG handle multipart format này, cần Multipart extractor riêng stream từng field qua field.next_field().await + file size limit + content-type validation + save to disk hoặc S3 + security pitfall (filename traversal, magic bytes verify); Shop API B36 implement admin upload product image. JSON vs Form decision rule lock vĩnh viễn: API endpoints (Bearer JWT lock B4 stateless) dùng Json<T> + AppJson<T> wrapper B32, HTML form submit (cookie session admin browser flow) dùng Form<T> + AppForm<T> wrapper B35, KHÔNG trộn lẫn để tránh complexity và security pitfall. Foundation cho B105 admin login (Form<LoginForm> + askama template render + PrivateCookieJar session cookie lock B34) + B107 CSRF protection (SignedCookieJar double-submit pattern) + B36 Multipart file upload product image admin.

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

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

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

  • Phân biệt application/x-www-form-urlencoded (HTML form submit native, format key=value&key2=value2 percent-encoded trong body) với application/json (structured nested, Shop API JSON-only policy lock B5 cho mọi API endpoint).
  • Biết dùng axum::Form<T> extractor có sẵn trong axum core (không cần axum-extra) cho HTML form submit; body extractor đặt CUỐI arg list theo lock B31.
  • Hiểu khi nào dùng Form<T> (admin login B105 cookie session, Stripe webhook B197 form-encoded payload) vs Json<T> (mọi API endpoint /api/v1/* Bearer JWT lock B4).
  • Áp dụng wrapper pattern AppForm<T> cho rejection envelope chuẩn theo B32: InvalidFormContentTypeBadRequest 400, FailedToDeserializeFormValidation 422 (align JsonDataError pattern).
  • Biết integrate template engine: askama (compile-time type-safe, Shop API chọn), maud (macro inline HTML), tera (runtime hot reload dev).
  • Preview B105 admin login flow + B197 Stripe webhook + CSRF protection MANDATORY khi serve form (B107 double-submit pattern); B36 Multipart preview cho file upload.
2

application/x-www-form-urlencoded Là Gì?

application/x-www-form-urlencoded là Content-Type mặc định khi HTML form submit với method="POST" không khai báo enctype. Format body tương tự query string nhưng nằm trong request body thay vì URL — key-value pairs nối qua &, percent-encoded theo RFC 1866 mục 8.2.1 và RFC 3986:

┌──────────────────────────────────────────────────────────────────────┐
│ HTML form gốc                                                        │
├──────────────────────────────────────────────────────────────────────┤
│  <form method="POST" action="/admin/login">                          │
│    <input name="email" value="[email protected]" />                     │
│    <input name="password" type="password" value="secret" />          │
│    <input name="remember" type="checkbox" checked />                 │
│    <button type="submit">Login</button>                              │
│  </form>                                                             │
└──────────────────────────────────────────────────────────────────────┘
                              ↓ browser submit
┌──────────────────────────────────────────────────────────────────────┐
│ HTTP request wire                                                    │
├──────────────────────────────────────────────────────────────────────┤
│  POST /admin/login HTTP/1.1                                          │
│  Host: admin.shop.com                                                │
│  Content-Type: application/x-www-form-urlencoded                     │
│  Content-Length: 56                                                  │
│                                                                      │
│  email=admin%40shop.com&password=secret&remember=on                  │
└──────────────────────────────────────────────────────────────────────┘

Browser tự encode các ký tự đặc biệt qua percent-encoding: @%40, space → %20 hoặc + (form context cho phép cả hai), &%26 để tránh đụng delimiter, =%3D. Server side decode tự động qua thư viện serde_urlencoded dưới capo axum::Form.

Anti-pattern: dùng application/x-www-form-urlencoded cho REST API data submit. JSON tốt hơn vì hỗ trợ nested structure ({"user": {"name": "...", "addresses": [...]}}), typed value (number không phải string), null phân biệt missing, schema document hóa qua JSON Schema/OpenAPI (lock B6). Shop API JSON-only policy lock B5 cho mọi data endpoint /api/v1/* — form-urlencoded chỉ giữ kiến thức để hiểu HTML form legacy.

Use case chính còn giá trị: traditional HTML form server-rendered template (admin dashboard login page, settings form internal tool), webhook legacy không support JSON (Stripe webhook gửi event qua form-encoded payload với signature header riêng — lock B197), integration với system cũ chỉ accept form data.

3

Form<T> Extractor Cơ Bản

axum::Form<T> có sẵn trong axum core, KHÔNG cần feature form của axum-extra (axum-extra cũng cung cấp Form<T> riêng với edge case nhỏ khác — lock B19 ghi nhận sẵn feature nhưng Shop API dùng axum core đủ). T phải impl serde::Deserialize để parse từ form-urlencoded thông qua crate serde_urlencoded:

use axum::Form;
use serde::Deserialize;

#[derive(Deserialize)]
struct LoginForm {
    email: String,
    password: String,
    #[serde(default)]
    remember: bool,
}

async fn login(Form(form): Form<LoginForm>) -> impl IntoResponse {
    // verify credentials, create session, set cookie
    tracing::info!(email = %form.email, "admin login attempt");
    // ...
}

Điểm chú ý:

  • Destructure Form(form): Form<LoginForm> ngay trên signature handler — pattern clean hơn f: Form<LoginForm> rồi f.0, đồng nhất với Path/Json lock B22/B15.
  • Đặt CUỐI arg list theo lock B31 — Form<T> impl FromRequest (consume body one-shot), KHÔNG impl FromRequestParts. Mỗi handler tối đa 1 body extractor; Form + Json cùng handler → compile error trait bound conflict.
  • Content-Type bắt buộc application/x-www-form-urlencoded — request gửi application/json hoặc thiếu Content-Type → axum reject với FormRejection::InvalidFormContentType + default 415 Unsupported Media Type + body plain text.
  • #[serde(default)] cho field optional (vd remember checkbox không check → key không gửi); HTML form checkbox unchecked KHÔNG gửi key tương ứng (khác null/false JSON), nên field bool optional cần default.
  • Nested structure không support — form-urlencoded flat key-value, không có nested object như JSON. Vd {"user": {"name": "x"}} phải flatten thành user.name=x hoặc user[name]=x (tùy convention crate — serde_urlencoded default flat, không hiểu bracket; cần serde_qs cho bracket syntax).

Browser flow tự nhiên: user submit form → browser encode body → POST với Content-Type đúng → axum extract Form<LoginForm> → handler verify business logic → set cookie session (PrivateCookieJar lock B34) → redirect /admin.

4

Khi Nào Dùng Form vs Json?

Quyết định Form<T> hay Json<T> tùy theo context client và transport:

┌─────────────────┬─────────────────────────────┬─────────────────────────────┐
│ Aspect          │ Form<T>                     │ Json<T>                     │
├─────────────────┼─────────────────────────────┼─────────────────────────────┤
│ Content-Type    │ x-www-form-urlencoded       │ application/json            │
│ Client          │ Browser HTML form           │ Mobile/SPA/partner          │
│ Structure       │ Flat key=value              │ Nested object/array         │
│ Encoding        │ Percent-encoding            │ UTF-8 JSON                  │
│ Auth            │ Cookie session              │ Bearer JWT                  │
│ Use case        │ Admin login, webhook        │ REST API endpoint           │
│ Shop API        │ /admin/login (B105)         │ /api/v1/* (lock B5)         │
│                 │ /webhooks/stripe (B197)     │                             │
└─────────────────┴─────────────────────────────┴─────────────────────────────┘

Form<T> phù hợp khi:

  • HTML form submit browser native — admin dashboard login page (B105), settings form internal, password reset form. Browser tự encode Content-Type đúng, không cần JS framework.
  • Webhook legacy không support JSON — Stripe webhook gửi event qua form-encoded payload với header Stripe-Signature riêng (B197 verify HMAC SHA256 với raw body bytes).
  • Integration system cũ chỉ accept form data — legacy partner API, third-party tool không hiểu JSON.

Json<T> phù hợp khi:

  • REST API endpoints — Shop API mặc định cho mọi /api/v1/* theo JSON-only policy lock B5.
  • Mobile app, SPA, partner integration — client programmatic access, parse JSON dễ dàng qua thư viện built-in.
  • Nested data complex — cart với items array, order với address object, product với variants nested.

Shop API decision rule lock vĩnh viễn:

  • /api/v1/*Json<T> + AppJson<T> wrapper B32, Bearer JWT auth lock B4.
  • /admin/loginForm<T> + AppForm<T> wrapper B35, render askama template + cookie session.
  • /api/v1/webhooks/stripeForm<T> hoặc Bytes raw (tùy signature method — Stripe v1 dùng raw body verify HMAC, parse Form sau khi verify thành công).

Không trộn lẫn — endpoint phục vụ browser dùng cookie + Form, endpoint phục vụ API client dùng JWT + Json. Tách bạch concern giúp middleware auth khác nhau (CSRF check chỉ cho cookie endpoint, không apply cho JWT endpoint), template engine chỉ wire vào admin route, infra hiện đại không phải support cả 2 encoding cho cùng endpoint.

5

AppForm<T> Wrapper Pattern (Áp Dụng B32)

axum Form<T> default rejection trả text/plain raw response không match envelope JSON Shop API (lock B3 + B16). Wrap thành AppForm<T> tương tự AppJson<T> lock B32 để rejection map về AppError envelope chuẩn:

// File: crates/shop-api/src/extractors/form.rs (B105 tạo cùng đợt admin login)
use axum::{
    extract::{rejection::FormRejection, FromRequest, Request},
    Form,
};
use serde::de::DeserializeOwned;
use shop_common::error::AppError;

pub struct AppForm<T>(pub T);

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

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        match Form::<T>::from_request(req, state).await {
            Ok(Form(value)) => Ok(AppForm(value)),
            Err(rejection) => Err(map_form_rejection(rejection)),
        }
    }
}

fn map_form_rejection(rejection: FormRejection) -> AppError {
    match rejection {
        FormRejection::InvalidFormContentType(_) => {
            AppError::BadRequest(
                "expected application/x-www-form-urlencoded".to_string(),
            )
        }
        FormRejection::FailedToDeserializeForm(err) => {
            AppError::Validation(format!("form validation failed: {}", err))
        }
        FormRejection::FailedToDeserializeFormBody(err) => {
            AppError::Validation(format!("form body invalid: {}", err))
        }
        FormRejection::BytesRejection(err) => {
            AppError::BadRequest(format!("body read error: {}", err))
        }
        _ => AppError::BadRequest("invalid form".to_string()),
    }
}

Điểm chú ý:

  • Impl FromRequest (KHÔNG FromRequestParts) vì Form<T> consume body — đặt CUỐI arg list theo lock B31. Bỏ T: Send so với AppPath/AppQuery (giống AppJson lock B32).
  • Delegate parse cho axum Form::<T>::from_request — pattern 3 bước cố định lock B32: tuple struct wrapper → impl trait FromRequest → delegate logic gọi extractor gốc, match map rejection sang AppError.
  • InvalidFormContentTypeBadRequest 400 thay 415 (default axum) cho consistency với JSON-only policy B5 — Shop API chỉ trả 400/422 cho client error parse, không trả 415 trộn lẫn semantic.
  • FailedToDeserializeForm/FailedToDeserializeFormBodyValidation 422 — align JsonDataError pattern lock B32, semantic "server hiểu request nhưng data không hợp lệ" RFC 9110 mục 15.5.21. Phân biệt với BytesRejection (body read fail → 400) là cú pháp/transport vs schema validation.
  • Helper function module-level fn map_form_rejection tách riêng để test unit-able cô lập logic mapping + dễ extend khi thêm variant mới (axum giữ quyền thêm variant ở minor version sau).

Re-export trong extractors/mod.rs khi tạo (B105):

// File: crates/shop-api/src/extractors/mod.rs (extend B105)
pub mod form;
pub mod json;
pub mod path;
pub mod query;

pub use form::AppForm;
pub use json::AppJson;
pub use path::AppPath;
pub use query::AppQuery;

Handler convention B105 onward: mọi handler nhận form body MANDATORY dùng AppForm<T> thay axum::Form default — rejection envelope chuẩn flow qua wrapper, dùng default sẽ leak text/plain raw response không nhất quán với policy B3 + B16.

6

Template Engine Integration: askama, maud, tera

HTML form admin dashboard cần render server-side template. 3 lựa chọn template engine phổ biến Rust 2026:

┌──────────┬──────────────────────┬──────────────────┬──────────────────────┐
│ Engine   │ Style                │ Error            │ Performance          │
├──────────┼──────────────────────┼──────────────────┼──────────────────────┤
│ askama   │ Compile-time         │ Compile-time     │ Cao (gần native)     │
│          │ Jinja2-like syntax   │                  │ Re-compile khi đổi   │
│          │ File .html riêng     │                  │ template             │
├──────────┼──────────────────────┼──────────────────┼──────────────────────┤
│ maud     │ Macro inline HTML    │ Compile-time     │ Cao (gần native)     │
│          │ Trong Rust code      │                  │ Refactor IDE-friendly│
│          │ Không file riêng     │                  │ Mixed concern        │
├──────────┼──────────────────────┼──────────────────┼──────────────────────┤
│ tera     │ Runtime              │ Runtime          │ Trung bình           │
│          │ Jinja2-like syntax   │                  │ Hot reload dev       │
│          │ File .html load late │                  │                      │
└──────────┴──────────────────────┴──────────────────┴──────────────────────┘

askama — Jinja2-like syntax ({% if %}, {{ var }}, {% for %}) trong file .html riêng, derive macro #[derive(Template)] + attribute #[template(path = "admin/login.html")] compile template thành Rust code lúc cargo build. Ưu điểm: error báo tại compile-time (field missing, syntax sai), performance cao gần native code, type-safe binding field struct → variable template. Nhược điểm: re-compile khi đổi template phải cargo run lại (không hot reload), ecosystem cần askama_axum wire IntoResponse tự động.

maud — macro html! inline HTML trong Rust code, syntax Rust-like (div { "Hello" }, (name) interpolate). Ưu điểm: type-safe template trong Rust code không file riêng, refactor IDE-friendly với rename symbol (đổi field struct rename luôn trong template). Nhược điểm: mixed concern (template + logic cùng file Rust dài), ecosystem nhỏ hơn askama, designer không sửa được template (phải biết Rust).

tera — Jinja2-like syntax tương tự askama nhưng runtime load template file lúc startup, không compile-time. Ưu điểm: hot reload friendly cho dev (đổi template không restart server qua feature flag), familiar với Python/Django dev. Nhược điểm: error báo runtime (deploy production mới phát hiện), performance thấp hơn askama do interpret thay native, không type-safe.

Shop API decision lock B35 chọn askama cho admin dashboard — lý do: (a) compile-time safe phù hợp production tránh error runtime; (b) performance cao admin dashboard handle nhiều request internal user; (c) ecosystem mature với askama_axum 0.4+ impl IntoResponse sẵn cho struct derive Template; (d) Jinja2 syntax quen thuộc dev có background Python/Flask; (e) file .html tách bạch concern designer có thể edit template không phải biết Rust.

Code mẫu askama (B105 implement):

// File: crates/shop-api/src/routes/admin/auth.rs (B105 implement)
use askama::Template;
use askama_axum::IntoResponse;
use axum::response::Html;
use axum_extra::extract::SignedCookieJar;

#[derive(Template)]
#[template(path = "admin/login.html")]
struct LoginTemplate {
    csrf_token: String,
    error: Option<String>,
}

async fn login_page(jar: SignedCookieJar) -> impl IntoResponse {
    let csrf = generate_csrf_token();  // 32 bytes random base64
    let template = LoginTemplate {
        csrf_token: csrf.clone(),
        error: None,
    };
    // SignedCookieJar set CSRF cookie + render template
    // askama_axum tự impl IntoResponse cho LoginTemplate
    template
}

Template file templates/admin/login.html (B105 tạo):

<!DOCTYPE html>
<html lang="vi">
<head><title>Admin Login</title></head>
<body>
  {% if let Some(err) = error %}
    <div class="error">{{ err }}</div>
  {% endif %}
  <form method="POST" action="/admin/login">
    <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
    <input type="email" name="email" required />
    <input type="password" name="password" required />
    <input type="checkbox" name="remember" /> Remember me
    <button type="submit">Login</button>
  </form>
</body>
</html>
7

CSRF Nhắc Lại (Preview B107)

Khi Shop API serve HTML form + POST endpoint cookie-based, CSRF risk MANDATORY phải mitigate. Attack pattern điển hình:

  • Admin đăng nhập shop.com, browser lưu cookie session (PrivateCookieJar lock B34).
  • Attacker tạo trang evil.com với form ẩn:
    <form id="csrf" method="POST" action="https://shop.com/admin/products">
      <input name="name" value="Fake Product" />
      <input name="price" value="0.01" />
    </form>
    <script>document.getElementById('csrf').submit()</script>
  • Admin truy cập evil.com (qua phishing email/link) → form auto-submit POST tới shop.com → browser tự động gửi cookie session theo cùng origin (nếu không có SameSite protection) → server xử lý request như user hợp lệ → tạo product giả.

3 protection strategy lock vĩnh viễn Shop API:

  • SameSite=Strict cho CSRF cookie (lock B34 attribute) — browser block cookie send với cross-site request. Modern browser từ 2020 mặc định Lax nếu không khai báo, chống được CSRF main vector (POST từ malicious site).
  • CSRF token hidden input trong form — server sinh token random 32 bytes, set vào SignedCookieJar (lock B34 HMAC tamper-proof), render template với hidden input <input type="hidden" name="csrf_token" value="..." />. Khi submit, server verify form field csrf_token == cookie value (double-submit pattern). Attacker không đọc được cookie cross-origin → không thể echo token vào form giả.
  • SignedCookieJar HMAC tamper-proof (lock B34) — client đọc token được nhưng KHÔNG sửa được vì sửa value làm signature không khớp → server verify fail. Đảm bảo token integrity end-to-end.

Code snippet pattern preview (B107 chi tiết):

// File: crates/shop-api/src/middleware/csrf.rs (B107 implement)
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
use axum_extra::extract::SignedCookieJar;
use shop_common::error::AppError;

async fn verify_csrf(
    jar: SignedCookieJar,
    req: Request,
    next: Next,
) -> Result<Response, AppError> {
    // Lấy token từ cookie (SignedCookieJar verify HMAC tự động)
    let cookie_token = jar
        .get("csrf_token")
        .ok_or_else(|| AppError::BadRequest("CSRF cookie missing".to_string()))?
        .value()
        .to_string();

    // Lấy token từ form field (đọc body, restore lại cho handler)
    let form_token = extract_csrf_from_form(&req).await?;

    if cookie_token != form_token {
        return Err(AppError::BadRequest(
            "CSRF token mismatch".to_string(),
        ));
    }

    Ok(next.run(req).await)
}

B107 implement đầy đủ pattern double-submit + middleware wire vào admin route + edge case body re-read sau verify (clone bytes hoặc Extension pass-through).

8

MultiPart Preview (Chi Tiết B36)

Khi HTML form có <input type="file" /> cho file upload, browser tự động đổi Content-Type từ application/x-www-form-urlencoded sang multipart/form-data; boundary=... — format hoàn toàn khác:

POST /admin/products/upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="name"

iPhone 16 Pro
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="image"; filename="phone.jpg"
Content-Type: image/jpeg

[binary bytes...]
------WebKitFormBoundary7MA4YWxkTrZu0gW--

Form<T> KHÔNG handle multipart — gặp Content-Type multipart/form-data sẽ reject với InvalidFormContentType. Phải dùng axum::extract::Multipart extractor riêng (deep dive B36):

// Preview B36 — admin upload product image
use axum::extract::Multipart;

async fn upload_image(mut multipart: Multipart) -> impl IntoResponse {
    while let Some(field) = multipart.next_field().await.unwrap() {
        let name = field.name().unwrap_or("").to_string();
        match name.as_str() {
            "name" => {
                let text = field.text().await.unwrap();
                // ...
            }
            "image" => {
                let filename = field.file_name().unwrap_or("").to_string();
                let bytes = field.bytes().await.unwrap();
                // validate magic bytes, size, save S3
            }
            _ => {}
        }
    }
    // ...
}

B36 chi tiết:

  • Stream từng field qua multipart.next_field().await — không load toàn body vào memory cùng lúc (file lớn 100MB không OOM).
  • File size limit qua DefaultBodyLimit::max(N) per route + check field.bytes() size; Shop API admin product image cap 5MB.
  • Content-type validation qua field.content_type() + magic bytes verify (đọc 4-8 bytes đầu file, match với PNG/JPEG/WebP signature). KHÔNG trust extension client gửi.
  • Save to disk/S3 — Shop API B36 dùng S3-compatible storage (MinIO dev, AWS S3 prod) qua crate aws-sdk-s3 hoặc rusty-s3; filename random UUID v4 tránh collision + traversal attack.
  • Security pitfall: filename traversal (../../../etc/passwd) — chỉ giữ tên file random UUID không dùng filename client. Magic bytes verify cho ảnh thật không phải .exe đổi extension.

Shop API use case lock B36: admin upload product image qua form multipart/form-data, validate + store S3, return URL CDN cho catalog endpoint. Endpoint POST /api/v1/admin/products/:id/image với AppMultipart wrapper (B36 lock pattern tương tự AppForm B35).

9

Tổng Kết

  • axum::Form<T> extract body Content-Type application/x-www-form-urlencoded từ HTML form submit native browser; T impl Deserialize parse qua serde_urlencoded.
  • Body extractor đặt CUỐI arg list theo lock B31 (FromRequest consume body one-shot, chỉ 1 body extractor / handler — Form + Json cùng handler → compile error).
  • Khi nào Form: HTML form submit browser (admin login B105), webhook legacy form-encoded (Stripe B197), integration system cũ. Khi nào Json: REST API endpoints (Shop API mặc định JSON-only policy lock B5), mobile/SPA/partner, nested data complex.
  • Shop API decision rule lock vĩnh viễn: /api/v1/*Json<T> + Bearer JWT (lock B4); /admin/loginForm<T> + cookie session (lock B34); /api/v1/webhooks/stripeForm<T> hoặc Bytes raw (tùy signature method B197).
  • AppForm<T> wrapper pattern theo B32 — file path lock vĩnh viễn crates/shop-api/src/extractors/form.rs (B105 tạo cùng đợt admin login). Impl FromRequest<S> for AppForm<T> delegate Form::<T>::from_request + match map rejection sang AppError.
  • Rejection mapping lock vĩnh viễn align AppJson B32: InvalidFormContentTypeBadRequest 400 (map 400 thay 415 cho consistency JSON-only B5), FailedToDeserializeForm/FailedToDeserializeFormBodyValidation 422 (align JsonDataError), BytesRejectionBadRequest 400, variant _ future-compat → BadRequest 400.
  • Template engine 3 lựa chọn 2026: askama compile-time Jinja2-like type-safe (Shop API chọn — compile-time safe, performance, ecosystem mature, askama_axum wire IntoResponse), maud macro inline HTML refactor-friendly nhưng mixed concern, tera runtime hot reload dev nhưng error runtime + performance thấp.
  • CSRF MANDATORY khi serve HTML form + POST endpoint cookie-based — 3 protection strategy lock vĩnh viễn: SameSite=Strict cho CSRF cookie (B34), CSRF token hidden input form server verify match cookie value (double-submit pattern B107), SignedCookieJar HMAC tamper-proof (B34).
  • MultiPart cho file upload (B36 chi tiết) — Content-Type multipart/form-data; boundary=... khác urlencoded, Form<T> KHÔNG handle, cần Multipart extractor riêng stream từng field + file size limit + content-type validation + save disk/S3 + security pitfall (filename traversal, magic bytes verify).
  • B35 conceptual + lock pattern, KHÔNG tạo file thực tế ở Shop API (Workspace State KHÔNG đổi). File crates/shop-api/src/extractors/form.rs + template templates/admin/login.html sẽ tạo ở B105 (admin login implement) khi infra Redis client + cookie key wire vào AppState.
10

Bài Tập Củng Cố

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

  1. Phân biệt application/x-www-form-urlencodedapplication/json. Format body khác gì? Browser submit form HTML mặc định Content-Type nào?
  2. Khi nào dùng Form<T> thay Json<T>? Shop API có 2-3 use case cụ thể lock vĩnh viễn nào? Decision rule chia API endpoint vs HTML form?
  3. AppForm<T> wrapper map rejection FormRejection thành AppError nào? Phân biệt InvalidFormContentType vs FailedToDeserializeForm? Tại sao map 400 thay 415 cho InvalidFormContentType?
  4. Template engine askama vs maud vs tera khác nhau ở error thời điểm, performance, style code. Shop API chọn cái nào và 5 lý do tại sao?
  5. Form HTML POST có CSRF risk. 3 mitigation strategy MANDATORY cho Shop API là gì? Double-submit pattern hoạt động ra sao? Tại sao attacker không thể bypass được?
Đáp án
  1. Phân biệt application/x-www-form-urlencodedapplication/json: (a) Format body — urlencoded có format key=value&key2=value2 flat key-value pairs percent-encoded theo RFC 3986 (vd email=admin%40shop.com&password=secret&remember=on), tương tự query string nhưng nằm trong request body thay vì URL; JSON có format structured nested object/array theo RFC 8259 (vd {"email":"[email protected]","password":"secret","remember":true}) hỗ trợ typed value (number không phải string), null phân biệt missing, nested arbitrary depth. (b) Encoding — urlencoded percent-encoding ký tự đặc biệt (@%40, space → %20 hoặc +, &%26), browser tự encode khi submit form; JSON UTF-8 string với escape sequence (\", \n), client (JS, mobile) tự serialize qua JSON.stringify hoặc thư viện built-in. (c) Nested support — urlencoded KHÔNG support nested object (chỉ flat), bracket syntax user[name]=x phụ thuộc convention crate (serde_urlencoded default flat không hiểu bracket, cần serde_qs); JSON nested arbitrary depth native. (d) Use case — urlencoded cho HTML form submit browser native, webhook legacy; JSON cho REST API, mobile/SPA, partner integration. Browser submit form HTML mặc định Content-Type: application/x-www-form-urlencoded khi form không khai báo enctype hoặc enctype="application/x-www-form-urlencoded". Khi form có <input type="file"> hoặc khai báo enctype="multipart/form-data" đổi sang multipart/form-data; boundary=.... enctype="text/plain" hiếm dùng (mỗi key-value 1 dòng plain text, không encode, dùng cho email mailto). Khi submit qua JS fetch/XMLHttpRequest dev tự set Content-Type tùy chọn — modern SPA dùng application/json với JSON.stringify(body), traditional form submit qua FormData giữ urlencoded hoặc multipart tùy có file không.
  2. Khi nào dùng Form<T> thay Json<T>: (a) HTML form submit browser native — traditional server-rendered template, không có JS framework, browser tự encode Content-Type đúng (admin dashboard login page, settings form internal tool, password reset form server-rendered); (b) Webhook legacy không support JSON — service partner cũ gửi event qua form-encoded payload với signature header riêng (Stripe webhook gửi event qua form-encoded payload với Stripe-Signature header HMAC SHA256); (c) Integration system cũ chỉ accept form data — legacy partner API B2B, third-party tool tài chính/banking không hiểu JSON. Shop API 2-3 use case cụ thể lock vĩnh viễn: (i) /admin/login (B105) — admin dashboard login HTML form server-rendered askama template, POST application/x-www-form-urlencoded với field email + password + remember + csrf_token, parse qua AppForm<LoginForm>, verify credentials, set PrivateCookieJar session cookie (lock B34), redirect /admin; (ii) /api/v1/webhooks/stripe (B197) — Stripe webhook event gửi qua form-encoded payload, verify HMAC SHA256 signature với raw body Bytes trước (lock B37), parse Form<StripeEvent> sau khi signature OK, idempotency check Redis idem:{key}; (iii) tiềm năng future /admin/products/upload dùng multipart cho file upload product image (B36) nhưng đó là multipart/form-data không phải urlencoded — handle qua Multipart extractor riêng. Decision rule chia API endpoint vs HTML form: /api/v1/* tất cả endpoint API client (mobile app iOS/Android, frontend SPA React/Vue, partner integration server-to-server) dùng Json<T> + AppJson<T> wrapper B32 + Bearer JWT auth lock B4 (stateless, mỗi request kèm JWT trong header, server verify signature + expiry không cần session storage lookup); /admin/* tất cả endpoint admin dashboard internal browser flow dùng Form<T> + AppForm<T> wrapper B35 + cookie session PrivateCookieJar (lock B34) + askama template render + CSRF protection MANDATORY (lock B107); /api/v1/webhooks/* tùy partner — Stripe form-encoded, GitHub JSON với signature, tách case-by-case. Lý do tách bạch: (1) cookie sinh ra cho browser context với origin-based security model, không phù hợp non-browser client; (2) JWT self-contained scale tốt không cần DB/Redis lookup mỗi request; (3) CSRF risk chỉ apply cho cookie endpoint, JWT không có (không tự động gửi); (4) middleware auth khác nhau (CSRF check chỉ cho cookie, rate-limit IP-based khác user-based); (5) template engine chỉ wire vào admin route, API client không cần render server-side; (6) infra hiện đại không phải support cả 2 encoding cho cùng endpoint giảm complexity.
  3. AppForm<T> wrapper map rejection FormRejection thành AppError theo pattern lock B32 vĩnh viễn align với AppJson<T>: (a) FormRejection::InvalidFormContentType(_)AppError::BadRequest("expected application/x-www-form-urlencoded") 400 (Content-Type sai hoặc thiếu); (b) FormRejection::FailedToDeserializeForm(err)AppError::Validation(format!("form validation failed: {}", err)) 422 (schema sai — field missing, type mismatch lúc deserialize struct); (c) FormRejection::FailedToDeserializeFormBody(err)AppError::Validation(format!("form body invalid: {}", err)) 422 (body parse fail lúc decode urlencoded); (d) FormRejection::BytesRejection(err)AppError::BadRequest(format!("body read error: {}", err)) 400 (body stream read fail — connection drop, timeout); (e) variant _ future-compat → AppError::BadRequest("invalid form") 400 (axum giữ quyền thêm variant ở minor version sau, wildcard đảm bảo wrapper không break khi upgrade). Phân biệt InvalidFormContentType vs FailedToDeserializeForm: (1) InvalidFormContentType xảy ra ở tầng transport/header — Content-Type header sai hoặc thiếu (vd client gửi application/json nhưng handler expect application/x-www-form-urlencoded, hoặc Content-Type missing hoàn toàn); axum reject trước khi đọc body, không parse gì cả; semantic "client gửi sai loại request" → 400 Bad Request (transport error); (2) FailedToDeserializeForm/FailedToDeserializeFormBody xảy ra ở tầng schema/validation — Content-Type đúng application/x-www-form-urlencoded, body decode urlencoded OK, nhưng map sang struct fail (field email required nhưng form gửi không có, hoặc field price type i64 nhưng form gửi string không phải số); semantic "server hiểu request nhưng data không hợp lệ" → 422 Unprocessable Entity (validation error) — align RFC 9110 mục 15.5.21. Tại sao map 400 thay 415 cho InvalidFormContentType: 415 Unsupported Media Type là default axum (semantic "server không hỗ trợ media type này"), về kỹ thuật đúng nhưng Shop API JSON-only policy lock B5 consistent — chỉ trả 400/422 cho client error parse, 415 trộn lẫn semantic gây client phải handle 3 status code parse error (400, 415, 422) thay 2; align với JsonRejection::MissingJsonContentType trong AppJson lock B32 cũng map 400 thay 415; client switch logic đơn giản hơn (400 = "fix request format/headers", 422 = "fix data schema"); machine-readable code envelope (BAD_REQUEST vs VALIDATION_FAILED) đủ phân biệt root cause khi debug.
  4. Template engine askama vs maud vs tera khác nhau: (a) Error thời điểm: askama compile-time (cargo build báo error nếu template syntax sai, field struct missing, variable không exist) — phát hiện sớm trước deploy; maud compile-time (macro html! expand tại compile, syntax sai Rust compile error) — cùng tier askama; tera runtime (template load lúc startup, error chỉ phát hiện khi request hit endpoint cụ thể) — deploy production mới phát hiện. (b) Performance: askama cao gần native code (template compile thành Rust function, render = function call thuần); maud cao gần native (macro expand thành write! calls compile-time); tera trung bình (interpret template tree mỗi request, parse cache nhưng vẫn slower native ~5-10x trên micro-benchmark). (c) Style code: askama Jinja2-like syntax ({% if %}, {{ var }}, {% for %}) trong file .html riêng, derive macro #[derive(Template)] + attribute #[template(path = "...")] bind struct field → variable template; maud macro html! inline trong Rust code (div { (name) }, @if cond { ... }), interpolate qua (expr), không file riêng; tera Jinja2-like syntax tương tự askama nhưng load runtime qua Tera::new("templates/**/*"). (d) Hot reload dev: askama KHÔNG (re-compile cargo run lại); maud KHÔNG (re-compile); tera CÓ (feature flag reload template từ disk khi đổi). Shop API chọn askama cho admin dashboard5 lý do: (1) Compile-time safe phù hợp production — error báo tại cargo build không phải runtime; admin dashboard handle revenue-critical action (product create, order status, inventory restock), không thể chấp nhận template render fail ở production gây 500 error cho admin user; (2) Performance cao — admin internal user concurrency thấp hơn API public nhưng vẫn cần render fast (dashboard page với 20+ widget, render < 50ms target); askama compile thành Rust function pure không phải interpret tree mỗi request; (3) Ecosystem mature 2026 — askama 0.12+ stable, askama_axum 0.4+ impl IntoResponse sẵn cho struct derive Template (handler return LoginTemplate { ... } trực tiếp không cần build response manual), ecosystem 5000+ download/tháng, maintain active GitHub; (4) Jinja2 syntax quen thuộc dev có background Python/Flask/Django dễ onboard ({% if %}, {{ var }}, {% for %} giống hệt Jinja2/Twig); designer/frontend có thể edit template không phải biết Rust (chỉ HTML + Jinja2 syntax cơ bản); (5) File .html tách bạch concern — template trong folder templates/admin/*.html, logic Rust trong routes/admin/*.rs, không mixed concern như maud; IDE syntax highlight HTML đúng (VSCode + Jinja2 extension), không phải parse macro Rust; refactor template không phải đụng code Rust handler. Trade-off chấp nhận: không hot reload dev (re-compile cargo run lại) — nhưng admin dashboard dev workflow ổn định không đổi template thường xuyên như SPA frontend. maud loại vì mixed concern (template + logic cùng file Rust dài 500+ dòng khó maintain) + designer không sửa được. tera loại vì error runtime risk + performance thấp + Shop API không cần hot reload dev đến mức phải hi sinh compile-time safety.
  5. Form HTML POST có CSRF risk — Cross-Site Request Forgery: attacker tạo trang evil.com với form ẩn action="https://shop.com/admin/products", admin đã đăng nhập shop.com truy cập evil.com (qua phishing email/link) → form auto-submit POST tới shop.com, browser tự động gửi cookie session theo cùng origin (browser security model dựa cookie domain) → server xử lý request như user hợp lệ → tạo product giả, thay đổi password, transfer tiền. Attack hoạt động được vì cookie tự động gửi với cross-site POST, server không phân biệt request từ form hợp lệ shop.com vs form giả evil.com — chỉ thấy cookie session đúng. 3 mitigation strategy MANDATORY cho Shop API lock vĩnh viễn: (i) SameSite=Strict cho CSRF cookie (lock B34 attribute) — modern browser tôn trọng SameSite directive: Strict chỉ gửi cookie với same-site request (cross-site bất kỳ context block hoàn toàn, kể cả click link từ email mở tab mới); browser tự động chống ở tầng dưới handler không phải code thêm; trade-off UX login flow có thể break (click email link không kèm cookie) — session cookie dùng Lax (gửi với top-level navigation), CSRF cookie dùng Strict; modern browser từ 2020 mặc định Lax nếu không khai báo. (ii) CSRF token hidden input trong form (synchronizer token pattern) — server sinh token random 32 bytes lúc render form, set vào SignedCookieJar (HMAC tamper-proof lock B34), render template với hidden input <input type="hidden" name="csrf_token" value="..." /> embed cùng token vào form; khi user submit, server verify form field csrf_token == cookie value; cookie và form field cùng nguồn server sinh = origin verify. (iii) SignedCookieJar HMAC tamper-proof (lock B34) — client đọc token được (cần thiết để JS echo header hoặc form hidden input) nhưng KHÔNG sửa được vì sửa value làm signature HMAC SHA256 không khớp với key server-side → server verify fail → jar.get() trả None như cookie không tồn tại; đảm bảo token integrity end-to-end. Double-submit pattern hoạt động ra sao: (1) Bước 1 — User GET /admin/login, server generate CSRF token random 32 bytes, set SignedCookieJar với key csrf_token value token, render template admin/login.html với hidden input embed cùng token; response trả về browser. (2) Bước 2 — Browser lưu cookie + render form với token visible trong HTML source. User điền credentials, submit form. Browser POST /admin/login kèm: cookie csrf_token=tk_abc.signature (browser tự gửi) + form field csrf_token=tk_abc (lấy từ hidden input). (3) Bước 3 — Server middleware verify: đọc cookie qua SignedCookieJar (HMAC verify tự động — nếu attacker sửa cookie value → jar.get() trả None → reject); đọc form field csrf_token; compare cookie value == form field value; match → ok, mismatch → reject 400. Tại sao attacker không thể bypass: attacker tạo form trên evil.com, browser sẽ tự gửi cookie csrf_token của shop.com theo cùng origin NẾU SameSite cho phép (vd Lax với top-level navigation) — nhưng attacker KHÔNG đọc được cookie value để echo vào form hidden field, vì browser same-origin policy block JS evil.com đọc cookie shop.com; attacker có thể guess token nhưng 32 bytes random = entropy 256 bit, brute force không feasible; SameSite=Strict cho CSRF cookie chặn thêm tầng nữa block cookie send với cross-site request hoàn toàn. Tổng hợp 3 strategy = defense in depth: SameSite block ở browser layer, double-submit verify ở server layer, SignedCookieJar đảm bảo token integrity không bị tamper. B107 implement đầy đủ middleware verify_csrf wire vào admin POST route, edge case body re-read sau verify (clone bytes hoặc Extension pass-through), header X-CSRF-Token alternative cho AJAX request thay form hidden input.
11

Bài Tiếp Theo

— chi tiết Multipart extractor cho file upload, stream từng field qua multipart.next_field().await, file size limit qua DefaultBodyLimit::max(N), content-type validation qua field.content_type() + magic bytes verify, save to disk hoặc S3-compatible storage (MinIO dev, AWS S3 prod), security pitfall (filename traversal, magic bytes verify cho ảnh thật không phải executable đổi extension), pattern Shop API admin upload product image qua POST /api/v1/admin/products/:id/image với AppMultipart wrapper.