Danh sách bài viết

Bài 45: JSON Skip + Rename — Ẩn Field Và Đổi Tên Wire

Bài 45 của series Rust RESTful API — phân biệt 4 attribute skip của serde: #[serde(skip)] ẩn field cả 2 chiều serialize và deserialize (yêu cầu Default), #[serde(skip_serializing)] ẩn xuất nhưng vẫn deserialize từ JSON input, #[serde(skip_deserializing)] serialize ra wire nhưng KHÔNG nhận từ input (dùng cho field server-generated như id, created_at), #[serde(skip_serializing_if = "Option::is_none")] ẩn theo điều kiện tránh null verbose response (Stripe/GitHub omit null fields industry pattern); ẩn field nhạy cảm password_hash, deleted_at soft delete, login_count analytics internal khỏi wire JSON bằng cách KHÔNG impl Serialize trên Internal Entity và tạo Response DTO riêng chỉ chứa field public; #[serde(rename = "...")] đổi tên field 1-1 cho wire format khác Rust idiomatic (GitHub API login field, Rust keyword conflict type/self) với option rename(serialize = "...", deserialize = "...") cho 2 chiều khác nhau; #[serde(rename_all = "...")] convert tên field global cho whole struct với 8 case style (lowercase, UPPERCASE, PascalCase, camelCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE); Shop API decision lock vĩnh viễn: wire format snake_case (Python/Ruby REST tradition, phổ biến VN), Rust field cũng snake_case đồng bộ tự nhiên KHÔNG cần rename_all cho most struct, chỉ dùng rename_all = "snake_case" cho enum variant (B43 lock) vì Rust enum variant là PascalCase mặc định; JSX client B85+ frontend handle conversion ở axios interceptor không phải ép wire format camelCase; áp dụng UserResponseDto Shop API lần đầu định nghĩa User domain với fields id: UserId, email: String, display_name: String, avatar_url: Option<String>, phone: Option<String>, created_at: DateTime<Utc>, last_login_at: Option<DateTime<Utc>> + #[serde(skip_serializing_if = "Option::is_none")] mọi Option field; Internal Entity User chứa thêm password_hash: String, deleted_at: Option<DateTime<Utc>> KHÔNG impl Serialize (accident-proof security pattern — compiler reject mọi attempt serialize accidentally); impl From<User> for UserResponseDto explicit conversion chỉ copy field public, password_hash và deleted_at KHÔNG vào ResponseDto — security pattern lock; tạo file mới crates/shop-common/src/dto/user.rs đầu tiên cho User domain (B104 sẽ thêm CreateUserDto + UpdateUserDto đầy đủ), update crates/shop-common/src/dto/mod.rs thêm pub mod user; pub use user::{User, UserResponseDto};, workspace deps thêm chrono = { version = "0.4", features = ["serde"] } preview B53 cho DateTime<Utc> timestamp ISO 8601 wire format; lock vĩnh viễn từ B45: skip_serializing_if = "Option::is_none" MANDATORY mọi Option field response DTO Shop API tránh null verbose wire, wire format snake_case lock toàn hệ thống KHÔNG dùng rename_all = "camelCase" ép wire frontend, Internal Entity KHÔNG impl Serialize (accident-proof) + Response DTO impl Serialize only (response one-way), From<Entity> for ResponseDto explicit conversion KHÔNG dùng Into auto tránh leak field accidentally, skip_deserializing cho field server-generated (id, created_at, updated_at) nếu cần dùng chung struct cho cả input và output, file dto/user.rs + UserResponseDto pattern lock cho mọi domain entity Shop API tương lai; foundation cho B46 (custom serializer + deserializer manual impl), B53 (chrono DateTime ISO 8601 deep dive), B104 (CreateUserDto + UpdateUserDto register flow), B112 (auth handler return UserResponseDto sau verify password).

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

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

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

  • Phân biệt 4 attribute skip của serde — skip, skip_serializing, skip_deserializing, skip_serializing_if — mỗi attribute use case riêng.
  • Ẩn field nhạy cảm (password_hash) khỏi wire JSON — pattern bảo mật MANDATORY cho mọi response DTO.
  • Tránh field optional null verbose trong response — skip_serializing_if = "Option::is_none" omit field khi None.
  • Đổi tên field wire khác Rust idiomatic với #[serde(rename = "...")] — map field third-party API, tránh keyword conflict.
  • Áp rename_all = "camelCase" cho toàn bộ struct theo convention client JS — và lý do Shop API lock snake_case thay vì camelCase.
  • Áp dụng UserResponseDto Shop API — lần đầu định nghĩa User domain (response DTO + Internal Entity tách bạch, KHÔNG include create/update — đó là B104).
2

Vấn Đề: Field Internal Bị Lộ Ra Wire

Domain entity load từ DB thường chứa field internal KHÔNG nên expose ra client. Lấy User Shop API làm ví dụ:

// Anti-pattern — entity với derive Serialize trực tiếp
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub id: UserId,
    pub email: String,
    pub password_hash: String,          // ← KHÔNG được expose!
    pub created_at: DateTime<Utc>,
    pub deleted_at: Option<DateTime<Utc>>, // ← soft delete internal
    pub login_count: u32,               // ← internal analytics
}

Handler trả entity thẳng làm response sẽ leak toàn bộ field ra wire JSON:

{
  "id": 1,
  "email": "[email protected]",
  "password_hash": "$argon2id$v=19$m=19456,t=2,p=1$...",
  "created_at": "2026-06-14T10:00:00Z",
  "deleted_at": null,
  "login_count": 42
}

Hậu quả nghiêm trọng:

  • Security breachpassword_hash bị lộ; attacker offline brute-force hash hoặc rainbow table attack; dù argon2 chậm, vẫn là bug security critical phải fix ngay.
  • Information leakdeleted_at tiết lộ user đã bị soft delete (internal business logic), login_count tiết lộ pattern hoạt động (analytics not for public).
  • API contract pollution — client SDK gen code từ wire JSON sẽ có field thừa client không bao giờ nên dùng; refactor schema sau khó vì client đã rely.

Lỗi này lặp lại nhiều trong industry — Heroku 2020 leak API token qua endpoint /account, GitHub 2018 leak email qua event API. Root cause đều là entity reuse cho response không tách DTO.

Có 2 giải pháp:

  • Inline attribute skip — dùng #[serde(skip)] trên field cần ẩn; giữ 1 struct cho cả entity và response. Đơn giản nhưng dễ accident (dev quên gắn attribute).
  • Tách struct DTO riêng — Internal Entity KHÔNG derive Serialize, Response DTO derive Serialize chỉ chứa field public; convert qua From<Entity>. An toàn hơn, lock B45 Shop API.

Bài hôm nay đi qua cả 2 — attribute skip trước để hiểu công cụ, sau đó áp pattern tách DTO production-grade vào Shop API.

3

#[serde(skip)] — Ẩn Field Hoàn Toàn

skip báo serde bỏ qua field ở cả 2 chiều — KHÔNG serialize ra JSON, KHÔNG nhận từ JSON input:

#[derive(Serialize, Deserialize)]
pub struct User {
    pub id: UserId,
    pub email: String,

    #[serde(skip)]
    pub password_hash: String,  // KHÔNG xuất, KHÔNG nhận từ JSON
}

JSON output bỏ field hoàn toàn:

{"id": 1, "email": "[email protected]"}

Khi deserialize từ JSON input thiếu password_hash, serde cần biết cách khởi tạo field — mặc định gọi Default::default():

  • String default = empty ""
  • u32/u64/i32 default = 0
  • Option<T> default = None
  • Vec<T> default = empty vec![]
  • Custom type cần #[derive(Default)] hoặc impl thủ công

Pitfall lớn: nếu field type KHÔNG impl Default, compile error. Vd chrono::DateTime<Utc> không có Default (không có "default time" hợp lý) — phải gắn default = "..." chỉ rõ function:

use chrono::{DateTime, Utc};

#[derive(Serialize, Deserialize)]
pub struct AuditEvent {
    pub action: String,

    #[serde(skip, default = "Utc::now")]
    pub recorded_at: DateTime<Utc>,  // generate khi deserialize
}

Use case skip trong Shop API thực tế ít — vì pattern lock B45 dùng struct DTO tách bạch (xem step 8). skip chỉ phù hợp khi 1 struct dùng đa mục đích, chấp nhận field internal khởi tạo default.

4

skip_serializing Vs skip_deserializing

Hai attribute con tách skip theo từng chiều:

  • skip_serializing: KHÔNG xuất ra JSON nhưng VẪN deserialize từ JSON input.
  • skip_deserializing: serialize ra JSON nhưng KHÔNG nhận từ JSON input (init qua Default hoặc default = "...").

Use case skip_serializing: field hash internal cần load từ DB query (deserialize từ DB row JSON) nhưng KHÔNG xuất ra response wire. Pitfall: trong thực tế pattern này ít dùng — vì sqlx load row trả struct (KHÔNG qua JSON), tách DTO response + entity rõ ràng vẫn tốt hơn. Lock B45: tránh dùng skip_serializing, ưu tiên tách struct.

Use case skip_deserializing phổ biến hơn — field server-generated mà client KHÔNG được set. Ví dụ id của entity:

#[derive(Serialize, Deserialize)]
pub struct Product {
    #[serde(skip_deserializing)]
    pub id: ProductId,  // server tự generate, client gửi sẽ bị bỏ qua

    pub name: String,
    pub price: Money,
}

Khi client POST {"id": 999, "name": "iPhone", "price": "25000000.00"}:

  • Field id trong JSON input bị bỏ qua, ProductId init qua Default (cần impl).
  • Server gen ID mới qua DB INSERT ... RETURNING id.
  • Response serialize trả id thật.

Field tương tự server-generated: created_at, updated_at, slug (nếu auto-generate từ name).

Pitfall: client gửi id không gây error — bị bỏ qua silent. Một số API muốn reject explicit (vd "id" not allowed in request body"); lúc đó dùng #[serde(deny_unknown_fields)] trên struct + có struct riêng KHÔNG có field id sẽ rõ ràng hơn. Lock B45 Shop API: tách struct CreateXxxDto + ResponseXxxDto thay vì share struct với skip_deserializing — semantic rõ và compile-time safe hơn.

5

skip_serializing_if — Skip Theo Điều Kiện

Attribute mạnh nhất trong nhóm skip — KHÔNG bỏ field cố định, mà theo predicate runtime. Use case kinh điển: Option<T> = None thì KHÔNG xuất field (tránh null verbose):

use serde::Serialize;
use chrono::{DateTime, Utc};

#[derive(Serialize)]
pub struct UserResponseDto {
    pub id: UserId,
    pub email: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub avatar_url: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub phone: Option<String>,
}

So sánh JSON wire khi field = None:

// KHÔNG skip_serializing_if:
{
  "id": 1,
  "email": "[email protected]",
  "avatar_url": null,
  "phone": null
}

// CÓ skip_serializing_if = "Option::is_none":
{
  "id": 1,
  "email": "[email protected]"
}

Pros của pattern này:

  • Wire gọn hơn — bandwidth saved 30-50% với response nhiều Option field (admin dashboard có 20+ field optional).
  • Client semantic rõ — field missing = "không có giá trị", thay null gây nhầm "có giá trị null" trong JS (JS distinguish undefined vs null).
  • Industry standard — Stripe API, GitHub API, Slack API đều omit null field response — đọc OpenAPI spec sẽ thấy nullable: false mặc định cho mọi optional response field.

Pattern lock Shop API: response DTO MANDATORY gắn #[serde(skip_serializing_if = "Option::is_none")] trên mọi Option field. KHÔNG omit attribute "vì lười" — wire format không nhất quán hại reputation API hơn lợi ích viết code nhanh.

Predicate khác — skip_serializing_if nhận đường dẫn tới function path fn(&T) -> bool:

#[derive(Serialize)]
pub struct ProductListResponse {
    pub items: Vec<ProductDto>,

    // Bỏ field "items" nếu vec empty
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub recommendations: Vec<ProductDto>,

    // Bỏ field nếu string empty
    #[serde(skip_serializing_if = "String::is_empty")]
    pub search_query: String,
}

Custom function predicate khi cần logic riêng:

fn is_zero(value: &u32) -> bool {
    *value == 0
}

#[derive(Serialize)]
pub struct CartDto {
    pub items_count: u32,

    #[serde(skip_serializing_if = "is_zero")]
    pub discount_amount: u32,  // omit khi không giảm giá
}

Pitfall: function predicate phải nhận &T reference (KHÔNG owned T) và trả bool — sai signature compile error khó đọc. Đặt function cùng module với struct để dễ trace.

6

#[serde(rename)] — Đổi Tên Field Wire

rename = "..." đổi tên field 1-1 — Rust giữ tên idiomatic, wire dùng tên khác. Use case 1: integrate third-party API có field naming khác convention Rust. GitHub User API trả field "login" thay vì "username":

#[derive(Serialize, Deserialize)]
pub struct GitHubUser {
    #[serde(rename = "login")]
    pub username: String,  // wire field "login" (GitHub API)

    #[serde(rename = "id")]
    pub github_id: u64,

    #[serde(rename = "node_id")]
    pub node_id: String,
}

Rust code dùng user.username idiomatic; wire JSON khớp GitHub spec:

{
  "login": "octocat",
  "id": 583231,
  "node_id": "MDQ6VXNlcjU4MzIzMQ=="
}

Use case 2: Rust keyword conflict. Vd field tên type hoặc self không hợp lệ trong Rust struct (reserved keyword):

#[derive(Serialize, Deserialize)]
pub struct Webhook {
    #[serde(rename = "type")]
    pub event_type: String,  // Rust `type` là keyword

    #[serde(rename = "self")]
    pub self_link: String,   // Rust `self` là keyword
}

Alternative: dùng raw identifier r#type (Rust 2018+) trong field name nhưng đọc xấu — convention dùng rename sạch hơn.

Mặc định rename apply cả 2 chiều (serialize và deserialize cùng tên wire). Khi cần khác nhau — dùng cú pháp tuple:

#[derive(Serialize, Deserialize)]
pub struct LegacyResponse {
    // Wire khi gửi: "userId", khi nhận: "user_id" hoặc "userId"
    #[serde(rename(serialize = "userId", deserialize = "user_id"))]
    pub user_id: u64,
}

Use case khác nhau theo chiều: API migration giai đoạn dual-write — wire output dùng tên mới "userId" nhưng wire input vẫn nhận tên cũ "user_id" để client cũ chưa migrate vẫn chạy. Sau khi 100% client migrate, gộp về 1 tên đồng nhất.

Note: nếu cần nhận nhiều tên input — dùng #[serde(alias = "...", alias = "...")] đa giá trị (sẽ deep dive ở B46). rename(deserialize) chỉ nhận 1 tên duy nhất.

7

#[serde(rename_all)] — Toàn Bộ Struct

rename_all convert tên field global cho whole struct theo 1 case style:

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserResponseDto {
    pub id: UserId,
    pub email_address: String,      // wire: "emailAddress"
    pub created_at: DateTime<Utc>,  // wire: "createdAt"
    pub login_count: u32,           // wire: "loginCount"
}

Tiện hơn gắn #[serde(rename = "...")] từng field. serde hỗ trợ 8 case style:

// Field Rust  | Case style              | Wire output
// my_field    | lowercase               | myfield
// my_field    | UPPERCASE               | MYFIELD
// my_field    | PascalCase              | MyField
// my_field    | camelCase               | myField           (JS standard)
// my_field    | snake_case              | my_field          (Python/Ruby standard)
// my_field    | SCREAMING_SNAKE_CASE    | MY_FIELD          (constant convention)
// my_field    | kebab-case              | my-field          (HTML attribute, URL slug)
// my_field    | SCREAMING-KEBAB-CASE    | MY-FIELD          (rare, HTTP header style)

Quy tắc convert: serde xử lý từ tên field gốc Rust (snake_case theo convention), parse thành "words", rồi format theo case đích. Field my_field_name → words ["my", "field", "name"]"myFieldName" (camelCase) hoặc "my-field-name" (kebab-case).

Shop API decision lock vĩnh viễn từ B45: wire format snake_case cho toàn hệ thống. Lý do:

  • Python/Ruby REST tradition — phần lớn REST API mainstream (Stripe, GitHub REST, Shopify, Twilio) dùng snake_case trên wire. camelCase phổ biến hơn ở GraphQL/Apollo nhưng không phải REST.
  • VN ecosystem — đa số REST API VN public (VietQR, MoMo, ZaloPay) dùng snake_case hoặc kebab-case; camelCase ít gặp.
  • Đồng bộ Rust idiomatic — Rust field naming convention là snake_case; wire snake_case khớp tự nhiên KHÔNG cần rename_all cho most struct, code gọn.
  • OpenAPI schema gen — schema field name khớp Rust struct field name, không phải convert qua lại tăng độ phức tạp tooling.

Hệ quả lock:

  • KHÔNG dùng #[serde(rename_all = "camelCase")] cho struct DTO Shop API — đồng nghĩa không bao giờ ép wire format camelCase dù client JS expect.
  • JSX client B85+ frontend handle convert ở layer axios interceptor hoặc tanstack-query transform — convert snake_case wire → camelCase JS local; component code vẫn dùng user.emailAddress camelCase tự nhiên trong React/Vue.
  • Chỉ dùng rename_all = "snake_case" cho enum variant — vì Rust enum variant convention là PascalCase, wire serialize mặc định "Stripe"/"BankTransfer"/"Cod" không khớp snake_case chuẩn wire; rename_all = "snake_case" convert thành "stripe"/"bank_transfer"/"cod". Lock B43 đã chốt cho enum.

Override per-field: nếu 1 field cần wire khác toàn struct, gắn #[serde(rename = "...")] đè rename_all:

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]  // global snake_case
pub struct LegacyDto {
    pub user_name: String,           // wire: "user_name"
    pub created_at: DateTime<Utc>,   // wire: "created_at"

    #[serde(rename = "ID")]
    pub id: u64,                     // wire: "ID" override
}
8

Apply: UserResponseDto Shop API

B45 lần đầu định nghĩa User domain Shop API — chỉ Response DTO + Internal Entity, KHÔNG full CRUD (đó là B104). Tạo file mới crates/shop-common/src/dto/user.rs theo pattern lock B41/B42/B43/B44 (mỗi domain 1 module submodule trong folder dto/):

// File: crates/shop-common/src/dto/user.rs
use chrono::{DateTime, Utc};
use serde::Serialize;

use super::UserId;

/// Response DTO cho `GET /api/v1/me` và `GET /api/v1/users/:id` (admin).
///
/// Pattern lock B45:
/// - impl `Serialize` only — response one-way, KHÔNG deserialize từ wire
/// - mọi `Option` field gắn `skip_serializing_if = "Option::is_none"`
///   tránh null verbose wire format
/// - KHÔNG include `password_hash`, `deleted_at`, `login_count` từ User entity
#[derive(Debug, Clone, Serialize)]
pub struct UserResponseDto {
    pub id: UserId,
    pub email: String,
    pub display_name: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub avatar_url: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub phone: Option<String>,

    pub created_at: DateTime<Utc>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_login_at: Option<DateTime<Utc>>,
}

/// Internal User entity — load từ DB row, dùng cross-layer (service, repo,
/// handler) nhưng KHÔNG bao giờ serialize ra wire.
///
/// Pattern lock B45 security:
/// - KHÔNG derive `Serialize` — compiler reject mọi attempt accident leak
/// - chứa field nhạy cảm (`password_hash`) và internal (`deleted_at`,
///   `login_count`) KHÔNG được xuất ra client
/// - convert sang `UserResponseDto` qua `From<User> for UserResponseDto`
///   trước khi trả từ handler
#[derive(Debug, Clone)]
pub struct User {
    pub id: UserId,
    pub email: String,
    pub password_hash: String,  // argon2id PHC string — server only
    pub display_name: String,
    pub avatar_url: Option<String>,
    pub phone: Option<String>,
    pub created_at: DateTime<Utc>,
    pub last_login_at: Option<DateTime<Utc>>,
    pub deleted_at: Option<DateTime<Utc>>,  // soft delete internal
}

/// Conversion explicit — chỉ copy field public, password_hash và deleted_at
/// KHÔNG vào ResponseDto. Pattern lock B45 security.
impl From<User> for UserResponseDto {
    fn from(user: User) -> Self {
        Self {
            id: user.id,
            email: user.email,
            display_name: user.display_name,
            avatar_url: user.avatar_url,
            phone: user.phone,
            created_at: user.created_at,
            last_login_at: user.last_login_at,
            // password_hash + deleted_at KHÔNG copy → security pattern
        }
    }
}

Update crates/shop-common/src/dto/mod.rs thêm submodule user + re-export top-level:

// File: crates/shop-common/src/dto/mod.rs (snippet — chỉ phần B45 thêm)
pub mod payment;
pub mod product;
pub mod types;
pub mod user;

pub use payment::PaymentMethod;
pub use product::{CreateProductDto, SLUG_REGEX, UpdateProductDto};
pub use types::{CategoryId, Money, OrderId, ProductId, UserId};
pub use user::{User, UserResponseDto};

Workspace deps thêm chrono (preview B53 chi tiết — bài hôm nay chỉ wire ra wire format):

# Cargo.toml workspace root
[workspace.dependencies]
chrono = { version = "0.4", features = ["serde"] }

Feature serde kích hoạt impl Serialize/Deserialize cho DateTime<Utc> dưới dạng RFC 3339 string (vd "2026-06-14T10:00:00Z") — lock JSON Format Policy B6 (RFC 3339 UTC suffix Z mọi timestamp). Consume qua chrono.workspace = trueshop-common.

JSON wire format response khi user mới register (chưa login, chưa avatar, chưa phone):

{
  "id": 1,
  "email": "[email protected]",
  "display_name": "Canh Nguyen",
  "created_at": "2026-06-14T10:00:00Z"
}

Ba field avatar_url, phone, last_login_at đều None → omit khỏi wire nhờ skip_serializing_if = "Option::is_none". JSON gọn, semantic rõ "field missing = no value".

Khi user upload avatar + verify phone + đăng nhập lần 2:

{
  "id": 1,
  "email": "[email protected]",
  "display_name": "Canh Nguyen",
  "avatar_url": "https://cdn.blogcode.vn/u/1/avatar.webp",
  "phone": "+84912345678",
  "created_at": "2026-06-14T10:00:00Z",
  "last_login_at": "2026-06-14T15:30:00Z"
}

Pattern lock Shop API:

  • Internal entity (User) KHÔNG impl Serialize — accident-proof. Dev nào quên rồi viết Json(user) trả thẳng entity sẽ bị compile error the trait `Serialize` is not implemented for `User`. Lỗi này không thể slip qua review hay deploy.
  • Response DTO (UserResponseDto) impl Serialize only — KHÔNG Deserialize vì response one-way (server → client). Dev nào define endpoint accept UserResponseDto làm input cũng bị compile error.
  • From<Entity> for ResponseDto convert explicit — KHÔNG dùng Into auto. Đọc code thấy ngay UserResponseDto::from(user) tại điểm convert; ai thêm field mới phải sửa From impl, không thể quên.

Verify compile module mới B45:

cd shop && cargo build -p shop-common
# Output:
# Compiling chrono v0.4.x
# Compiling shop-common v0.1.0
# Finished `dev` profile [unoptimized + debuginfo] target(s)

Suggested commit:

git add crates/shop-common/src/dto/user.rs \
        crates/shop-common/src/dto/mod.rs \
        Cargo.toml

git commit -m "B45: UserResponseDto + User entity dto/user.rs + chrono workspace dep"
9

Tổng Kết

  • 4 skip attribute mỗi loại có use case riêng:
    • skip — ẩn cả 2 chiều (yêu cầu Default cho field type)
    • skip_serializing — ẩn xuất nhưng vẫn nhận input
    • skip_deserializing — xuất output, KHÔNG nhận input (id, created_at, updated_at server-generated)
    • skip_serializing_if — ẩn theo predicate runtime (Option::is_none, Vec::is_empty, custom function)
  • skip_serializing_if = "Option::is_none" MANDATORY mọi Option field response DTO Shop API — wire gọn, semantic rõ, đồng nhất Stripe/GitHub/Shopify industry standard.
  • rename đổi tên field wire 1-1 (third-party API integration, Rust keyword conflict); cú pháp rename(serialize = "...", deserialize = "...") cho 2 chiều khác nhau (API migration dual-write).
  • rename_all convert global cho whole struct theo 8 case style; Shop API wire format snake_case lock vĩnh viễn KHÔNG dùng rename_all most struct.
  • rename_all = "snake_case" chỉ dùng cho enum variant (B43 lock) vì Rust enum variant là PascalCase mặc định.
  • Internal Entity vs Response DTO tách bạch — Entity KHÔNG impl Serialize (accident-proof), DTO impl Serialize only (response one-way); convert qua From<Entity> for ResponseDto explicit, KHÔNG Into auto.
  • UserResponseDto lock fields: id, email, display_name, avatar_url?, phone?, created_at, last_login_at?.
  • password_hash, deleted_at, login_count KHÔNG bao giờ vào response wire — security pattern lock vĩnh viễn.
  • File path lock B45: crates/shop-common/src/dto/user.rs — mỗi domain 1 module submodule (đồng nhất lock B41 product, B43 payment, B44 types).
  • Workspace deps thêm chrono = { version = "0.4", features = ["serde"] } cho DateTime<Utc> ISO 8601 wire format (preview B53 deep dive).
  • JSX client B85+ frontend handle conversion snake_case wire → camelCase JS local ở axios interceptor — không phải ép wire format camelCase từ backend.
  • Foundation cho B46 (custom serializer + deserializer manual impl), B53 (chrono DateTime ISO 8601 deep dive), B104 (CreateUserDto + UpdateUserDto register flow), B112 (auth handler return UserResponseDto sau verify password).
10

Bài Tập Củng Cố

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

  1. Phân biệt skip, skip_serializing, skip_deserializing, skip_serializing_if. Use case nào dùng cái nào? Cho ví dụ code mỗi loại.
  2. Field id của entity nên dùng skip attribute nào? Tại sao? Pitfall nếu client cố tình gửi id trong POST body là gì, và Shop API xử lý thế nào (lock B45)?
  3. skip_serializing_if = "Option::is_none" mang lại lợi ích gì cho wire format? Cho ví dụ JSON có và không attribute với response DTO 5 field optional (3 None, 2 Some).
  4. Shop API wire format chọn snake_case thay camelCase. Lý do và trade-off với client JS? Client React/Vue xử lý conversion ở đâu?
  5. Pattern Internal Entity vs Response DTO khác nhau ra sao? Tại sao Entity KHÔNG impl Serialize? Tại sao dùng From<Entity> explicit thay Into auto?
Đáp án
  1. Phân biệt 4 skip attribute — use case mỗi loại. skip: ẩn cả serialize VÀ deserialize. Field KHÔNG vào JSON output, KHÔNG nhận từ JSON input (init qua Default). Use case: field internal runtime cache, lazy field tính từ field khác.
    #[derive(Serialize, Deserialize)]
    pub struct Config {
        pub api_key: String,
    
        #[serde(skip)]
        pub cached_client: Option<HttpClient>,  // runtime cache
    }
    skip_serializing: KHÔNG xuất ra JSON nhưng VẪN deserialize từ input. Use case ít gặp — field load từ source khác (DB row JSON) cần ẩn ra wire response.
    #[derive(Serialize, Deserialize)]
    pub struct InternalRecord {
        pub id: u64,
    
        #[serde(skip_serializing)]
        pub raw_payload: serde_json::Value,  // load từ DB, không xuất wire
    }
    skip_deserializing: serialize ra JSON nhưng KHÔNG nhận từ input. Use case phổ biến: field server-generated (id, created_at, updated_at).
    #[derive(Serialize, Deserialize)]
    pub struct Product {
        #[serde(skip_deserializing)]
        pub id: ProductId,  // server gen, client gửi bị bỏ qua
    
        pub name: String,
        pub price: Money,
    }
    skip_serializing_if: ẩn theo predicate runtime. Use case kinh điển: Option<T> None thì omit field tránh null verbose.
    #[derive(Serialize)]
    pub struct UserDto {
        pub id: u64,
    
        #[serde(skip_serializing_if = "Option::is_none")]
        pub avatar_url: Option<String>,
    
        #[serde(skip_serializing_if = "Vec::is_empty")]
        pub tags: Vec<String>,
    }
    Quy tắc chọn: bí mật runtime → skip, field load 1 chiều input-only → skip_serializing, field server-gen output-only → skip_deserializing, field optional dạng Optionskip_serializing_if = "Option::is_none". Lock Shop API B45: ưu tiên tách struct DTO riêng thay vì share struct nhiều mục đích — chỉ skip_serializing_if là MANDATORY mọi Option field response DTO; 3 loại còn lại tránh dùng vì pattern struct tách bạch an toàn hơn.
  2. Field id entity nên dùng skip_deserializing — và lý do Shop API tách struct. Nếu giữ 1 struct entity cho cả input/output: skip_deserializing ẩn input nhưng vẫn xuất output. Server gen id qua DB INSERT ... RETURNING id; client gửi {"id": 999, ...} bị bỏ qua silent, init qua Default (nếu type impl).
    #[derive(Serialize, Deserialize)]
    pub struct Product {
        #[serde(skip_deserializing)]
        pub id: ProductId,
        pub name: String,
        pub price: Money,
    }
    Pitfall: (a) Client cố tình gửi id KHÔNG gây error — bị bỏ qua silent. API "lenient" nhưng client không biết payload bị reject (gọi POST với id: 999 hi vọng tạo entity ID cụ thể, nhận response ID 1 do server gen, confused). (b) ProductId phải impl Default để deserialize hoạt động — Shop API hiện chưa impl Default cho newtype ID (B44 lock 5 derive: Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize — KHÔNG Default). Phải thêm hoặc dùng #[serde(skip_deserializing, default = "...")] chỉ rõ function. (c) Reject explicit khi client gửi field không cho phép: dùng #[serde(deny_unknown_fields)] trên struct — nhưng phải có struct riêng KHÔNG có field id thì mới reject; nếu giữ field id với skip_deserializing thì deny_unknown_fields KHÔNG reject (field tồn tại trong struct, attribute chỉ skip parse value). Cách Shop API xử lý (lock B45): tách 2 struct riêng:
    // Input request body — KHÔNG có id (client KHÔNG được gửi)
    #[derive(Deserialize)]
    pub struct CreateProductDto {
        pub name: String,
        pub price: Money,
    }
    
    // Output response — CÓ id (server gen sau INSERT)
    #[derive(Serialize)]
    pub struct ProductResponseDto {
        pub id: ProductId,
        pub name: String,
        pub price: Money,
    }
    Pros: (a) compile-time safe — handler nhận ValidatedJson<CreateProductDto> KHÔNG có field id nên client gửi id bị deny_unknown_fields reject 400 (nếu enable) hoặc silent skip (nếu disable); (b) semantic rõ — đọc struct biết ngay request vs response shape; (c) tránh phụ thuộc Default impl cho ID newtype. Lock B45 + B41 pattern Shop API.
  3. skip_serializing_if = "Option::is_none" lợi ích wire — ví dụ JSON 5 field optional. KHÔNG attribute — verbose với null:
    {
      "id": 1,
      "email": "[email protected]",
      "display_name": "Canh",
      "avatar_url": null,
      "phone": null,
      "bio": null,
      "twitter_handle": "@canh",
      "website_url": "https://blogcode.vn"
    }
    Wire ~210 byte, 3 field null không mang giá trị. Client JS parse user.avatar_url === null phải xử lý case riêng vs undefined. skip_serializing_if = "Option::is_none" — gọn:
    {
      "id": 1,
      "email": "[email protected]",
      "display_name": "Canh",
      "twitter_handle": "@canh",
      "website_url": "https://blogcode.vn"
    }
    Wire ~130 byte (~38% saved). 3 field None omit khỏi wire — client check user.avatar_url === undefined tự nhiên hơn. Lợi ích: (a) bandwidth saved 30-50% với response nhiều Option field — admin dashboard 20+ field optional response 50KB còn 25KB; (b) semantic rõ "field missing = no value" — JS distinguish undefined (field absent) vs null (field present với value null); (c) industry standard Stripe/GitHub/Shopify/Slack omit null field response — đọc OpenAPI spec sẽ thấy nullable: false mặc định cho optional response field; (d) wire format đẹp hơn cho debugging, log review. Lock Shop API B45: MANDATORY #[serde(skip_serializing_if = "Option::is_none")] mọi Option field response DTO — KHÔNG omit attribute "vì lười"; wire không nhất quán hại reputation API hơn lợi ích viết nhanh.
  4. Shop API chọn snake_case thay camelCase — lý do và trade-off client JS. Lý do chọn snake_case: (a) Industry tradition REST — Stripe (customer_id, payment_intent_id), GitHub REST (node_id, created_at), Shopify (order_number, line_items), Twilio đều dùng snake_case; camelCase phổ biến hơn ở GraphQL/Apollo nhưng không REST mainstream. (b) VN ecosystem — đa số API VN public dùng snake_case/kebab-case; cộng đồng Rust VN cũng quen Python/Ruby convention. (c) Đồng bộ Rust idiomatic — Rust field convention là snake_case, wire snake_case khớp tự nhiên KHÔNG cần rename_all most struct → code gọn hơn 1 dòng/struct. (d) OpenAPI schema gen — field name khớp Rust struct trực tiếp, tooling đơn giản hơn convert qua lại. Trade-off với client JS: React/Vue developer quen user.emailAddress camelCase tự nhiên; wire snake_case ép phải viết user.email_address trong component code mất idiomatic JS. Cách xử lý lock B85+ frontend: convert ở axios interceptor hoặc tanstack-query select transform:
    // Tương đương TypeScript pseudo-code:
    // axios.interceptors.response.use(response => {
    //   response.data = camelCaseKeys(response.data, { deep: true });
    //   return response;
    // });
    //
    // Client component vẫn dùng:
    // const { data: user } = useUser();
    // user.emailAddress  // camelCase tự nhiên JS
    Library: camelcase-keys, humps, hoặc viết function recursive. Cost: 1-time setup interceptor, sau đó component code giữ camelCase JS idiomatic. Trade-off cuối: chọn snake_case wire ưu tiên backend consistency + industry standard + Rust idiomatic; frontend bear chi phí 1-time conversion layer. Lock Shop API B45 vĩnh viễn: wire format snake_case, KHÔNG bao giờ ép camelCase; chỉ dùng rename_all = "snake_case" cho enum variant (B43 lock).
  5. Pattern Internal Entity vs Response DTO — vì sao tách và From explicit. Khác nhau: Internal Entity (User) load từ DB row, dùng cross-layer service/repo/handler, chứa field nhạy cảm (password_hash) + internal (deleted_at, login_count). Response DTO (UserResponseDto) trả về client wire JSON, chỉ field public, mọi Option gắn skip_serializing_if. Tại sao Entity KHÔNG impl Serialize: accident-proof security pattern. Dev nào quên rồi viết Json(user) trong handler trả entity thẳng sẽ bị compile error the trait `Serialize` is not implemented for `User`. Lỗi không thể slip qua review hay deploy — compiler enforce. Nếu Entity derive Serialize, accident có thể slip qua review nếu reviewer không để ý field nhạy cảm; production leak password hash là disaster.
    // Internal entity — accident-proof
    #[derive(Debug, Clone)]  // KHÔNG Serialize
    pub struct User {
        pub id: UserId,
        pub email: String,
        pub password_hash: String,
        pub deleted_at: Option<DateTime<Utc>>,
    }
    
    // Compile error nếu accident:
    async fn handler() -> Json<User> {  // ❌ User KHÔNG impl Serialize
        let user = repo.find_user(1).await?;
        Json(user)  // ERROR: trait bound `User: Serialize` not satisfied
    }
    Tại sao dùng From explicit thay Into auto: visibility + maintainability. From<User> for UserResponseDto impl rõ ràng trong code:
    impl From<User> for UserResponseDto {
        fn from(user: User) -> Self {
            Self {
                id: user.id,
                email: user.email,
                display_name: user.display_name,
                avatar_url: user.avatar_url,
                phone: user.phone,
                created_at: user.created_at,
                last_login_at: user.last_login_at,
                // password_hash + deleted_at KHÔNG copy → đọc code thấy ngay
            }
        }
    }
    Pros: (a) Đọc code thấy ngay — convert tại điểm UserResponseDto::from(user) explicit call site; reviewer trace dễ. (b) Add field mới phải sửa From — Rust compile error nếu thêm field UserResponseDtoFrom chưa initialize; dev không thể quên copy field từ entity sang DTO. (c) Add field nhạy cảm KHÔNG accident leak — thêm field biometric_hash vào entity, From giữ nguyên không copy, response DTO không có field — security pattern continued. Into auto qua blanket impl impl<T, U> Into<U> for T where U: From<T> hoạt động sau khi define From nhưng KHÔNG visible trong code đọc (user.into() ẩn type đích, phải đọc context); dùng explicit UserResponseDto::from(user) rõ hơn. Lock Shop API B45 vĩnh viễn: (i) Internal Entity KHÔNG impl Serialize accident-proof, (ii) Response DTO impl Serialize only response one-way, (iii) From<Entity> for ResponseDto explicit conversion KHÔNG Into auto — đảm bảo security + maintainability + reviewer-friendly code.
11

Bài Tiếp Theo

— manual impl serde::Serializer/Deserializer cho format custom, dùng Visitor pattern, áp Vietnamese phone format normalize (0912...+84912...) cho phone field DTO Shop API.