Danh sách bài viết

Bài 14: Response Types: String, Html, Json, StatusCode

Bài 14 của series Rust RESTful API — mổ xẻ 5 nhóm response type built-in của axum đi kèm Content-Type và status default: String/&'static str trả text/plain; charset=utf-8 phù hợp cho infrastructure handler đơn giản, Html<T> trả text/html; charset=utf-8 dành riêng cho Swagger UI và template page, Json<T> trả application/json; charset=utf-8 là response chính của REST API serialize qua serde_json, StatusCode standalone cho 204 No Content và 202 Accepted không kèm body, tuple (StatusCode, body) + (StatusCode, HeaderMap, body) cho create 201 + Location header với thứ tự cố định compiler-enforce; pattern custom impl IntoResponse cho struct domain riêng làm bệ phóng cho AppError impl ở B16; decision matrix per endpoint Shop API (list/read → Json, create → tuple + Location, update → Json hoặc 204, delete → 204, action async → 202 + Json job_id, infrastructure → Json envelope); confirm policy JSON-only cấm raw String cho data endpoint align B5.

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

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

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

  • Biết khi nào dùng String&'static str (text/plain) — chỉ phù hợp cho infrastructure handler đơn giản, không phù hợp cho data endpoint của REST API.
  • Biết khi nào dùng Html<T> (text/html) — dành riêng cho render server-side template hoặc Swagger UI page, kèm cảnh báo XSS khi embed user input.
  • Nắm Json<T> đầy đủ — response chính của REST API, vai trò kép vừa response (T: Serialize) vừa request extractor (T: DeserializeOwned), Content-Type tự set application/json; charset=utf-8.
  • Hiểu StatusCode standalone trả body rỗng (204/202) và tuple (StatusCode, body), (StatusCode, HeaderMap, body) cho custom status + header với thứ tự cố định.
  • Implement custom IntoResponse cho struct domain riêng — chuẩn bị nền tảng cho AppError impl ở B16.
  • Apply vào Shop API: response envelope chuẩn theo endpoint, error format envelope, decision matrix per endpoint pattern cho cả series.
2

String / &'static str — text/plain

Hai type String&'static str là response type đơn giản nhất — axum impl IntoResponse sẵn với behavior mặc định:

  • Content-Type: text/plain; charset=utf-8 (axum set tự động, charset bắt buộc vì JSON/text spec yêu cầu UTF-8).
  • Status code: 200 OK mặc định, không override được trừ khi wrap qua tuple.
  • Body: bytes UTF-8 của chuỗi.

Use case hợp lệ cho String/&str trong handler:

  • Health check minimal — endpoint cũ kỹ trả "ok" cho load balancer (đã thay bằng JSON envelope ở B12 theo policy B5).
  • Version banner dạng plain text — debug endpoint.
  • Debug echo — endpoint print path/header cho dev.
  • Robots.txt, sitemap dạng đơn giản — file static phục vụ qua handler.

Code snippet handler trả String&'static str:

use axum::response::IntoResponse;

// &'static str — chuỗi literal compile-time
async fn ping() -> &'static str {
    "pong"
}

// String — chuỗi build runtime
async fn echo_path(path: String) -> impl IntoResponse {
    format!("path: {}", path)
}

// Multi-line, vẫn text/plain
async fn banner() -> &'static str {
    "shop-api v0.1.0\nbuild: 2026-06-12\nedition: 2024"
}

Shop API quyết định: KHÔNG dùng raw String hay &str cho bất kỳ data endpoint nào — đã lock policy JSON-only từ B5 và confirm lại ở B12 khi đổi /health từ text "ok" sang Json(json!({"status":"ok"})). Lý do: client tooling expect parse JSON cross-endpoint, monitoring agent (Prometheus exporter, Datadog) cần body structured, browser tab show plain text trông không production-quality. Plain string chỉ giữ kiến thức để bạn hiểu type built-in của axum — không xuất hiện trong codebase Shop API tương lai trừ một trường hợp duy nhất là endpoint GET / banner (vốn không phải data endpoint).

3

Html<T> — text/html

axum cung cấp wrapper Html<T> trong module axum::response, type parameter T phải implement Into<Body> (vd String, &'static str, Vec<u8>). Behavior mặc định:

  • Content-Type: text/html; charset=utf-8 — axum tự set khi bạn wrap value qua Html(...).
  • Status code: 200 OK mặc định.
  • Body: bytes của HTML string.

Use case hợp lệ cho Html<T> trong REST API:

  • Render server-side template — askama (compile-time template), maud (Rust DSL), tera (Jinja2-like). Endpoint trả HTML page hoàn chỉnh cho email confirmation, error page user-friendly, admin dashboard nội bộ.
  • Swagger UI / ReDoc / Scalar page — render UI document API từ spec OpenAPI (đã lock B8 stack utoipa + utoipa-swagger-ui).
  • Email verification landing page — user click link trong email, server render trang xác nhận thành công kèm CTA back to app.

Code snippet handler render mini template:

use axum::response::Html;

// HTML string literal — debug/dev
async fn admin_banner() -> Html<&'static str> {
    Html("<h1>Shop Admin</h1><p>Welcome back.</p>")
}

// HTML build runtime từ template
async fn verify_success(email: String) -> Html<String> {
    let html = format!(
        "<!DOCTYPE html><html><body>\
         <h1>Email Verified</h1>\
         <p>Account <strong>{}</strong> đã active.</p>\
         <a href=\"/app\">Vào Shop</a>\
         </body></html>",
        // CẢNH BÁO: chỗ này phải escape user input trước khi inject
        html_escape::encode_text(&email)
    );
    Html(html)
}

Lưu ý bảo mật XSS: KHÔNG bao giờ embed user input chưa escape vào HTML output. Tấn công XSS (Cross-Site Scripting) cho phép attacker inject <script> chạy trên trình duyệt nạn nhân, đánh cắp cookie session. Template engine như askama auto-escape mặc định; nếu build HTML thủ công bằng format!, phải gọi escape helper (html_escape::encode_text) cho mọi giá trị động — KHÔNG có ngoại lệ.

Shop API quyết định: Html<T> chỉ dùng cho hai trường hợp — (1) Swagger UI page /swagger-ui mount qua utoipa-swagger-ui ở dev/staging (đã lock B8), (2) email verification landing page /auth/verify-email/landing. Toàn bộ API data thuần JSON. Không có handler resource nào trả Html<T> — REST API thuần backend, frontend SPA tự render trên client.

4

Json<T> — Cốt Lõi REST API

Wrapper axum::Json<T> là response type quan trọng nhất của Shop API — mọi resource handler đều trả qua type này. Một điểm thiết kế đáng chú ý: cùng tên struct Json<T> đóng hai vai trò đối xứng nhau:

  • Response: Json<T> với T: Serialize — wrap struct DTO, axum serialize qua serde_json::to_vec rồi build response với Content-Type tự set.
  • Request extractor: Json<T> với T: DeserializeOwned — đặt làm argument handler, axum đọc body request, parse qua serde_json::from_slice vào struct.

Behavior mặc định khi dùng làm response:

  • Content-Type: application/json; charset=utf-8 — axum set tự động, charset bắt buộc theo RFC 8259 (JSON UTF-8 default).
  • Status code: 200 OK mặc định, override qua tuple (StatusCode, Json<T>).
  • Body: bytes JSON sau khi serde_json serialize.

Code snippet handler trả Json<T> đơn giản:

use axum::Json;
use serde::Serialize;
use serde_json::json;

#[derive(Serialize)]
struct ProductDto {
    id: u64,
    name: String,
    price: String,
}

// Handler đơn giản — trả Json wrap struct DTO
async fn get_product() -> Json<ProductDto> {
    Json(ProductDto {
        id: 42,
        name: "Demo Laptop".into(),
        price: "1499.00".into(),
    })
}

// Shortcut ad-hoc qua macro serde_json::json! — không cần define struct
async fn get_status() -> Json<serde_json::Value> {
    Json(json!({
        "status": "ok",
        "timestamp": "2026-06-12T14:30:00Z"
    }))
}

Macro serde_json::json! tiện cho ad-hoc response không define struct trước — phù hợp cho infrastructure endpoint (/health, /version ở B12 đã dùng pattern này). Tuy nhiên cho resource handler, luôn define struct DTO rõ ràng vì utoipa cần ToSchema derive để sinh OpenAPI spec (lock B8).

Pattern Shop API: handler resource từ B16 onward dùng signature AppResult<Json<T>> — đã lock từ B13. Đây là pattern xuyên suốt: Result wrap quanh Json, vế Ok(Json(dto)) trả 200 + body, vế Err(AppError) trả status mapping tương ứng (404/401/422/...) qua AppError impl IntoResponse ở B16.

5

StatusCode Standalone — Status Only, No Body

Type http::StatusCode tự implement IntoResponse — response trả về có status code đúng giá trị, body rỗng, không có Content-Type. Phù hợp cho endpoint mà semantic là "thành công không cần trả gì thêm":

  • StatusCode::NO_CONTENT (204) — DELETE thành công, RFC 9110 mục 15.3.5 cấm body trong 204. Reset password OK, mark notification read OK.
  • StatusCode::ACCEPTED (202) — request đã nhận, xử lý async; thường kèm body chứa job_id qua tuple (xem section 6) chứ ít khi standalone.
  • StatusCode::CREATED (201) — về lý có thể standalone, nhưng convention REST là kèm body entity mới + Location header, nên cũng dùng tuple.

Code snippet handler DELETE trả 204:

use axum::{extract::Path, http::StatusCode};

// Handler DELETE — trả 204 standalone
async fn delete_cart_item(Path(_item_id): Path<u64>) -> StatusCode {
    // Logic xóa item khỏi cart
    // Thành công → trả 204 No Content
    StatusCode::NO_CONTENT
}

// Handler reset password OK — không cần trả gì thêm
async fn confirm_password_reset() -> StatusCode {
    StatusCode::NO_CONTENT
}

Pattern Shop API: handler DELETE dùng signature AppResult<StatusCode> — đã lock từ B13. Vế Ok(StatusCode::NO_CONTENT) trả 204; vế Err(AppError::NotFound) trả 404 nếu resource không tồn tại. Mọi handler DELETE trong Shop API (vd DELETE /api/v1/cart/items/:item_id, DELETE /api/v1/me/addresses/:id) follow pattern này.

Pitfall: KHÔNG gửi body kèm 204 — một số HTTP client (curl với verbose) sẽ cảnh báo "no Content-Length but body present" hoặc cache layer (Varnish) drop body âm thầm. axum tự enforce điều này khi bạn return StatusCode standalone — không có cách trộn body vào trong cùng response chuẩn.

6

Tuple (StatusCode, body) Và (StatusCode, HeaderMap, body)

Khi cần custom status code và/hoặc custom header kèm body, axum cho phép return tuple với hai dạng phổ biến:

  • 2-element tuple (StatusCode, body) — override status, body giữ Content-Type của chính nó (vd Json<T> giữ application/json).
  • 3-element tuple (StatusCode, HeaderMap, body) — thêm nhiều header tùy ý (Location, Cache-Control, X-Request-Id, ...).

Use case chính: POST create cần trả 201 + Location: /api/v1/<resource>/<id> + body entity mới (đã lock B3 status code policy). Code snippet đầy đủ:

use axum::{
    http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
    Json,
};
use serde::Serialize;
use shop_common::error::AppResult;

#[derive(Serialize)]
struct ProductDto {
    id: u64,
    name: String,
    price: String,
}

// Handler POST create — trả 201 + Location + Json body
async fn create_product() -> AppResult<(StatusCode, HeaderMap, Json<ProductDto>)> {
    // Logic INSERT vào DB, lấy id mới
    let product = ProductDto {
        id: 42,
        name: "Demo Laptop".into(),
        price: "1499.00".into(),
    };

    let mut headers = HeaderMap::new();
    let location = format!("/api/v1/products/{}", product.id);
    headers.insert(LOCATION, HeaderValue::from_str(&location).unwrap());

    Ok((StatusCode::CREATED, headers, Json(product)))
}

// Variant 2-element — chỉ override status, không thêm header
async fn create_simple() -> (StatusCode, Json<ProductDto>) {
    let product = ProductDto {
        id: 1,
        name: "Demo".into(),
        price: "10.00".into(),
    };
    (StatusCode::CREATED, Json(product))
}

Thứ tự tuple cố định: (StatusCode, HeaderMap, body) — không đảo được. axum impl IntoResponse cho tuple với type position cụ thể; nếu bạn viết (HeaderMap, StatusCode, body) hoặc (body, StatusCode), compile error "the trait IntoResponse is not implemented for this tuple". Đây là compile-time check chứ không phải runtime — rất an toàn. Memorize thứ tự: status → headers → body, từ "outer wrapping" vào "inner content".

Pattern Shop API: handler POST create dùng signature AppResult<(StatusCode, HeaderMap, Json<T>)> — đã lock từ B13. Handler async action (vd POST /api/v1/checkout trả 202 + job_id) dùng signature AppResult<(StatusCode, Json<T>)> với 2-element tuple vì không cần custom header.

7

Custom IntoResponse Implementation

Khi struct domain riêng (vd error type, custom response wrapper) cần trả về từ handler, bạn impl IntoResponse cho struct đó. axum sẽ gọi method .into_response() tự động sau khi handler return — đúng pattern như mọi built-in type.

Pattern cơ bản cho wrapper struct envelope chung:

use axum::{
    response::{IntoResponse, Response},
    Json,
};
use serde::Serialize;

// Wrapper response envelope chung — gắn metadata (request_id) vào mọi response
pub struct ApiResponse<T> {
    pub data: T,
    pub request_id: String,
}

impl<T: Serialize> IntoResponse for ApiResponse<T> {
    fn into_response(self) -> Response {
        // Delegate qua Json wrapper — tận dụng IntoResponse có sẵn
        Json(serde_json::json!({
            "data": serde_json::to_value(self.data).unwrap_or(serde_json::Value::Null),
            "request_id": self.request_id,
        }))
        .into_response()
    }
}

Pattern quan trọng hơn cho Shop API là AppError impl IntoResponse — chuẩn bị nền tảng cho B16. Code preview để bạn hình dung shape, chi tiết đầy đủ sẽ ở B16:

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use shop_common::error::AppError;

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code) = match &self {
            AppError::BadRequest(_)    => (StatusCode::BAD_REQUEST, "BAD_REQUEST"),
            AppError::Unauthenticated  => (StatusCode::UNAUTHORIZED, "UNAUTHENTICATED"),
            AppError::Forbidden        => (StatusCode::FORBIDDEN, "FORBIDDEN"),
            AppError::NotFound         => (StatusCode::NOT_FOUND, "NOT_FOUND"),
            AppError::Conflict(_)      => (StatusCode::CONFLICT, "CONFLICT"),
            AppError::Validation(_)    => (StatusCode::UNPROCESSABLE_ENTITY, "VALIDATION"),
            AppError::RateLimited      => (StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED"),
            // ... 4 variants khác (MethodNotAllowed, Internal, Upstream, Unavailable)
            _ => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL"),
        };

        let body = Json(serde_json::json!({
            "error": self.to_string(),
            "code": code,
            "request_id": "TODO",  // sẽ inject từ Extension<RequestId> ở B39
        }));

        (status, body).into_response()
    }
}

Hai điểm cần làm rõ về code preview này:

  • Mapping AppError → HTTP status đã lock từ B3 (11 variants → status code tương ứng). Code preview ở section này chỉ minh họa pattern; đầy đủ 11 variants với header phụ trợ (WWW-Authenticate, Retry-After, Allow) sẽ chi tiết ở B16.
  • Field request_id hiện đang là placeholder "TODO" — sẽ inject từ Extension<RequestId> middleware ở B39 (Extension extractor). Body envelope chuẩn { "error", "code", "request_id" } đã lock từ B3.

Shop API quyết định: AppError impl IntoResponse bật ở B16 (file crates/shop-api/src/responses/error.rs) — sau bài đó mọi handler return AppResult<Json<T>> mới compile được. Wrapper ApiResponse<T> optional cho metadata envelope — Shop API ban đầu sẽ dùng Json<T> trực tiếp cho đơn giản, evaluate sau xem có cần wrap qua envelope ở G14 (Response Builder Pattern B40) không.

8

Decision Matrix: Khi Nào Dùng Cái Nào

Bảng quyết định response type per endpoint pattern — lock cho mọi handler Shop API:

Tình huống                              │ Response Type
────────────────────────────────────────┼──────────────────────────────────────────────
GET /products → list collection         │ Json<ListResponse<ProductDto>>
GET /products/:slug → single entity     │ Json<ProductDto>
POST /products → 201 + Location         │ (StatusCode::CREATED, HeaderMap, Json<ProductDto>)
PUT /products/:id → updated entity      │ Json<ProductDto>
PATCH /products/:id → updated entity    │ Json<ProductDto>
DELETE /products/:id → 204              │ StatusCode::NO_CONTENT
POST /checkout → 202 + job_id           │ (StatusCode::ACCEPTED, Json<CheckoutResponse>)
POST /orders/:id/cancel → state mới     │ Json<OrderDto>
GET /health → infrastructure            │ Json(json!({"status":"ok"}))  per policy B5
GET /swagger-ui → document UI           │ Html<String> (qua utoipa-swagger-ui mount)
Error path bất kỳ                       │ AppError → IntoResponse (B16)

Bảng trên áp dụng cho mọi handler Shop API từ G7 (B61 CRUD Cơ Bản) onward. Mỗi entity (Product, Category, Cart, Order, ...) follow đúng matrix này — không sáng tạo riêng từng resource.

Ba quyết định nguyên tắc đi kèm matrix:

  • JSON-only policy (lock B5): KHÔNG dùng raw String hay &str cho data endpoint. Mọi response data envelope qua Json<T>. Plain text chỉ giữ kiến thức axum, không có handler resource nào của Shop API trả raw string.
  • Html<T> giới hạn: chỉ dùng cho Swagger UI mount (/swagger-ui ở dev/staging) và email verification landing page. Mọi UI khác là responsibility của frontend SPA, không phải REST API.
  • Tuple thứ tự cố định: (StatusCode, HeaderMap, body) — compile-time enforce. Memorize: status → headers → body, không đảo, không skip.

Đây là decision matrix lock vĩnh viễn — cập nhật _plans/shop-state.md note "Response Type Decision Matrix" để mọi bài sau reference đồng nhất.

9

Apply Vào shop-api: Mini Preview Handler Set

Code preview các handler Shop API sẽ có khi G7 implement, mỗi handler theo signature lock B13 + response type B14:

use axum::{
    extract::{Path, Query, State},
    http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
    Json,
};
use serde::{Deserialize, Serialize};
use shop_common::error::AppResult;

#[derive(Clone)]
struct AppState; // define đầy đủ ở B28

#[derive(Deserialize)]
struct ProductFilter { page: u32, size: u32 }

#[derive(Deserialize)]
struct CreateProductDto { name: String, price: String }

#[derive(Deserialize)]
struct CheckoutDto { cart_id: u64 }

#[derive(Serialize)]
struct ProductDto { id: u64, name: String, price: String }

#[derive(Serialize)]
struct ListResponse<T> { items: Vec<T>, total: u64 }

#[derive(Serialize)]
struct CheckoutResponse { job_id: String, poll_url: String }

// GET /api/v1/products → list collection
async fn list_products(
    State(_state): State<AppState>,
    Query(_filter): Query<ProductFilter>,
) -> AppResult<Json<ListResponse<ProductDto>>> {
    Ok(Json(ListResponse { items: vec![], total: 0 }))
}

// POST /api/v1/products → 201 + Location + entity mới
async fn create_product(
    State(_state): State<AppState>,
    Json(_dto): Json<CreateProductDto>,
) -> AppResult<(StatusCode, HeaderMap, Json<ProductDto>)> {
    let product = ProductDto {
        id: 42,
        name: "demo".into(),
        price: "100".into(),
    };
    let mut headers = HeaderMap::new();
    let location = format!("/api/v1/products/{}", product.id);
    headers.insert(LOCATION, HeaderValue::from_str(&location).unwrap());
    Ok((StatusCode::CREATED, headers, Json(product)))
}

// DELETE /api/v1/cart/items/:item_id → 204 No Content
async fn delete_cart_item(
    State(_state): State<AppState>,
    Path(_item_id): Path<u64>,
) -> AppResult<StatusCode> {
    Ok(StatusCode::NO_CONTENT)
}

// POST /api/v1/checkout → 202 Accepted + job_id polling URL
async fn checkout(
    State(_state): State<AppState>,
    Json(_dto): Json<CheckoutDto>,
) -> AppResult<(StatusCode, Json<CheckoutResponse>)> {
    let job_id = "job_01J5K6XYZ".to_string();
    let response = CheckoutResponse {
        poll_url: format!("/api/v1/checkout/jobs/{}", job_id),
        job_id,
    };
    Ok((StatusCode::ACCEPTED, Json(response)))
}

Bốn handler trên đại diện 4 dạng response type lock cho Shop API: Json<ListResponse<T>> cho list, tuple 3-element cho create, StatusCode cho delete, tuple 2-element cho action async. Mọi resource entity sau này (Category, Cart, Order, Review, Payment, ...) follow đúng matrix B14.

Code preview ở section này tạm chưa compile được vì AppError impl IntoResponse sẽ bật ở B16. Sau B16, các signature AppResult<Json<T>>, AppResult<StatusCode>, AppResult<(StatusCode, HeaderMap, Json<T>)> chạy thật được trong codebase. Suggested commit khi B16 done: "B16: implement AppError IntoResponse cho mapping 11 variants → HTTP status".

10

Tổng Kết

  • axum cung cấp 8 built-in response type implementor phổ biến: &str, String, Html<T>, Json<T>, StatusCode, tuple (StatusCode, body) + (StatusCode, HeaderMap, body), Response<Body>, Result<T, E>.
  • REST API ưu tiên Json<T> cho data response (Content-Type tự set application/json; charset=utf-8), StatusCode cho 204/202 không body, tuple cho 201 + Location header.
  • Html<T> chỉ dùng cho Swagger UI / email verification landing page — KHÔNG cho data endpoint; cảnh báo XSS khi embed user input chưa escape.
  • Custom impl IntoResponse cho struct domain riêng — pattern AppError sẽ ở B16, wrapper ApiResponse<T> optional cho envelope chung evaluate ở B40.
  • Shop API JSON-only confirm: KHÔNG dùng raw String cho data endpoint, Json(json!({"status":"ok"})) thay "ok" text — align policy B5 đã lock từ B12.
  • Tuple thứ tự cố định: (StatusCode, HeaderMap, body) — compile-time check, không đảo được, memorize status → headers → body.
  • Decision matrix per endpoint lock vĩnh viễn: list/read → Json, create → tuple 3-element + Location, update → Json hoặc 204, delete → 204, action async → tuple 2-element 202 + Json job_id, infrastructure → Json envelope, swagger → Html, error → AppError IntoResponse.
11

Bài Tập Củng Cố

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

  1. Phân biệt Json<T> (response) và Json<T> (extractor request body). Cùng tên struct nhưng vai trò khác nhau ra sao? Trait bound trên T ở hai vai trò là gì?
  2. Handler POST tạo product cần return code 201 + Location header. Viết signature đầy đủ kèm import cần thiết.
  3. DELETE thành công không trả body. Return type nào phù hợp? Status code nào? Tại sao RFC 9110 cấm body trong status đó?
  4. Custom struct ApiResponse<T> muốn dùng làm response type của handler. Phải implement trait gì? Method nào? Liệt kê đầy đủ signature của method.
  5. Tại sao Shop API không dùng raw String cho data endpoint? Policy nào quyết định? Đưa ví dụ cụ thể về handler đã chuyển từ raw text sang JSON envelope.
Đáp án
  1. Cùng tên struct axum::Json<T> đóng hai vai trò đối xứng: (a) Response: Json<T> với T: Serialize — wrap struct DTO, axum serialize qua serde_json::to_vec, set Content-Type application/json; charset=utf-8 và status 200, trả response. Handler return Json(ProductDto { ... }). (b) Request extractor: Json<T> với T: DeserializeOwned — đặt làm argument handler, axum đọc body request bytes, parse qua serde_json::from_slice vào struct, reject 422 nếu parse fail. Handler nhận Json(dto): Json<CreateProductDto>. Trait bound khác nhau vì hướng dòng dữ liệu khác nhau: response cần serialize (struct → JSON bytes), extract cần deserialize (JSON bytes → struct). Cùng struct vì axum thiết kế đối xứng cho DX gọn — bạn import một type dùng cả hai phía.
  2. Signature đầy đủ cho POST create product:
    use axum::{
        http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
        Json,
    };
    use shop_common::error::AppResult;
    
    async fn create_product(
        State(state): State<AppState>,
        Json(dto): Json<CreateProductDto>,
    ) -> AppResult<(StatusCode, HeaderMap, Json<ProductDto>)> {
        let product = service::create(&state, dto).await?;
        let mut headers = HeaderMap::new();
        let location = format!("/api/v1/products/{}", product.id);
        headers.insert(LOCATION, HeaderValue::from_str(&location)?);
        Ok((StatusCode::CREATED, headers, Json(product)))
    }
    Pattern lock B13. Tuple thứ tự cố định (StatusCode, HeaderMap, Json<T>) — compile-time enforce.
  3. Return type: StatusCode standalone — tự implement IntoResponse trả status only, body rỗng, không Content-Type. Status code: StatusCode::NO_CONTENT (204). Lý do RFC 9110 mục 15.3.5 cấm body trong 204: "204 No Content" semantic là "request thành công, không có representation để trả" — nếu kèm body, contradict semantic, một số HTTP client (curl verbose) cảnh báo "no Content-Length but body present", cache layer (Varnish, browser) có thể drop body âm thầm gây inconsistency. axum tự enforce: bạn return StatusCode::NO_CONTENT thì không có cách trộn body vào cùng response chuẩn. Pattern Shop API lock: handler DELETE dùng AppResult<StatusCode>, vế Ok(StatusCode::NO_CONTENT) trả 204, vế Err(AppError::NotFound) trả 404.
  4. Phải implement trait axum::response::IntoResponse. Method: fn into_response(self) -> Response — consume self trả về axum::response::Response (alias của http::Response<axum::body::Body>). Signature đầy đủ:
    use axum::response::{IntoResponse, Response};
    
    impl<T: serde::Serialize> IntoResponse for ApiResponse<T> {
        fn into_response(self) -> Response {
            // Delegate qua type built-in có sẵn impl
            Json(serde_json::json!({
                "data": serde_json::to_value(self.data).unwrap_or_default(),
                "request_id": self.request_id,
            })).into_response()
        }
    }
    Lưu ý: method into_response consume self (move ownership), không phải &self. axum gọi method này tự động sau khi handler return — bạn không bao giờ gọi tay. Pattern delegate qua type built-in (Json) là cách clean nhất, tận dụng impl có sẵn thay vì build Response::builder() raw từ đầu.
  5. Lý do Shop API không dùng raw String cho data endpoint: (a) Client tooling expect parse JSON cross-endpoint — frontend SPA, mobile client, third-party integration đều assume mọi response data là JSON. Trả text mixed làm pipeline parse complex (phải detect Content-Type từng response). (b) Monitoring agent (Prometheus exporter, Datadog APM) cần body structured để extract metric/log field — text không cấu trúc bỏ qua. (c) Browser tab show plain text trông không production-quality cho debug. (d) Test harness viết generic dễ hơn khi mọi response cùng dạng. Policy quyết định: Content Negotiation Policy lock từ B5 — "JSON-only cho mọi data endpoint Shop API, response set Content-Type application/json; charset=utf-8". Ví dụ cụ thể: handler GET /health ở B10 ban đầu trả &'static str "ok", **B12 đã đổi sang** Json(serde_json::json!({"status":"ok"})) để align policy B5; B14 confirm lại quyết định này lock vĩnh viễn — mọi infrastructure handler tương lai (/healthz, /readyz, /metrics ở G15) follow pattern JSON envelope, không trả raw text. Ngoại lệ duy nhất: GET / banner trả tuple (StatusCode::OK, "shop-api v0.1.0") — không phải data endpoint, chỉ là banner identification.
12

Bài Tiếp Theo

— chi tiết serde derive Serialize/Deserialize, field rename #[serde(rename = "camelCase")]rename_all cấp struct, skip_serializing_if = "Option::is_none" bỏ field None khỏi output, tagged/untagged/adjacent enum cho polymorphism, default value qua #[serde(default)], alias cho input đa dạng, áp dụng vào DTO Shop API (ProductDto, CreateProductDto, UpdateProductDto) với convention naming + JSON Schema sinh qua schemars.