Danh sách bài viết

Bài 40: Response Builder — Custom IntoResponse

Bài 40 của series Rust RESTful API — bài CUỐI Group 4 Extractors Và Response Sâu — implement 4 helper builder Created<T> / Accepted<T> / NoContent + optional Ok<T> trong file mới crates/shop-api/src/responses/helpers.rs + re-export top-level qua crates/shop-api/src/responses/mod.rs (folder pre-allocate placeholder từ B17 lock Project Structure giờ populate lần đầu), mỗi helper là một struct riêng impl IntoResponse đóng gói tuple (StatusCode, HeaderMap, Json<T>) 3-element verbose lock B14 thành 1 type clean dùng được cross-endpoint không lặp boilerplate; cốt lõi pattern: thay vì handler trả AppResult<(StatusCode, HeaderMap, Json<ProductDto>)> 3 dòng dựng tuple manual mỗi POST endpoint, dùng AppResult<Created<ProductDto>> 1 dòng trả Ok(Created { location: format!("/api/v1/products/{slug}"), data: product }) compiler tự lo set status + header + serialize body. Created<T> { location: String, data: T } cho POST tạo resource 201 + header Location RFC 9110 mục 10.2.2 (lock B3 cho create endpoint Shop API như POST /api/v1/products, POST /api/v1/auth/register, POST /api/v1/cart/items); impl IntoResponse for Created<T> where T: Serialize build HeaderMap insert Location: self.location defensive qua HeaderValue::from_str fail silent nếu control char, return tuple (StatusCode::CREATED, headers, Json(self.data)).into_response() tận dụng axum impl tuple 3-element sẵn có. Accepted<T> { data: T } cho POST async job 202 (lock B14 action async pattern) dùng cho endpoint Shop API như POST /api/v1/checkout trả CheckoutResponse { job_id, poll_url }, POST /api/v1/orders/:id/cancel trả CancelResponse { job_id }, POST /api/v1/admin/inventory/restock trả JobResponse; impl IntoResponse trả tuple 2-element (StatusCode::ACCEPTED, Json(self.data)) KHÔNG có header riêng (job_id nằm trong body cho client poll). NoContent unit struct cho DELETE/PUT success không body 204 (lock B14 + RFC 9110 mục 15.3.5 cấm body trong 204) dùng cho DELETE /api/v1/products/:slug, DELETE /api/v1/cart/items/:item_id, PUT /api/v1/me (update profile không cần trả entity); impl IntoResponse trả thẳng StatusCode::NO_CONTENT.into_response() — axum auto enforce KHÔNG trộn body vào 204. Ok<T> alternative optional wrap đơn giản 200 + Json data — Shop API KHÔNG implement helper này riêng vì Json<T> mặc định axum đã đủ cho 200 OK + body; chỉ tạo helper khi cần thêm header metadata custom (vd X-Total-Count trên list endpoint). Refactor handler create_product trong crates/shop-api/src/routes/products.rs (B21 skeleton hiện trả tuple 2-element (StatusCode::CREATED, Json(json!(...))) placeholder) từ pattern verbose tuple sang pattern struct: async fn create_product(State(state), AppJson(dto)) -> AppResult<Created<ProductDto>> trả Ok(Created { location: format!("/api/v1/products/{}", product.slug), data: product }) — giảm 3 dòng build tuple manual còn 4 dòng business logic semantic rõ ràng; pattern này lock G7+ B62 Create Resource cho mọi POST endpoint Shop API tương lai. Decision matrix handler signature update lock G7+ (mở rộng B14): list AppResult<Json<ListResponse<T>>>, read AppResult<Json<T>>, create AppResult<Created<T>> thay tuple, update PUT/PATCH AppResult<Json<T>> hoặc AppResult<NoContent>, delete AppResult<NoContent> thay AppResult<StatusCode> (semantic rõ hơn), action async AppResult<Accepted<T>> thay tuple 2-element verbose. Quyết định ApiResponse<T> envelope wrapper KHÔNG DÙNG cho success response lock vĩnh viễn B40 (mở rộng B14 evaluate): một số API wrap data trong envelope { data: ..., meta: { request_id, version } } nhưng Shop API align Stripe / GitHub industry pattern trả data trực tiếp không wrap; lý do (a) client đơn giản không phải unwrap data field mỗi response, (b) request_id đã enrich qua middleware B39 chỉ inject vào error envelope không cần lặp trong success body, (c) pagination metadata đã có trong ListResponse<T> lock B23 với fields { items, total, page, size, hasNext } đủ cho client UI control phân trang, (d) JSON:API spec envelope heavy cho API ngoài domain SaaS không phù hợp scope Shop API; 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. Custom IntoResponse cho headers pattern ListResponseWithHeaders<T>: use case list endpoint muốn expose X-Total-Count header (constant shop_common::headers::X_TOTAL_COUNT lock B4) cho client UI pagination control bên cạnh body envelope; struct chứa items + total + pagination, impl IntoResponse build HeaderMap insert X_TOTAL_COUNT qua HeaderValue::from(self.total), return tuple (headers, Json(ListResponse::new(items, total, &pagination))) — pattern preview cho admin list endpoint G14 cần expose pagination metadata cả trong header lẫn body. HOÀN THÀNH Group 4 Extractors Và Response Sâu 10/10 bài — foundation ready: B31 extractor trait hierarchy FromRequestParts / FromRequest, B32 custom extractor wrapper AppPath / AppQuery / AppJson, B33 TypedHeader strategy, B34 Cookie 3-tier (Plain / Signed / Private), B35 Form extractor, B36 Multipart upload, B37 Raw Body cho webhook signature, B38 Streaming body, B39 Extension request-scoped data + middleware request_id + error_enrich, B40 Response Builder pattern — sẵn sàng vào Group 5 JSON Body Streaming B41 validator crate + B42 optional field + B43 enum tagged + B44 chrono/uuid/decimal + B47 body size limit + B48 compression + B50 SSE. Workspace state change: 2 file mới crates/shop-api/src/responses/mod.rs (re-export top-level) + crates/shop-api/src/responses/helpers.rs (3 struct + impl), 1 file updated crates/shop-api/src/routes/products.rs (refactor create_product); KHÔNG add workspace dep mới (chỉ dùng axum/serde đã có). Suggested commit: B40: implement Created/Accepted/NoContent response builder + refactor create_product handler + HOÀN THÀNH Group 4.

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

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

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

  • Hiểu pattern custom IntoResponse cho 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), optional Ok<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.
2

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.

3

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).

4

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.

5

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.

6

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 data ignore field mới trong meta).

Cons của envelope wrapper:

  • Verbose: client phải unwrap data field mỗi response — JS/TS code lặp const product = response.data; 60+ chỗ.
  • Inconsistent với industry pattern: StripeGitHub 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ó header X-Request-Id echo 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ầng data.
  • 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"
}
7

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.

8

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) vs FromRequest (consume body, đặt cuối).
  • B32 custom-extractor — 3 wrapper extractor Shop API AppPath<T> / AppQuery<T> / AppJson<T> map rejection chuẩn envelope AppError.
  • B33 header-extractor-typedTypedHeader<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-extractorForm<T> cho admin login + Stripe webhook.
  • B36 multipart-upload — file upload streaming với size limit + content-type check.
  • B37 raw-body-bytesBytes extractor cho webhook signature verify (Stripe, GitHub).
  • B38 streaming-body — stream upload large file + impl Stream cho download với backpressure.
  • B39 extension-extractor — request-scoped data RequestId qua middleware + error envelope enrich pattern.
  • B40 response-builder-pattern — 4 helper builder Created / Accepted / NoContent custom IntoResponse.

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 CurrentUser Extension pattern preview B39.
  • Middleware ecosystem — Group 15+ với tower-http::TraceLayer trên top middleware request_id đã có B39.

Group 5 JSON Body Streaming (B41-B50) sẽ cover:

  • JSON extract + validation với validator crate (B41) — derive(Validate) cho DTO Shop API CreateProductDto.
  • Field optional / default / null pitfall (B42) — Option<T> vs #[serde(default)] vs skip_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"}
9

Tổng Kết

  • Pattern custom IntoResponse cho 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), optional Ok<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_id enrich qua middleware B39 — chỉ inject vào error envelope không lặp trong success body; header X-Request-Id echo 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 IntoResponse cho headers: ListResponseWithHeaders<T> expose X-Total-Count lock B4 cho admin endpoint G14 — public endpoint vẫn dùng Json<ListResponse<T>> default.
  • Decision matrix handler signature update G7+: list Json<ListResponse<T>>, read Json<T>, create Created<T>, delete NoContent, action async Accepted<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.
10

Bài Tập Củng Cố

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

  1. Pattern custom IntoResponse gồ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?
  2. Created<T> helper builder làm gì? Constructor signature như thế nào? Khi nào set header Location bị fail và mechanism defensive xử lý ra sao?
  3. Shop API KHÔNG dùng ApiResponse envelope wrapper cho success response. Lý do và 3 nguồn metadata thay thế (industry pattern, request_id, pagination)?
  4. Refactor handler create_product từ pattern tuple verbose (StatusCode, HeaderMap, Json<T>) sang pattern struct Created<T>. So sánh số dòng code và lợi ích cụ thể.
  5. List endpoint muốn expose X-Total-Count header bên cạnh body envelope. Pattern custom IntoResponse với struct riêng hay tuple ad-hoc inline? Lý do và use case cụ thể public vs admin endpoint.
Đáp án
  1. Pattern custom IntoResponse 2 bước MANDATORY: (1) Tạo struct wrap dữ liệu cần thiết — vd pub 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) Impl IntoResponse trait với 1 method bắt buộc fn into_response(self) -> Response — consume self by value (không borrow vì response build xong, struct drop), trả axum::response::Response (alias http::Response<Body>). Body method thường dùng pattern delegate qua built-in tuple impl: dựng HeaderMap, 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ần T: Serialize nếu body là JSON (vì Json<T> require T: Serialize); KHÔNG cần Send + Sync + 'staticinto_response consume self KHÔNG await point. Nếu type chứa data không cần serialize (vd NoContent unit 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ùng HeaderValue::from_str(&value) trả Result defensive — chain if 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, future Redirect custom với cookie set).
  2. Created<T> helper builder: struct generic wrap 2 field — pub location: String chứa path URL của resource vừa tạo (vd /api/v1/products/laptop-xyz, RFC 9110 mục 10.2.2 mandate header Location cho response 201 Created), pub data: T chứ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 helper fn new(...) wrap. Impl IntoResponse for Created<T> where T: Serialize 3 bước: (a) let mut headers = HeaderMap::new() empty map, (b) parse HeaderValue::from_str(&self.location) trả Result<HeaderValue, InvalidHeaderValue> + chain if 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ào HeaderValue::from_str bị 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ứa a-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ếu Location header), KHÔNG .unwrap() panic crash server. Alternative defensive nâng cao: log warning tracing::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).
  3. Shop API KHÔNG dùng ApiResponse envelope wrapper dạng { data: T, meta: ResponseMeta } cho success response. Lý do chính: (a) Verbose client side — JS/TS code phải unwrap const 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ào metarequest_idversion như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; list endpoint dùng struct riêng ListResponse<T> với { items, total, page, size, hasNext } chứa cả data + pagination metadata trong 1 struct flat (lock B23). (2) request_id qua middleware B39request_id_middleware set Extension RequestId(String) per request, echo response header X-Request-Id: <uuid> trên MỌI response (success + error), middleware enrich_error_response chỉ 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 header X-Request-Id không cần lặp trong body data (giảm payload + tránh leak internal trace ID vào business logic). (3) Pagination metadata trong ListResponse<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 nested data.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; field request_id trong 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.
  4. Refactor handler create_product tuple verbose → struct Created<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 type AppResult<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 set location field — struct literal Created { data: product } thiếu location sẽ compile error missing field 'location' in initializer of 'Created<_>'; pattern tuple không ép set header — quên insert Location compile 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 field let 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ải let (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-endpointCreated<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.
  5. List endpoint expose X-Total-Count header — pattern custom IntoResponse với struct riêng ListResponseWithHeaders<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ùng async 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ựng HeaderMap + insert X_TOTAL_COUNT + build ListResponse::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_COUNT lock B4 dùng cross-handler thay hard-code string literal "x-total-count" tránh typo (chữ x cap vs lower) + dễ rename centralized. Use case cụ thể public vs admin: (a) Public endpoint GET /api/v1/products — client SPA React/Next render product grid + pagination button → đọc total + hasNext từ body envelope ListResponse<T> đã đủ, không cần header riêng → dùng Json<ListResponse<ProductDto>> default đơn giản. (b) Admin endpoint GET /api/v1/admin/orders — admin dashboard có pagination component phức tạp (jump to page N, infinite scroll preload count) cần đọc total nhanh 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-Count header cần thiết. (c) Pattern hybrid — admin endpoint trả CẢ header X-Total-Count CẢ body envelope ListResponse<T> chứa total — 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 laiListResponseWithHeaders có thể extend thêm header Link: <next-page-url>; rel="next" (GitHub-style pagination cursor B96 G10), X-RateLimit-Remaining cho 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 header X-Total-Count trong response schema để client biết tận dụng — sẽ wire ở G14 admin module.
11

Bài Tiếp Theo

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