Mục lục
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, formatkey=value&key2=value2percent-encoded trong body) vớiapplication/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ầnaxum-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) vsJson<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:InvalidFormContentType→BadRequest400,FailedToDeserializeForm→Validation422 (alignJsonDataErrorpattern). - 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.
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.
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ơnf: Form<LoginForm>rồif.0, đồng nhất vớiPath/Jsonlock B22/B15. - Đặt CUỐI arg list theo lock B31 —
Form<T>implFromRequest(consume body one-shot), KHÔNG implFromRequestParts. Mỗi handler tối đa 1 body extractor;Form+Jsoncùng handler → compile error trait bound conflict. - Content-Type bắt buộc
application/x-www-form-urlencoded— request gửiapplication/jsonhoặc thiếu Content-Type → axum reject vớiFormRejection::InvalidFormContentType+ default 415 Unsupported Media Type + body plain text. #[serde(default)]cho field optional (vdremembercheckbox 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ànhuser.name=xhoặcuser[name]=x(tùy convention crate —serde_urlencodeddefault flat, không hiểu bracket; cầnserde_qscho 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.
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-Signatureriê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/login→Form<T>+AppForm<T>wrapper B35, render askama template + cookie session./api/v1/webhooks/stripe→Form<T>hoặcBytesraw (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.
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ÔNGFromRequestParts) vìForm<T>consume body — đặt CUỐI arg list theo lock B31. BỏT: Sendso vớiAppPath/AppQuery(giốngAppJsonlock 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 sangAppError. InvalidFormContentType→BadRequest400 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/FailedToDeserializeFormBody→Validation422 — alignJsonDataErrorpattern 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ớiBytesRejection(body read fail → 400) là cú pháp/transport vs schema validation.- Helper function module-level
fn map_form_rejectiontá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.
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>
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.comvớ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ớishop.com→ browser tự động gửi cookie session theo cùng origin (nếu không cóSameSiteprotection) → server xử lý request như user hợp lệ → tạo product giả.
3 protection strategy lock vĩnh viễn Shop API:
SameSite=Strictcho CSRF cookie (lock B34 attribute) — browser block cookie send với cross-site request. Modern browser từ 2020 mặc địnhLaxnế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 fieldcsrf_token== cookie value (double-submit pattern). Attacker không đọc được cookie cross-origin → không thể echo token vào form giả. SignedCookieJarHMAC 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).
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 + checkfield.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-s3hoặcrusty-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).
Tổng Kết
axum::Form<T>extract body Content-Typeapplication/x-www-form-urlencodedtừ HTML form submit native browser;TimplDeserializeparse quaserde_urlencoded.- Body extractor đặt CUỐI arg list theo lock B31 (FromRequest consume body one-shot, chỉ 1 body extractor / handler —
Form+Jsoncù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/login→Form<T>+ cookie session (lock B34);/api/v1/webhooks/stripe→Form<T>hoặcBytesraw (tùy signature method B197). AppForm<T>wrapper pattern theo B32 — file path lock vĩnh viễncrates/shop-api/src/extractors/form.rs(B105 tạo cùng đợt admin login). ImplFromRequest<S> for AppForm<T>delegateForm::<T>::from_request+ match map rejection sangAppError.- Rejection mapping lock vĩnh viễn align
AppJsonB32:InvalidFormContentType→BadRequest400 (map 400 thay 415 cho consistency JSON-only B5),FailedToDeserializeForm/FailedToDeserializeFormBody→Validation422 (alignJsonDataError),BytesRejection→BadRequest400, variant_future-compat →BadRequest400. - 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_axumwireIntoResponse), 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=Strictcho CSRF cookie (B34), CSRF token hidden input form server verify match cookie value (double-submit pattern B107),SignedCookieJarHMAC 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ầnMultipartextractor 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+ templatetemplates/admin/login.htmlsẽ tạo ở B105 (admin login implement) khi infra Redis client + cookie key wire vào AppState.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Phân biệt
application/x-www-form-urlencodedvàapplication/json. Format body khác gì? Browser submit form HTML mặc định Content-Type nào? - Khi nào dùng
Form<T>thayJson<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? AppForm<T>wrapper map rejectionFormRejectionthànhAppErrornào? Phân biệtInvalidFormContentTypevsFailedToDeserializeForm? Tại sao map 400 thay 415 choInvalidFormContentType?- 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?
- 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
- Phân biệt
application/x-www-form-urlencodedvàapplication/json: (a) Format body — urlencoded có formatkey=value&key2=value2flat key-value pairs percent-encoded theo RFC 3986 (vdemail=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 →%20hoặc+,&→%26), browser tự encode khi submit form; JSON UTF-8 string với escape sequence (\",\n), client (JS, mobile) tự serialize quaJSON.stringifyhoặc thư viện built-in. (c) Nested support — urlencoded KHÔNG support nested object (chỉ flat), bracket syntaxuser[name]=xphụ thuộc convention crate (serde_urlencodeddefault flat không hiểu bracket, cầnserde_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-urlencodedkhi form không khai báoenctypehoặcenctype="application/x-www-form-urlencoded". Khi form có<input type="file">hoặc khai báoenctype="multipart/form-data"đổi sangmultipart/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 JSfetch/XMLHttpRequestdev tự set Content-Type tùy chọn — modern SPA dùngapplication/jsonvớiJSON.stringify(body), traditional form submit quaFormDatagiữ urlencoded hoặc multipart tùy có file không. - Khi nào dùng
Form<T>thayJson<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ớiStripe-Signatureheader 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, POSTapplication/x-www-form-urlencodedvới fieldemail + password + remember + csrf_token, parse quaAppForm<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 bodyBytestrước (lock B37), parseForm<StripeEvent>sau khi signature OK, idempotency check Redisidem:{key}; (iii) tiềm năng future/admin/products/uploaddùng multipart cho file upload product image (B36) nhưng đó làmultipart/form-datakhông phải urlencoded — handle quaMultipartextractor 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ùngJson<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ùngForm<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. AppForm<T>wrapper map rejectionFormRejectionthànhAppErrortheo pattern lock B32 vĩnh viễn align vớiAppJson<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ệtInvalidFormContentTypevsFailedToDeserializeForm: (1)InvalidFormContentTypexảy ra ở tầng transport/header — Content-Type header sai hoặc thiếu (vd client gửiapplication/jsonnhưng handler expectapplication/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/FailedToDeserializeFormBodyxảy ra ở tầng schema/validation — Content-Type đúngapplication/x-www-form-urlencoded, body decode urlencoded OK, nhưng map sang struct fail (fieldemailrequired nhưng form gửi không có, hoặc fieldpricetypei64như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 choInvalidFormContentType: 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ớiJsonRejection::MissingJsonContentTypetrongAppJsonlock 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_REQUESTvsVALIDATION_FAILED) đủ phân biệt root cause khi debug.- 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ànhwrite!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.htmlriêng, derive macro#[derive(Template)]+ attribute#[template(path = "...")]bind struct field → variable template; maud macrohtml!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 quaTera::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 dashboard — 5 lý do: (1) Compile-time safe phù hợp production — error báo tạicargo buildkhô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_axum0.4+ implIntoResponsesẵn cho struct deriveTemplate(handler returnLoginTemplate { ... }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.htmltách bạch concern — template trong foldertemplates/admin/*.html, logic Rust trongroutes/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. - Form HTML POST có CSRF risk — Cross-Site Request Forgery: attacker tạo trang
evil.comvới form ẩnaction="https://shop.com/admin/products", admin đã đăng nhậpshop.comtruy cậpevil.com(qua phishing email/link) → form auto-submit POST tớishop.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=Strictcho CSRF cookie (lock B34 attribute) — modern browser tôn trọngSameSitedirective:Strictchỉ 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ùngLax(gửi với top-level navigation), CSRF cookie dùngStrict; modern browser từ 2020 mặc địnhLaxnế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àoSignedCookieJar(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 fieldcsrf_token== cookie value; cookie và form field cùng nguồn server sinh = origin verify. (iii)SignedCookieJarHMAC 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ảNonenhư 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, setSignedCookieJarvới keycsrf_tokenvalue token, render templateadmin/login.htmlvớ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/loginkèm: cookiecsrf_token=tk_abc.signature(browser tự gửi) + form fieldcsrf_token=tk_abc(lấy từ hidden input). (3) Bước 3 — Server middleware verify: đọc cookie quaSignedCookieJar(HMAC verify tự động — nếu attacker sửa cookie value →jar.get()trảNone→ reject); đọc form fieldcsrf_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ênevil.com, browser sẽ tự gửi cookiecsrf_tokencủashop.comtheo cùng origin NẾUSameSitecho phép (vdLaxvớ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 JSevil.comđọc cookieshop.com; attacker có thể guess token nhưng 32 bytes random = entropy 256 bit, brute force không feasible;SameSite=Strictcho 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:SameSiteblock ở browser layer, double-submit verify ở server layer,SignedCookieJarđảm bảo token integrity không bị tamper. B107 implement đầy đủ middlewareverify_csrfwire vào admin POST route, edge case body re-read sau verify (clone bytes hoặcExtensionpass-through), headerX-CSRF-Tokenalternative cho AJAX request thay form hidden input.
Bài Tiếp Theo
Bài 36: Multipart Upload: File + Field — 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.
