Mục lục
- Mục Tiêu Bài Học
- Recap
IntoResponsePattern (B14) - Helper Builder:
Created,Accepted,NoContent,Ok - Tạo
responses/mod.rsVà Helpers - Update Handler Pattern (B13 Refresh)
- Vấn Đề Envelope Wrapper:
ApiResponse<T>Có Nên Dùng? - Pattern Headers Tùy Chỉnh: Trailing Custom Headers
- Tổng Kết Group 4 Foundation + Roadmap Group 5
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu pattern custom
IntoResponsecho struct riêng — recap B14 và mở rộng cho helper builder reusable cross-endpoint. - Implement 4 helper builder cho 4 response phổ biến Shop API:
Created<T>(201 + Location),Accepted<T>(202 async job),NoContent(204), optionalOk<T>. - Biết khi nào nên dùng tuple
(StatusCode, HeaderMap, Json<T>)ad-hoc 1 chỗ, khi nào nên tạo custom struct reusable cross-endpoint. - Quyết định
ApiResponse<T>envelope wrapper: dùng hay không cho Shop API — align với industry pattern. - Áp dụng pattern vào Shop API — handler return type clean, consistent, decision matrix G7+ rõ ràng cho mọi resource (Product, Order, Cart, Review, Payment, Address, Notification).
- HOÀN THÀNH Group 4 Extractors Và Response Sâu (10/10 bài) — sẵn sàng vào Group 5 JSON Body Streaming với validator crate B41, body size limit B47, compression B48, SSE B50.
Recap IntoResponse Pattern (B14)
Trait axum::response::IntoResponse cho phép convert một type bất kỳ thành http::Response<Body> mà axum gửi về client. axum cung cấp 8 built-in implementor đủ dùng cho 95% case: String / &'static str (text/plain), Html<T> (text/html), Json<T> (application/json), StatusCode, các tuple như (StatusCode, body), (StatusCode, HeaderMap, body), Response, và Result<T, E> với cả hai nhánh impl IntoResponse.
Tuple 3-element (StatusCode, HeaderMap, Json<T>) là pattern phổ biến cho POST tạo resource — 201 Created + header Location + JSON body entity vừa tạo (lock B14):
use axum::{http::{header, HeaderMap, HeaderValue, StatusCode}, Json};
async fn create_product_verbose() -> (StatusCode, HeaderMap, Json<ProductDto>) {
let product = ProductDto { /* ... */ };
let mut headers = HeaderMap::new();
let location = format!("/api/v1/products/{}", product.slug);
headers.insert(
header::LOCATION,
HeaderValue::from_str(&location).expect("valid header"),
);
(StatusCode::CREATED, headers, Json(product))
}
Vấn đề pattern tuple verbose: lặp lại 3-5 dòng dựng header trên mỗi POST endpoint Shop API tương lai (G7 B62 products, B105 register, B106 cart items, B115 orders, B135 admin products). Code đọc khó vì business logic (compute product) trộn với boilerplate (build header map). Solution: tạo helper struct riêng impl IntoResponse đóng gói boilerplate vào 1 chỗ, handler chỉ khai báo dữ liệu semantic.
Helper Builder: Created, Accepted, NoContent, Ok
Pattern lock cho mọi helper builder Shop API: (1) tạo struct wrap dữ liệu cần thiết, (2) impl IntoResponse dựng response từ struct, (3) handler trả struct trực tiếp.
Created<T> cho POST tạo resource (201 + Location header — RFC 9110 mục 10.2.2):
pub struct Created<T> {
pub location: String,
pub data: T,
}
impl<T: Serialize> IntoResponse for Created<T> {
fn into_response(self) -> Response {
let mut headers = HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(&self.location) {
headers.insert(header::LOCATION, value);
}
(StatusCode::CREATED, headers, Json(self.data)).into_response()
}
}
Accepted<T> cho POST async job (202 + body chứa job_id hoặc poll_url cho client poll status):
pub struct Accepted<T> {
pub data: T, // chứa job_id, poll_url
}
impl<T: Serialize> IntoResponse for Accepted<T> {
fn into_response(self) -> Response {
(StatusCode::ACCEPTED, Json(self.data)).into_response()
}
}
NoContent cho DELETE/PUT success không body (204 — RFC 9110 mục 15.3.5 cấm body trong 204, axum tự enforce):
pub struct NoContent;
impl IntoResponse for NoContent {
fn into_response(self) -> Response {
StatusCode::NO_CONTENT.into_response()
}
}
Ok<T> alternative optional: wrap đơn giản 200 + JSON body. Shop API KHÔNG implement helper riêng cho case này vì Json<T> mặc định axum đã đủ — chỉ tạo struct riêng khi cần thêm header metadata custom (vd X-Total-Count trên list endpoint, xem Bước 7).
Tạo responses/mod.rs Và Helpers
Folder crates/shop-api/src/responses/ đã pre-allocate placeholder từ B17 lock Project Structure, file mod.rs hiện chỉ có comment. B40 populate lần đầu — pattern giống middleware/ ở B39 và extractors/ ở B32:
// File: crates/shop-api/src/responses/mod.rs
pub mod helpers;
pub use helpers::{Accepted, Created, NoContent};
Re-export top-level cho handler import 1 dòng use crate::responses::Created; thay path dài. Tạo file mới crates/shop-api/src/responses/helpers.rs chứa 3 struct + impl:
// File: crates/shop-api/src/responses/helpers.rs
use axum::{
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
/// 201 Created + header `Location: <path>` + JSON body entity vừa tạo.
/// Lock B40 cho mọi POST tạo resource Shop API (products, cart items,
/// orders, register, admin uploads).
pub struct Created<T> {
pub location: String,
pub data: T,
}
impl<T: Serialize> IntoResponse for Created<T> {
fn into_response(self) -> Response {
let mut headers = HeaderMap::new();
// Defensive: HeaderValue::from_str fail nếu chứa control char (hiếm).
if let Ok(value) = HeaderValue::from_str(&self.location) {
headers.insert(header::LOCATION, value);
}
(StatusCode::CREATED, headers, Json(self.data)).into_response()
}
}
/// 202 Accepted + JSON body chứa job_id / poll_url cho client poll status.
/// Lock B40 cho POST async job Shop API (checkout, cancel order, restock).
pub struct Accepted<T> {
pub data: T,
}
impl<T: Serialize> IntoResponse for Accepted<T> {
fn into_response(self) -> Response {
(StatusCode::ACCEPTED, Json(self.data)).into_response()
}
}
/// 204 No Content — DELETE/PUT success không body.
/// RFC 9110 mục 15.3.5 cấm body trong 204; axum tự enforce.
pub struct NoContent;
impl IntoResponse for NoContent {
fn into_response(self) -> Response {
StatusCode::NO_CONTENT.into_response()
}
}
Module responses đã declared trong main.rs từ B17 (mod responses;) nên không cần thêm dòng nào. Compile cargo check -p shop-api pass — 3 struct sẵn sàng dùng cross-handler.
Update Handler Pattern (B13 Refresh)
Decision matrix B14 cho handler signature update mở rộng với 3 helper mới — lock vĩnh viễn từ G7 B62 CRUD Cơ Bản onward:
Pattern | Trước B40 (tuple verbose) | Sau B40 (helper builder)
---------------------+-----------------------------------------+----------------------------------
List | AppResult<Json<ListResponse<T>>> | (không đổi)
Read | AppResult<Json<T>> | (không đổi)
Create POST | AppResult<(StatusCode, HeaderMap, | AppResult<Created<T>>
| Json<T>)> |
Update PUT/PATCH | AppResult<Json<T>> | AppResult<Json<T>> hoặc
| hoặc AppResult<StatusCode> | AppResult<NoContent>
Delete | AppResult<StatusCode> | AppResult<NoContent>
Action async | AppResult<(StatusCode, Json<T>)> | AppResult<Accepted<T>>
Refactor handler create_product trong crates/shop-api/src/routes/products.rs (B21 skeleton hiện trả tuple 2-element placeholder) sang pattern struct:
// File: crates/shop-api/src/routes/products.rs (B40 refactor create_product)
use crate::{extractors::AppJson, responses::Created, state::AppState};
use axum::extract::State;
use shop_common::error::AppResult;
async fn create_product(
State(state): State<AppState>,
AppJson(dto): AppJson<CreateProductDto>,
) -> AppResult<Created<ProductDto>> {
let product = state.product_service.create(dto).await?;
Ok(Created {
location: format!("/api/v1/products/{}", product.slug),
data: product,
})
}
So sánh số dòng: pattern tuple verbose mất 3 dòng dựng HeaderMap + insert Location + return tuple; pattern struct chỉ 4 dòng business logic (await service + return Created struct). Lợi ích: (a) handler signature đọc semantic ngay — Created<ProductDto> nói rõ endpoint trả 201 không cần đọc body; (b) compiler ép set location field — quên field → compile error, không silent forget header; (c) test unit dễ — match struct field thay parse tuple element.
Pattern delete handler refactor tương tự — từ AppResult<StatusCode> sang AppResult<NoContent>:
use crate::responses::NoContent;
async fn delete_product(
State(state): State<AppState>,
AppPath(slug): AppPath<String>,
) -> AppResult<NoContent> {
state.product_service.delete(&slug).await?;
Ok(NoContent)
}
Action async handler checkout dùng Accepted<CheckoutResponse>:
use crate::responses::Accepted;
async fn checkout(
State(state): State<AppState>,
AppJson(dto): AppJson<CheckoutDto>,
) -> AppResult<Accepted<CheckoutResponse>> {
let response = state.checkout_service.start(dto).await?;
Ok(Accepted { data: response })
}
3 pattern lock G7+ vĩnh viễn cho Shop API — KHÔNG sáng tạo riêng từng resource.
Vấn Đề Envelope Wrapper: ApiResponse<T> Có Nên Dùng?
Một số API wrap mọi data response trong envelope ApiResponse<T> với metadata bao quanh:
// ANTI-PATTERN cho Shop API — đánh giá nhưng KHÔNG dùng.
pub struct ApiResponse<T> {
pub data: T,
pub meta: ResponseMeta,
}
pub struct ResponseMeta {
pub request_id: String,
pub version: String,
}
// Wire JSON output:
// { "data": { "id": 1, "name": "Laptop" }, "meta": { "request_id": "...", "version": "v1" } }
Pros của envelope wrapper:
- Metadata uniform mọi response (request_id, pagination info, deprecation notice).
- Future-proof: thêm field metadata tương lai không break client (client đọc
dataignore field mới trongmeta).
Cons của envelope wrapper:
- Verbose: client phải unwrap
datafield mỗi response — JS/TS code lặpconst product = response.data;60+ chỗ. - Inconsistent với industry pattern: Stripe và GitHub trả data trực tiếp KHÔNG envelope; JSON:API spec envelope nhưng heavy + dùng cho API ngoài domain SaaS không phù hợp Shop scope.
Shop API decision lock vĩnh viễn B40 — KHÔNG dùng ApiResponse envelope cho success response. Lý do cụ thể:
- Align Stripe / GitHub industry pattern — client SDK đơn giản, không phải custom code unwrap.
request_idđã enrich qua middleware B39 — chỉ inject vào error envelope không cần lặp trong success body; mỗi response đều có headerX-Request-Idecho cho client log correlation.- Pagination metadata đã có trong
ListResponse<T>lock B23 với fields{ items, total, page, size, hasNext }đủ cho client UI control phân trang — không cần wrap thêm tầngdata. - Error envelope giữ nguyên
{ error, code, request_id }lock B16 — vì debug distributed system cần correlation ID trong body để grep log khi user gửi screenshot error toast (header bị strip khi share screenshot).
Pattern handler Shop API lock G7+:
// LIST endpoint — Json<ListResponse<T>> trực tiếp KHÔNG cần ApiResponse<ListResponse<T>>
async fn list_products(/* ... */) -> AppResult<Json<ListResponse<ProductDto>>> {
let (items, total) = state.product_service.list(&pagination).await?;
Ok(Json(ListResponse::new(items, total, &pagination)))
}
// READ endpoint — Json<ProductDto> trực tiếp KHÔNG cần wrap data field
async fn get_product(/* ... */) -> AppResult<Json<ProductDto>> {
let product = state.product_service.get(&slug).await?;
Ok(Json(product))
}
JSON wire output trực tiếp data — client dùng ngay không transform:
GET /api/v1/products → 200 OK
{
"items": [...],
"total": 152,
"page": 1,
"size": 20,
"hasNext": true
}
GET /api/v1/products/laptop-xyz → 200 OK
{
"id": 1,
"slug": "laptop-xyz",
"name": "Laptop XYZ",
"price": "999.99"
}
Pattern Headers Tùy Chỉnh: Trailing Custom Headers
Use case mở rộng: list endpoint muốn expose X-Total-Count header bên cạnh body envelope — client UI pagination control có thể đọc nhanh tổng số record từ header HEAD request trước khi GET full body. Constant shop_common::headers::X_TOTAL_COUNT đã lock B4.
Pattern custom IntoResponse với headers tùy chỉnh:
// File: crates/shop-api/src/responses/helpers.rs (extend tương lai G14 admin)
use shop_common::pagination::{ListResponse, Pagination};
pub struct ListResponseWithHeaders<T> {
pub items: Vec<T>,
pub total: u64,
pub pagination: Pagination,
}
impl<T: Serialize> IntoResponse for ListResponseWithHeaders<T> {
fn into_response(self) -> Response {
let mut headers = HeaderMap::new();
headers.insert(
shop_common::headers::X_TOTAL_COUNT,
HeaderValue::from(self.total),
);
let body = ListResponse::new(self.items, self.total, &self.pagination);
(headers, Json(body)).into_response()
}
}
Sử dụng trong handler admin list endpoint G14:
async fn admin_list_orders(
State(state): State<AppState>,
AppQuery(pagination): AppQuery<Pagination>,
) -> AppResult<ListResponseWithHeaders<OrderDto>> {
let (items, total) = state.order_service.list(&pagination).await?;
Ok(ListResponseWithHeaders {
items,
total,
pagination,
})
}
Pattern preview cho admin endpoint G14 — public endpoint vẫn dùng Json<ListResponse<T>> default. Lý do tách: admin UI thường có pagination component phức tạp cần đọc tổng count nhanh qua HEAD request, public API client thường chỉ render list không cần header riêng.
Tổng Kết Group 4 Foundation + Roadmap Group 5
Group 4 Extractors Và Response Sâu (10/10 bài) đã cover:
- B31 extractor-trait — trait hierarchy
FromRequestParts(không consume body, đặt đầu) vsFromRequest(consume body, đặt cuối). - B32 custom-extractor — 3 wrapper extractor Shop API
AppPath<T>/AppQuery<T>/AppJson<T>map rejection chuẩn envelopeAppError. - B33 header-extractor-typed —
TypedHeader<T>cho Authorization, ContentType, UserAgent validated tại compile-time. - B34 cookie-extractor — 3-tier strategy Plain / Signed (HMAC) / Private (encrypted) với key management lock B106.
- B35 form-extractor —
Form<T>cho admin login + Stripe webhook. - B36 multipart-upload — file upload streaming với size limit + content-type check.
- B37 raw-body-bytes —
Bytesextractor cho webhook signature verify (Stripe, GitHub). - B38 streaming-body — stream upload large file + impl
Streamcho download với backpressure. - B39 extension-extractor — request-scoped data
RequestIdqua middleware + error envelope enrich pattern. - B40 response-builder-pattern — 4 helper builder
Created/Accepted/NoContentcustomIntoResponse.
Foundation đã ready cho Group 5+:
- DTO design pattern lock B15 — Group 5 JSON Body Streaming sẽ implement DTO đầu tiên.
- Database integration — Group 6 PostgreSQL + sqlx kết nối
AppState::pool. - CRUD implementation — Group 7 dùng decision matrix B14 + B40 handler signature.
- Auth — Group 11-14 với
CurrentUserExtension pattern preview B39. - Middleware ecosystem — Group 15+ với
tower-http::TraceLayertrên top middlewarerequest_idđã có B39.
Group 5 JSON Body Streaming (B41-B50) sẽ cover:
- JSON extract + validation với
validatorcrate (B41) — derive(Validate) cho DTO Shop APICreateProductDto. - Field optional / default / null pitfall (B42) —
Option<T>vs#[serde(default)]vsskip_serializing_if. - Enum tagged / untagged / adjacent (B43) — API polymorphism design.
- DateTime / UUID / Decimal trong JSON (B44) — chrono ISO 8601, uuid string, rust_decimal cho money precision.
- Custom serializer / deserializer (B45) — serde_with crate helpers.
- Compression (B48) — gzip / deflate / brotli qua tower-http
CompressionLayer. - SSE Server-Sent Events đầy đủ (B50) — order status broadcast real-time.
Pattern handler từ G5+ MANDATORY: dùng AppJson<T> (B32) + Created<T> / NoContent / Accepted<T> (B40) — KHÔNG sáng tạo riêng từng resource.
Suggested verify Group 4 foundation đầy đủ chạy local:
cd shop && cargo run -p shop-api
# Output: shop-api listening addr=0.0.0.0:3000
# Test create endpoint với Created<ProductDto> (sau khi G7 implement service):
curl -i -X POST http://localhost:3000/api/v1/admin/products \
-H 'Content-Type: application/json' \
-d '{"name":"Laptop","slug":"laptop-xyz","price":"999.99"}'
# HTTP/1.1 201 Created
# location: /api/v1/products/laptop-xyz
# x-request-id: 550e8400-e29b-41d4-a716-446655440000
# content-type: application/json
#
# {"id":1,"slug":"laptop-xyz","name":"Laptop","price":"999.99"}
Tổng Kết
- Pattern custom
IntoResponsecho struct riêng — recap B14, mở rộng cho helper builder reusable cross-endpoint. - 4 helper builder lock vĩnh viễn:
Created<T>(201 + Location),Accepted<T>(202 async job),NoContent(204), optionalOk<T>không implement. - File path lock:
crates/shop-api/src/responses/mod.rs(re-export top-level) +crates/shop-api/src/responses/helpers.rs(3 struct + impl). - Handler refactor: tuple
(StatusCode, HeaderMap, Json<T>)verbose →Created<T>clean — 3 dòng boilerplate giảm còn 4 dòng business logic semantic rõ. - ApiResponse envelope decision: Shop API KHÔNG dùng wrapper
{ data, meta }cho success response — align Stripe / GitHub industry pattern, client SDK đơn giản. - Pagination metadata đã có trong
ListResponse<T>lock B23 với{ items, total, page, size, hasNext }— không cần wrap thêm. request_idenrich qua middleware B39 — chỉ inject vào error envelope không lặp trong success body; headerX-Request-Idecho trên mọi response.- Error envelope giữ nguyên
{ error, code, request_id }lock B16 vì debug distributed system cần correlation ID trong body. - Pattern custom
IntoResponsecho headers:ListResponseWithHeaders<T>exposeX-Total-Countlock B4 cho admin endpoint G14 — public endpoint vẫn dùngJson<ListResponse<T>>default. - Decision matrix handler signature update G7+: list
Json<ListResponse<T>>, readJson<T>, createCreated<T>, deleteNoContent, action asyncAccepted<T>. - HOÀN THÀNH Group 4 Extractors Và Response Sâu (10/10 bài) — foundation extractor + response builder sẵn sàng cho G5 JSON Body Streaming B41 validator + B47 body limit + B48 compression + B50 SSE.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Pattern custom
IntoResponsegồm 2 bước nào? Trait method nào bắt buộc impl? Trait bound nào cần thiết trên type T generic? Created<T>helper builder làm gì? Constructor signature như thế nào? Khi nào set headerLocationbị fail và mechanism defensive xử lý ra sao?- Shop API KHÔNG dùng
ApiResponseenvelope wrapper cho success response. Lý do và 3 nguồn metadata thay thế (industry pattern, request_id, pagination)? - Refactor handler
create_producttừ pattern tuple verbose(StatusCode, HeaderMap, Json<T>)sang pattern structCreated<T>. So sánh số dòng code và lợi ích cụ thể. - List endpoint muốn expose
X-Total-Countheader bên cạnh body envelope. Pattern customIntoResponsevới struct riêng hay tuple ad-hoc inline? Lý do và use case cụ thể public vs admin endpoint.
Đáp án
- Pattern custom
IntoResponse2 bước MANDATORY: (1) Tạo struct wrap dữ liệu cần thiết — vdpub struct Created<T> { pub location: String, pub data: T }chứa đúng field cần dựng response (header value + body data); chỉ public field cần handler gán từ ngoài, internal logic giấu trong impl. (2) ImplIntoResponsetrait với 1 method bắt buộcfn into_response(self) -> Response— consumeselfby value (không borrow vì response build xong, struct drop), trảaxum::response::Response(aliashttp::Response<Body>). Body method thường dùng pattern delegate qua built-in tuple impl: dựngHeaderMap, gọi(StatusCode::CREATED, headers, Json(self.data)).into_response()tận dụng impl tuple 3-element axum sẵn có thay vì manual build từResponse::builder()verbose. Trait bound trên T generic: thường chỉ cầnT: Serializenếu body là JSON (vìJson<T>requireT: Serialize); KHÔNG cầnSend + Sync + 'staticvìinto_responseconsume self KHÔNG await point. Nếu type chứa data không cần serialize (vdNoContentunit struct) thì KHÔNG cần generic + KHÔNG cần bound nào — impl thẳng cho struct cụ thể. Edge case khi cần custom header value động: dùngHeaderValue::from_str(&value)trả Result defensive — chainif let Ok(v) = HeaderValue::from_str(...) { headers.insert(name, v); }bỏ qua silent nếu fail (control char rare) thay.unwrap()panic crash server. Pattern lock cho mọi helper builder Shop API tương lai (Created/Accepted/NoContent B40, ListResponseWithHeaders B40 preview G14, futureRedirectcustom với cookie set). Created<T>helper builder: struct generic wrap 2 field —pub location: Stringchứa path URL của resource vừa tạo (vd/api/v1/products/laptop-xyz, RFC 9110 mục 10.2.2 mandate headerLocationcho response 201 Created),pub data: Tchứa entity vừa tạo serialize JSON. Constructor signature: thực tế struct dùng literal syntax không có constructor riêng —Created { location: format!("/api/v1/products/{}", product.slug), data: product }trực tiếp gán field public; pattern này lock cho mọi helper builder Shop API vì 2-3 field đơn giản không cần helperfn new(...)wrap. ImplIntoResponse for Created<T> where T: Serialize3 bước: (a)let mut headers = HeaderMap::new()empty map, (b) parseHeaderValue::from_str(&self.location)trảResult<HeaderValue, InvalidHeaderValue>+ chainif let Ok(value) = ... { headers.insert(header::LOCATION, value); }, (c) return tuple(StatusCode::CREATED, headers, Json(self.data)).into_response()delegate tuple 3-element impl. Khi nàoHeaderValue::from_strbị fail: input chứa control character không in được như\n(byte 0x0A),\r(0x0D), null byte (0x00), hoặc byte ngoài range visible ASCII 0x20-0x7E + extended Latin-1 0xA0-0xFF (RFC 9110 mục 5.1 quy định visible character). Use case fail thực tế hiếm: location path Shop API luôn là URL slug ASCII format/api/v1/products/<slug>với slug chỉ chứaa-z0-9-; nhưng nếu slug được generate từ input user chứa Unicode không escape (vd tên sản phẩm tiếng Việt chưa slugify) thì fail. Mechanism defensive:if let Ok(value) = HeaderValue::from_str(&self.location) { ... }— fail thì bỏ qua silent không insert header (response vẫn 201 + body OK chỉ thiếuLocationheader), KHÔNG.unwrap()panic crash server. Alternative defensive nâng cao: log warningtracing::warn!(location = %self.location, "invalid Location header skipped")để dev phát hiện slug generator có bug; B40 chưa add log để giữ helper minimal, có thể extend G15 observability layer. Pattern locked vĩnh viễn cho mọi POST tạo resource Shop API (G7 B62 products, B105 register, B106 cart items, B115 orders, B135 admin products).- Shop API KHÔNG dùng
ApiResponseenvelope wrapper dạng{ data: T, meta: ResponseMeta }cho success response. Lý do chính: (a) Verbose client side — JS/TS code phải unwrapconst product = response.data;60+ chỗ qua mọi endpoint, lặp boilerplate vô nghĩa khi response chỉ trả 1 entity duy nhất; (b) Inconsistent với industry pattern dominant — Stripe API + GitHub API + Twitter API + Shopify API đều trả data trực tiếp KHÔNG envelope (Stripe chỉ envelope cho list endpoint với{ object: "list", data: [...], has_more: bool }nhưng đó là pagination metadata cụ thể không phải metadata generic); JSON:API spec envelope{ data, included, meta, links }heavy phù hợp API ngoài domain SaaS phức tạp không phù hợp scope Shop e-commerce; (c) Không có giá trị thêm — metadata duy nhất Shop API có thể put vàometalàrequest_idvàversionnhưng cả hai đã có cách khác đơn giản hơn. 3 nguồn metadata thay thế đã đủ: (1) Industry pattern Stripe / GitHub — trả data trực tiếp{ id, slug, name, price, ... }trong success response, client SDK đơn giản không transform;listendpoint dùng struct riêngListResponse<T>với{ items, total, page, size, hasNext }chứa cả data + pagination metadata trong 1 struct flat (lock B23). (2)request_idqua middleware B39 —request_id_middlewareset ExtensionRequestId(String)per request, echo response headerX-Request-Id: <uuid>trên MỌI response (success + error), middlewareenrich_error_responsechỉ inject vào error envelope JSON body{ error, code, request_id }vì debug error cần correlation ID grep log khi user gửi screenshot toast (header bị strip khi share screenshot); success response client log headerX-Request-Idkhông cần lặp trong body data (giảm payload + tránh leak internal trace ID vào business logic). (3) Pagination metadata trongListResponse<T>lock B23 — struct{ items: Vec<T>, total: u64, page: u32, size: u32, has_next: bool (rename "hasNext") }chứa cả data items lẫn pagination metadata trong 1 envelope flat, client deserialize 1 lần đọc cả 2 phần KHÔNG cần unwrap nesteddata.items+meta.total. Error envelope ngoại lệ giữ{ error, code, request_id }lock B16 vì error path cần debug distributed system: user report bug → support ask request_id → grep log cross-service (frontend log + API log + worker log) cùng correlation ID; fieldrequest_idtrong body vì user share screenshot error toast thường chỉ thấy message + code KHÔNG thấy header (browser DevTools không phải user thường mở). Quyết định lock vĩnh viễn cho mọi resource Shop API tương lai (Product, Order, Cart, Review, Payment, Address, Notification, AuditLog, Idempotency) — KHÔNG sáng tạo envelope riêng từng resource. - Refactor handler
create_producttuple verbose → structCreated<T>. BEFORE (B14 pattern tuple verbose ~3 dòng boilerplate + 2 dòng business):async fn create_product(State(state): State<AppState>, AppJson(dto): AppJson<CreateProductDto>) -> AppResult<(StatusCode, HeaderMap, Json<ProductDto>)> { let product = state.product_service.create(dto).await?; let mut headers = HeaderMap::new(); let location = format!("/api/v1/products/{}", product.slug); headers.insert(header::LOCATION, HeaderValue::from_str(&location).expect("valid")); Ok((StatusCode::CREATED, headers, Json(product))) }— 5 dòng tổng (3 dòng dựng HeaderMap manual + 2 dòng business logic + return tuple). AFTER (B40 pattern struct ~4 dòng business):async fn create_product(State(state): State<AppState>, AppJson(dto): AppJson<CreateProductDto>) -> AppResult<Created<ProductDto>> { let product = state.product_service.create(dto).await?; Ok(Created { location: format!("/api/v1/products/{}", product.slug), data: product }) }— 4 dòng (1 dòng business await + 3 dòng struct literal — KHÔNG có dòng dựng HeaderMap manual). Giảm ~40% line count (5 → 3 dòng nội dung) + giảm 100% boilerplate header build. Lợi ích cụ thể: (a) Handler signature đọc semantic ngay — return typeAppResult<Created<ProductDto>>nói rõ endpoint POST trả 201 Created với entity ProductDto, không cần đọc body để biết status code; tuple(StatusCode, HeaderMap, Json<T>)ngược lại không nói gì semantic — reviewer phải đọc body để xác định status nào được set. (b) Compiler ép setlocationfield — struct literalCreated { data: product }thiếulocationsẽ compile errormissing field 'location' in initializer of 'Created<_>'; pattern tuple không ép set header — quên insertLocationcompile vẫn pass nhưng runtime trả 201 không có Location vi phạm RFC 9110 mục 10.2.2 silent bug khó phát hiện. (c) Test unit dễ — match struct fieldlet created = handler(...).await.unwrap(); assert_eq!(created.location, "/api/v1/products/laptop-xyz"); assert_eq!(created.data.slug, "laptop-xyz");rõ ràng; pattern tuple phảilet (status, headers, Json(data)) = handler(...).await.unwrap(); assert_eq!(status, StatusCode::CREATED); assert_eq!(headers.get("location").unwrap(), "..."); assert_eq!(data.slug, "...");verbose hơn. (d) Refactor an toàn — đổi format URL location vd thêm prefix/v2/products/...chỉ sửa 1 chỗformat!trong helper hoặc handler, pattern tuple phải sửa mọi POST handler. (e) Reuse cross-endpoint —Created<T>dùng được cho 10+ POST endpoint Shop API (products, orders, cart items, register, addresses, reviews, admin products, admin categories, admin uploads, webhooks); pattern tuple phải lặp boilerplate trên mỗi handler. Pattern lock vĩnh viễn G7+ Shop API. - List endpoint expose
X-Total-Countheader — pattern customIntoResponsevới struct riêngListResponseWithHeaders<T>, KHÔNG dùng tuple ad-hoc inline. Pattern struct:pub struct ListResponseWithHeaders<T> { pub items: Vec<T>, pub total: u64, pub pagination: Pagination }+impl<T: Serialize> IntoResponse for ListResponseWithHeaders<T> { fn into_response(self) -> Response { let mut headers = HeaderMap::new(); headers.insert(shop_common::headers::X_TOTAL_COUNT, HeaderValue::from(self.total)); let body = ListResponse::new(self.items, self.total, &self.pagination); (headers, Json(body)).into_response() } }. Handler dùngasync fn admin_list_orders(...) -> AppResult<ListResponseWithHeaders<OrderDto>> { Ok(ListResponseWithHeaders { items, total, pagination }) }clean 1 dòng return. Lý do KHÔNG dùng tuple ad-hoc inline: tuple(HeaderMap, Json<ListResponse<T>>)phải dựngHeaderMap+ insertX_TOTAL_COUNT+ buildListResponse::new(...)body manual trong MỖI handler → lặp boilerplate 5+ admin list endpoint G14 (orders, users, products, categories, reviews) → vi phạm DRY → bug-prone nếu quên insert header trên 1 endpoint silent inconsistent. Custom struct tập trung logic build header + body trong 1 chỗ — sửa format header 1 lần áp dụng toàn bộ. Constant header reuse:shop_common::headers::X_TOTAL_COUNTlock B4 dùng cross-handler thay hard-code string literal"x-total-count"tránh typo (chữxcap vs lower) + dễ rename centralized. Use case cụ thể public vs admin: (a) Public endpointGET /api/v1/products— client SPA React/Next render product grid + pagination button → đọctotal+hasNexttừ body envelopeListResponse<T>đã đủ, không cần header riêng → dùngJson<ListResponse<ProductDto>>default đơn giản. (b) Admin endpointGET /api/v1/admin/orders— admin dashboard có pagination component phức tạp (jump to page N, infinite scroll preload count) cần đọctotalnhanh qua HEAD request KHÔNG download body (vd admin xem dashboard có 50k orders không muốn fetch hết 50k record chỉ để biết total); HEAD request trả status + headers không body →X-Total-Countheader cần thiết. (c) Pattern hybrid — admin endpoint trả CẢ headerX-Total-CountCẢ body envelopeListResponse<T>chứatotal— duplicate intentional vì 2 use case khác nhau (HEAD vs GET full); KHÔNG vi phạm DRY vì 2 channel serving 2 client pattern khác nhau. (d) Mở rộng tương lai —ListResponseWithHeaderscó thể extend thêm headerLink: <next-page-url>; rel="next"(GitHub-style pagination cursor B96 G10),X-RateLimit-Remainingcho admin endpoint rate-limit G17 — vẫn 1 struct, sửa 1 chỗ impl. Pattern lock B40 cho admin endpoint G14, public endpoint giữJson<ListResponse<T>>default. Documentation OpenAPI utoipa B8 cần document headerX-Total-Counttrong response schema để client biết tận dụng — sẽ wire ở G14 admin module.
Bài Tiếp Theo
Bài 41: JSON Extract + Validation Với validator Crate — mở Group 5 JSON Body Streaming: chi tiết validator crate, #[derive(Validate)] trên DTO, field rule (email / length / regex / range / custom), manual ValidatedJson<T> wrapper extractor compose AppJson + validate sau parse, 422 Unprocessable Entity error response với field-level error details, áp dụng vào DTO Shop API đầu tiên CreateProductDto trong crates/shop-api/src/dto/product.rs.
