Danh sách bài viết

Bài 42: JSON Field Pitfalls — Optional, Default, Null

Bài 42 của series Rust RESTful API — đi sâu pitfall serde JSON khi field có thể missing, null, hoặc có giá trị: JSON spec không phân biệt missing vs null về giá trị nhưng REST API phải phân biệt cho semantic HTTP PATCH (RFC 5789 + RFC 7396 merge-patch) — {"description": null} nghĩa là xóa field (SET DB column NULL) còn thiếu key description nghĩa là giữ nguyên (không update field này); phân tích 3 cấp deserializer serde Option<T> mặc định (gộp null → None nhưng missing trả ERROR cần thêm #[serde(default)] để missing → None), #[serde(default)] no-arg dùng T::default() vs #[serde(default = "fn_name")] custom function path trỏ fn() -> T không argument, #[serde(deserialize_with = "fn_name")] custom deserializer chạy trong context serde có access tới Deserializer cho format không chuẩn (parse string "1,000,000" → u64 1000000); core pattern lock double-Option Option<Option<T>> kết hợp #[serde(default, deserialize_with = "deserialize_optional_field")] phân biệt 3 trạng thái — outer Option bằng default kích hoạt biến missing → None, inner Option kết quả deserializer chạy trên null → Some(None), value → Some(Some(value)); mapping ra handler match 3 arm rõ ràng None = không update, Some(None) = SET column NULL, Some(Some(v)) = SET column value; áp pattern thực tế cho refactor file đơn crates/shop-common/src/dto.rs đã có B41 promote thành folder structure crates/shop-common/src/dto/{mod,product}.rs mỗi domain 1 module (lock vĩnh viễn cho user/order/cart tương lai), dto/mod.rs chứa re-export module con + helper module-level pub fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error> lock cross-crate KHÔNG duplicate, dto/product.rs chứa CreateProductDto + SLUG_REGEX giữ nguyên từ B41 + NEW UpdateProductDto với 4 field minh họa quy tắc chọn (name: Option<String> single vì cột name DB NOT NULL, description: Option<Option<String>> double vì cột description DB NULLABLE, price: Option<u64> single, stock: Option<u32> single) đều dùng #[validate(...)] bên trong rule cho giá trị Some + #[serde(default)] hoặc #[serde(default, deserialize_with = "deserialize_optional_field")]; handler update_product trong crates/shop-api/src/routes/products.rs đổi signature từ skeleton B21 (Path(slug)) -> Json<Value> sang preview (State, AppPath(slug), ValidatedJson(dto): ValidatedJson<UpdateProductDto>) -> AppResult<Json<ProductDto>> với business logic placeholder, route wire vẫn giữ .patch(update_product) chưa enable end-to-end (chờ G7 service layer); verify 4 curl test trên endpoint PATCH /api/v1/products/:slug minh họa 4 nhánh — chỉ update name giữ nguyên description (missing), set description: null xóa mô tả (SET NULL), set description value mới (SET value), custom format price: "1,000,000" qua deserialize_with parse thành u64 1000000 (preview pattern B53 DateTime + B54 Decimal). Lock vĩnh viễn từ B42: pattern folder dto/ mỗi domain 1 module, helper deserialize_optional_field đặt ở shop-common::dto::mod KHÔNG duplicate cross-crate, quy tắc chọn single vs double Option theo cột DB (NOT NULL → Option<T>, NULLABLE → Option<Option<T>>) MANDATORY mọi PATCH DTO, semantic PATCH chuẩn missing = giữ nguyên / null = xóa / value = set RFC 7396, handler match 3 arm None | Some(None) | Some(Some(v)). Workspace state change: refactor 1 file thành 2 file mới (shop-common/src/dto.rs → shop-common/src/dto/mod.rs + shop-common/src/dto/product.rs), 1 file updated (shop-api/src/routes/products.rs handler update_product preview), shop-common/src/lib.rs giữ nguyên pub mod dto; (Rust auto-detect folder thay file). Suggested commit: B42: PATCH semantic với double-Option pattern + UpdateProductDto + refactor dto/ folder.

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

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

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

  • Phân biệt 3 trạng thái field JSON: missing (không có key), null (có key nhưng giá trị null), có giá trị — và tại sao REST API phải tách rời 3 case này cho semantic HTTP PATCH.
  • Hiểu cách Option<T> xử lý null và missing trong serde mặc định — null → None tự động, missing → ERROR missing field nếu không kèm thêm attribute.
  • Áp #[serde(default)] no-arg cho field có giá trị mặc định qua T::default(), và #[serde(default = "fn_name")] trỏ tới custom function fn() -> T.
  • Dùng #[serde(deserialize_with = "fn_name")] cho custom deserializer parse format không chuẩn (string "1,000,000" thành u64 1000000) khi From/TryFrom không đủ.
  • Hiểu pattern double-Option Option<Option<T>> kết hợp #[serde(default, deserialize_with = "deserialize_optional_field")] phân biệt missing vs null cho HTTP PATCH partial update RFC 7396.
  • Implement UpdateProductDto Shop API trong shop-common::dto::product với PATCH semantic chuẩn — match 3 arm None | Some(None) | Some(Some(v)) trong handler.
  • Refactor file đơn crates/shop-common/src/dto.rs đã có ở B41 thành folder dto/ mỗi domain 1 module (product.rs, sau này user.rs, order.rs, cart.rs).
  • Lock helper deserialize_optional_fieldshop-common::dto::mod — KHÔNG duplicate cross-crate.
2

3 Trạng Thái Field JSON Mà Backend Phải Phân Biệt

Field description: String trong API có thể nhận 3 dạng input JSON khác biệt từ client:

// 1) Missing — không có key "description"
{ "name": "iPhone 15" }

// 2) Null — có key nhưng giá trị null
{ "name": "iPhone 15", "description": null }

// 3) Có giá trị
{ "name": "iPhone 15", "description": "Flagship Apple 2024" }

JSON spec RFC 8259 không phân biệt missing vs null về mặt giá trị — cả hai đều có thể coi là "không có dữ liệu". Nhưng REST API có ý nghĩa khác biệt rõ ràng cho HTTP PATCH partial update theo RFC 5789 + RFC 7396 (JSON Merge Patch):

  • PATCH với description: null = client yêu cầu xóa mô tả sản phẩm — server SET column description = NULL trong DB.
  • PATCH thiếu description = client yêu cầu giữ nguyên — server KHÔNG update column này, giá trị cũ giữ y nguyên.
  • PATCH với description: "Mô tả mới" = client yêu cầu set giá trị mới — server SET column description = 'Mô tả mới'.

Backend Rust phải decode đúng 3 trạng thái này thành kiểu Rust khác biệt để query SQL ra đúng. Nếu serde gộp missingnull về cùng giá trị (mặc định Option<T> làm vậy), backend mất khả năng phân biệt — không biết client muốn xóa hay giữ nguyên.

HTTP PUT khác PATCH: PUT là full replace toàn bộ resource — mọi field bắt buộc gửi đủ, missing tương đương null tương đương "xóa". PATCH là partial update — chỉ field có mặt mới update; missing = không động đến.

Shop API lock semantic PATCH chuẩn RFC 7396 cho mọi endpoint update tương lai (PATCH /api/v1/products/:slug, PATCH /api/v1/me, PATCH /api/v1/cart/items/:id, PATCH /api/v1/admin/orders/:id/status). Decode đúng 3 trạng thái là yêu cầu nền tảng — pattern code lock từ bài này.

3

Option<T> Mặc Định — Gộp Missing Và Null

Hành vi mặc định của serde với field kiểu Option<T>: deserialize null thành None tự động, nhưng nếu key thiếu trong JSON thì trả ERROR missing field. Khác kỳ vọng thông thường, missing KHÔNG tự động hóa thành None.

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct PatchProductDto {
    description: Option<String>,
}

Test 3 input minh họa:

// Input 1: {"description": null}
// Result: Ok(PatchProductDto { description: None })

// Input 2: {"description": "abc"}
// Result: Ok(PatchProductDto { description: Some("abc") })

// Input 3: {} — không có key
// Result: Err("missing field `description` at line 1 column 2")

Pattern này phù hợp với HTTP PUT (full replace — mọi field bắt buộc) nhưng KHÔNG đủ cho HTTP PATCH (partial update — field thiếu = giữ nguyên, không phải lỗi).

Thêm #[serde(default)] để missing trả về None thay ERROR:

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct PatchProductDto {
    #[serde(default)]
    description: Option<String>,
}

// Input 1: {"description": null} → description: None
// Input 2: {"description": "abc"} → description: Some("abc")
// Input 3: {}                     → description: None (default kích hoạt)

Giờ Option<T> + #[serde(default)] gộp missing và null về cùng None. Đây là pattern đủ dùng cho PATCH khi field DB NOT NULL — chỉ cần phân biệt "không update" (None) vs "update giá trị mới" (Some). Còn khi field DB NULLABLE phải tách thêm "set NULL" vs "không update" — đó là lý do phải dùng double-Option ở Bước 6.

Quy tắc lock áp dụng cho mọi PATCH DTO Shop API:

  • Field DB NOT NULL (vd name, price, stock) → dùng Option<T> single + #[serde(default)] — đủ phân biệt 2 case (không update / update giá trị mới).
  • Field DB NULLABLE (vd description, discount_code, parent_id) → dùng double-Option Option<Option<T>> — phân biệt 3 case (không update / set NULL / update giá trị mới).
4

#[serde(default)] Cho Field Primitive Có Default Value

Use case khác của #[serde(default)]: field có giá trị mặc định cụ thể KHÔNG phải None — ví dụ query string phân trang có page + per_page mặc định khi client không gửi:

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct ProductListQuery {
    #[serde(default = "default_page")]
    page: u32,

    #[serde(default = "default_per_page")]
    per_page: u32,

    #[serde(default)]
    search: Option<String>,
}

fn default_page() -> u32 { 1 }
fn default_per_page() -> u32 { 20 }

2 form syntax:

  • #[serde(default)] no-arg — dùng T::default() từ trait std::default::Default. Với Option<T> default là None; với u32/i64/f64 default là 0; với String default là chuỗi rỗng; với Vec<T> default là vec rỗng.
  • #[serde(default = "fn_name")] — gọi custom function. Function PHẢI là path tới fn() -> T KHÔNG argument, return type khớp type field. Function visibility tối thiểu fn module-level đủ; KHÔNG cần pub. Path là string literal — compiler resolve lúc derive macro expand.

Pattern lock cho mọi query string Shop API: default value đặt ở DTO qua #[serde(default = "fn_name")] — KHÔNG hardcode trong handler body. Lý do:

  • DTO là source-of-truth cho contract API — đọc 1 file biết toàn bộ default.
  • Test fixture dễ tạo qua ProductListQuery::default() nếu impl Default match function path.
  • Documentation tool (utoipa B8) sinh OpenAPI spec đọc được default từ DTO attribute.

Cross-reference: B23 Pagination struct trong shop_common::pagination đã áp pattern này với default_page/default_size; B42 reuse cùng convention cho mọi query DTO tương lai.

5

#[serde(deserialize_with)] — Custom Deserializer

Use case: field nhận format không chuẩn cần custom logic parse — vd client gửi giá tiền dạng string có dấu phẩy ngăn cách hàng nghìn "1,000,000" cần parse thành u64 1000000:

use serde::{Deserialize, Deserializer};

#[derive(Debug, Deserialize)]
struct CreateOrderDto {
    #[serde(deserialize_with = "parse_amount_with_comma")]
    amount: u64,
}

fn parse_amount_with_comma<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    s.replace(',', "")
        .parse::<u64>()
        .map_err(serde::de::Error::custom)
}

Signature function chuẩn lock vĩnh viễn:

  • Generic <'de, D> với lifetime 'de + type param D: Deserializer<'de> — lifetime 'de là "lifetime of the deserializer input" serde framework cung cấp.
  • Return type Result<FieldType, D::Error> — error type lấy từ deserializer (serde_json::Error khi parse JSON, serde_urlencoded::Error khi parse query string).
  • Body 3 bước: (1) decode raw từ deserializer qua String::deserialize(deserializer)? hoặc Value::deserialize, (2) transform sang type target, (3) map error qua serde::de::Error::custom(msg) nếu transform fail.

Phân biệt deserialize_with vs From/TryFrom:

  • From/TryFrom chạy ngoài context serde — convert 2 type bất kỳ. Nếu chỉ cần wrap String thành newtype không cần parse phức tạp, dùng #[serde(try_from = "String")] trên struct + impl TryFrom<String> for MyType.
  • deserialize_with chạy trong context serde — có access trực tiếp tới Deserializer trait method. Dùng khi cần parse format non-trivial (regex, multi-format fallback), khi function tái sử dụng cross-DTO không gắn với 1 type, hoặc khi cần xử lý lifetime 'de phức tạp (zero-copy borrow).

Use case khác trong Shop API tương lai:

  • B53 parse DateTime format không chuẩn (client mobile gửi "2026-06-14 10:30:00" thay "2026-06-14T10:30:00Z" RFC 3339) — fallback parse 2 format trong cùng function.
  • B54 parse rust_decimal::Decimal precision cao cho money từ string (tránh float precision loss khi serialize qua JSON Number).
  • B62 parse multi-format giá tiền VND (admin upload CSV có dấu phẩy / dấu chấm / không dấu — tolerant input).

Pattern deserialize_with là nền tảng để xây helper deserialize_optional_field cho double-Option ở Bước 6.

6

Double-Option Option<Option<T>> — Phân Biệt Missing Vs Null

Vấn đề cốt lõi PATCH RFC 7396 nêu ở Bước 2: client cần 3 nhánh khác biệt cho 1 field nullable trong DB:

  • "Không update field này" (key missing) — DB giữ nguyên value cũ.
  • "Set field về NULL" (key có với value null) — DB SET column = NULL.
  • "Set field về giá trị mới" (key có với value cụ thể) — DB SET column = value.

Single Option<T> + #[serde(default)] ở Bước 3 gộp missing và null về None — không tách được 2 nhánh đầu. Solution: double-Option Option<Option<T>> kết hợp deserialize_with custom:

use serde::{Deserialize, Deserializer};

#[derive(Debug, Deserialize)]
struct PatchProductDto {
    #[serde(default, deserialize_with = "deserialize_optional_field")]
    description: Option<Option<String>>,
    //          ^^^^^^^^^^^^^^^^^^^^^^
    //          outer Option = missing or present
    //                 ^^^^^^^^^^^^
    //                 inner Option = null or value
}

pub fn deserialize_optional_field<'de, T, D>(
    deserializer: D,
) -> Result<Option<Option<T>>, D::Error>
where
    T: Deserialize<'de>,
    D: Deserializer<'de>,
{
    Option::<T>::deserialize(deserializer).map(Some)
}

Logic hoạt động 3 case:

  • Missing: serde thấy key không có → #[serde(default)] kích hoạt → field nhận giá trị Option::default() tức là None — outer Option = None. Custom deserializer KHÔNG chạy.
  • Null: serde thấy key có với value null → bypass default → chạy deserialize_optional_field. Function gọi Option::<T>::deserialize(deserializer) decode null → Ok(None), sau đó .map(Some) wrap thêm 1 lớp ngoài → Ok(Some(None)).
  • Có giá trị: serde thấy key có với value cụ thể → chạy deserialize_optional_field. Option::<T>::deserialize decode value → Ok(Some(value)), .map(Some) wrap → Ok(Some(Some(value))).

Mapping tổng kết:

// Input JSON                              | Output Rust
// {}                                       | None
// {"description": null}                    | Some(None)
// {"description": "Mô tả mới"}             | Some(Some("Mô tả mới"))

Handler match 3 nhánh rõ ràng:

match dto.description {
    None => {
        // Không update field này — bỏ qua, không thêm vào SET clause SQL
    }
    Some(None) => {
        // Client yêu cầu xóa — SET description = NULL trong DB
        sql_set_parts.push("description = NULL");
    }
    Some(Some(value)) => {
        // Client yêu cầu set giá trị mới — SET description = $N
        sql_set_parts.push(format!("description = ${}", bind_index));
        bind_values.push(value);
    }
}

Pattern lock Shop API: mọi PATCH DTO có field nullable trong DB MANDATORY dùng double-Option + helper deserialize_optional_field đặt ở shop-common::dto::mod để cross-crate share (Bước 7 setup folder structure).

Alternative cân nhắc nhưng KHÔNG chọn:

  • serde_with::rust::double_option module — crate serde_with cung cấp sẵn helper tương đương. Shop API chọn impl tự helper trong shop-common::dto vì: (a) tránh thêm 1 workspace dep cho 1 function 5 dòng, (b) helper có doc-comment kiểm soát được lock B42, (c) giảm coupling với external crate version drift.
  • Custom enum 3 variant enum FieldUpdate<T> { Skip, SetNull, SetValue(T) } — semantic rõ hơn nhưng phá vỡ pattern Option standard library, cần impl Deserialize thủ công + JSON encoding không tự nhiên. Double-Option dùng type sẵn có Rust, ergonomic hơn cho pattern match.
7

UpdateProductDto Shop API Với PATCH Semantic

B41 đã tạo file đơn crates/shop-common/src/dto.rs chứa CreateProductDto + SLUG_REGEX. B42 refactor thành folder structure để mỗi domain (product, user, order, cart) 1 module riêng — pattern lock vĩnh viễn từ B42.

Bước refactor:

  1. Xóa file crates/shop-common/src/dto.rs.
  2. Tạo folder crates/shop-common/src/dto/.
  3. Tạo file crates/shop-common/src/dto/mod.rs — chứa re-export module con + helper deserialize_optional_field module-level.
  4. Tạo file crates/shop-common/src/dto/product.rs — chứa CreateProductDto + SLUG_REGEX (move từ dto.rs cũ) + NEW UpdateProductDto.
  5. crates/shop-common/src/lib.rs giữ nguyên dòng pub mod dto; — Rust compiler auto detect folder thay file qua quy tắc dto/mod.rs.

crates/shop-common/src/dto/mod.rs:

// File: crates/shop-common/src/dto/mod.rs
use serde::{Deserialize, Deserializer};

pub mod product;

pub use product::{CreateProductDto, UpdateProductDto, SLUG_REGEX};

/// Helper deserialize cho field double-Option phân biệt 3 trạng thái
/// missing / null / value trong HTTP PATCH RFC 7396 merge-patch.
///
/// Mapping:
/// - missing → `None` (outer default kích hoạt, function KHÔNG chạy)
/// - null    → `Some(None)` (function chạy, decode null thành inner None)
/// - value   → `Some(Some(value))` (function chạy, decode value thành inner Some)
///
/// Lock B42: dùng cho mọi PATCH DTO Shop API có field DB nullable
/// (description, discount_code, parent_id, deleted_at, ...).
pub fn deserialize_optional_field<'de, T, D>(
    deserializer: D,
) -> Result<Option<Option<T>>, D::Error>
where
    T: Deserialize<'de>,
    D: Deserializer<'de>,
{
    Option::<T>::deserialize(deserializer).map(Some)
}

crates/shop-common/src/dto/product.rs:

// File: crates/shop-common/src/dto/product.rs
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use validator::Validate;

use crate::dto::deserialize_optional_field;

/// Regex slug kebab-case lowercase — lock vĩnh viễn cho mọi slug Shop API
/// (product, category, blog post, page). Pattern: bắt đầu bằng a-z hoặc 0-9,
/// có thể có nhiều segment cách nhau bởi dấu gạch ngang, mỗi segment chỉ chứa
/// a-z và 0-9. Không cho phép viết hoa, khoảng trắng, dấu chấm, gạch dưới.
pub static SLUG_REGEX: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"^[a-z0-9]+(-[a-z0-9]+)*$").expect("invalid SLUG_REGEX pattern")
});

/// DTO cho `POST /api/v1/products` (lock B41).
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
pub struct CreateProductDto {
    #[validate(length(min = 3, max = 200, message = "tên sản phẩm dài 3-200 ký tự"))]
    pub name: String,

    #[validate(regex(
        path = *SLUG_REGEX,
        message = "slug chỉ chứa chữ thường, số và dấu gạch ngang"
    ))]
    pub slug: String,

    #[validate(range(min = 1, max = 100_000_000, message = "giá phải lớn hơn 0 và không vượt 100 triệu VND"))]
    pub price: u64,

    #[validate(range(min = 0, max = 1_000_000, message = "stock phải từ 0 đến 1.000.000"))]
    pub stock: u32,
}

/// DTO cho `PATCH /api/v1/products/:slug` (lock B42 — partial update RFC 7396).
///
/// Quy tắc chọn Option single vs double:
/// - DB column NOT NULL → `Option<T>` + `#[serde(default)]` — phân biệt 2 case
///   (None = không update / Some(v) = update giá trị mới).
/// - DB column NULLABLE → `Option<Option<T>>` + `#[serde(default, deserialize_with)]`
///   — phân biệt 3 case (None = không update / Some(None) = SET NULL / Some(Some(v)) = SET value).
#[derive(Debug, Clone, Deserialize, Validate)]
pub struct UpdateProductDto {
    /// `name` DB NOT NULL → single Option đủ.
    #[serde(default)]
    #[validate(length(min = 3, max = 200, message = "tên sản phẩm dài 3-200 ký tự"))]
    pub name: Option<String>,

    /// `description` DB NULLABLE → double Option để phân biệt missing vs null.
    #[serde(default, deserialize_with = "deserialize_optional_field")]
    pub description: Option<Option<String>>,

    /// `price` DB NOT NULL → single Option đủ.
    #[serde(default)]
    #[validate(range(min = 1, max = 100_000_000, message = "giá phải lớn hơn 0 và không vượt 100 triệu VND"))]
    pub price: Option<u64>,

    /// `stock` DB NOT NULL → single Option đủ.
    #[serde(default)]
    #[validate(range(min = 0, max = 1_000_000, message = "stock phải từ 0 đến 1.000.000"))]
    pub stock: Option<u32>,
}

Chi tiết lock B42:

  • SLUG không có trong UpdateProductDto — slug là natural key resource identifier (đứng trong URL /api/v1/products/:slug), KHÔNG cho phép đổi qua PATCH. Đổi slug = redirect 301 endpoint riêng POST /api/v1/admin/products/:slug/rename (lock G14).
  • Validate vẫn áp dụng cho Option — validator crate auto skip rule khi field None, chỉ check khi Some(v). Vd Option<String> với rule length(min = 3, max = 200): None bypass, Some("") fail length min, Some("abc") pass.
  • Double-Option không validate inner None — rule chỉ áp khi inner Some(v). Some(None) (set NULL) luôn pass — đúng semantic vì SET NULL không vi phạm length/range rule.

Handler update_product trong crates/shop-api/src/routes/products.rs đổi signature từ skeleton B21 sang preview pattern G7+:

// File: crates/shop-api/src/routes/products.rs (refactor B42 update_product)
use axum::extract::State;
use shop_common::dto::UpdateProductDto;
use shop_common::error::AppResult;

use crate::extractors::{AppPath, ValidatedJson};
use crate::state::AppState;

async fn update_product(
    State(state): State<AppState>,
    AppPath(slug): AppPath<String>,
    ValidatedJson(dto): ValidatedJson<UpdateProductDto>,
) -> AppResult<axum::Json<ProductDto>> {
    tracing::info!(
        port = state.config.port,
        slug = %slug,
        "patching product"
    );

    // Bước business logic — sẽ implement đầy đủ G7 B66 với
    // state.product_service.update(&slug, dto).await — service layer
    // build dynamic SQL UPDATE qua QueryBuilder match 3 case mỗi field
    // (None | Some(None) | Some(Some(v))) phân biệt skip/SET NULL/SET value.
    // Hiện tại preview: build ProductDto skeleton cho compile pass.
    let product = ProductDto {
        id: 1,
        slug: slug.clone(),
        name: dto.name.unwrap_or_else(|| "iPhone 15".to_string()),
        price: dto.price.unwrap_or(25_000_000),
        stock: dto.stock.unwrap_or(10),
    };

    Ok(axum::Json(product))
}

Pattern handler match 3 case (preview cho G7 B66 service layer):

// Preview ProductService::update (G7 B66) — minh họa logic phân biệt 3 case
fn build_update_sql(slug: &str, dto: UpdateProductDto) -> (String, Vec<Value>) {
    let mut set_clauses: Vec<String> = Vec::new();
    let mut binds: Vec<Value> = Vec::new();
    let mut idx = 1;

    // name: Option<String> (single — DB NOT NULL)
    if let Some(name) = dto.name {
        set_clauses.push(format!("name = ${}", idx));
        binds.push(Value::String(name));
        idx += 1;
    }

    // description: Option<Option<String>> (double — DB NULLABLE)
    match dto.description {
        None => {} // missing — skip, không thêm vào SET
        Some(None) => {
            set_clauses.push("description = NULL".to_string());
        }
        Some(Some(value)) => {
            set_clauses.push(format!("description = ${}", idx));
            binds.push(Value::String(value));
            idx += 1;
        }
    }

    // price, stock tương tự name (single Option)
    // ...

    let sql = format!(
        "UPDATE products SET {} WHERE slug = ${} RETURNING *",
        set_clauses.join(", "),
        idx
    );
    binds.push(Value::String(slug.to_string()));

    (sql, binds)
}

Lock pattern dynamic SQL UPDATE B66 áp dụng cho mọi PATCH endpoint Shop API: build SET clause incremental dựa trên trạng thái mỗi field, skip field None, append = NULL cho Some(None), append = $N + bind value cho Some(Some(v)).

Verify compile:

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

Verify Với Curl

Chạy server:

cd shop && cargo run -p shop-api
# Output:
# shop-api listening addr=0.0.0.0:3000

4 test minh họa 4 nhánh PATCH semantic. Handler hiện preview skeleton (chưa wire DB G7) — verify focus vào extract + validate flow đúng cho mỗi case input.

Test 1: PATCH chỉ update namedescription giữ nguyên (missing → None):

curl -i -X PATCH http://localhost:3000/api/v1/products/iphone-15 \
  -H 'Content-Type: application/json' \
  -d '{"name":"iPhone 15 Pro"}'

# HTTP/1.1 200 OK
# content-type: application/json
# x-request-id: 550e8400-e29b-41d4-a716-446655440000
#
# {"id":1,"slug":"iphone-15","name":"iPhone 15 Pro","price":25000000,"stock":10}

Extract: name = Some("iPhone 15 Pro"), description = None, price = None, stock = None. Service layer G7 B66 chỉ append name = $1 vào SET clause, các field khác skip — DB giữ nguyên description/price/stock cũ.

Test 2: PATCH set description: null → xóa mô tả (SET NULL):

curl -i -X PATCH http://localhost:3000/api/v1/products/iphone-15 \
  -H 'Content-Type: application/json' \
  -d '{"description":null}'

# HTTP/1.1 200 OK
# content-type: application/json
#
# {"id":1,"slug":"iphone-15","name":"iPhone 15","price":25000000,"stock":10}

Extract: description = Some(None) qua double-Option deserializer. Service G7 append description = NULL vào SET clause — DB UPDATE products SET description = NULL WHERE slug = 'iphone-15'.

Test 3: PATCH set description giá trị mới:

curl -i -X PATCH http://localhost:3000/api/v1/products/iphone-15 \
  -H 'Content-Type: application/json' \
  -d '{"description":"Mô tả mới iPhone 15"}'

# HTTP/1.1 200 OK
# content-type: application/json
#
# {"id":1,"slug":"iphone-15","name":"iPhone 15","price":25000000,"stock":10}

Extract: description = Some(Some("Mô tả mới iPhone 15")). Service append description = $1 bind value — DB SET column value.

Test 4: PATCH price string format custom với deserialize_with (preview pattern — yêu cầu thêm #[serde(deserialize_with = "parse_amount_with_comma")] vào field price nếu muốn enable trên Shop API, hiện UpdateProductDto chưa wire vì giá VND nguyên đơn vị không cần phân cách):

curl -i -X PATCH http://localhost:3000/api/v1/products/iphone-15 \
  -H 'Content-Type: application/json' \
  -d '{"price":"1,000,000"}'

# HTTP/1.1 400 Bad Request (default — price field type u64 không nhận string)
# {
#   "error": "bad request: JSON data error: invalid type: string \"1,000,000\", expected u64",
#   "code": "BAD_REQUEST",
#   "request_id": "..."
# }

Để Test 4 trả 200 với price: 1_000_000 cần thêm #[serde(deserialize_with = "parse_amount_with_comma")] vào field price trong UpdateProductDto kèm function helper. Pattern này là preview cho B53 (DateTime) và B54 (Decimal) — nơi format không chuẩn là use case chính.

Suggested commit:

git add crates/shop-common/src/dto/mod.rs \
        crates/shop-common/src/dto/product.rs \
        crates/shop-api/src/routes/products.rs

git rm crates/shop-common/src/dto.rs

git commit -m "B42: PATCH semantic với double-Option pattern + UpdateProductDto + refactor dto/ folder"
9

Tổng Kết

  • 3 trạng thái field JSON: missing (không có key) / null (key có value null) / value (key có value cụ thể) — REST API phải phân biệt cho semantic HTTP PATCH RFC 5789 + RFC 7396.
  • Option<T> mặc định: null → None tự động, missing → ERROR missing field; thêm #[serde(default)] để missing trở thành None.
  • #[serde(default)] no-arg dùng T::default(); #[serde(default = "fn_name")] dùng custom function path fn() -> T không argument — default value đặt ở DTO, KHÔNG hardcode trong handler.
  • #[serde(deserialize_with = "fn_name")] cho custom deserializer parse format không chuẩn — function signature fn(D) -> Result<T, D::Error> với D: Deserializer<'de>.
  • Option<Option<T>> double-Option pattern: kết hợp #[serde(default, deserialize_with = "deserialize_optional_field")] phân biệt missing → None / null → Some(None) / value → Some(Some(value)).
  • Helper deserialize_optional_field lock ở shop-common::dto::mod — body 1 dòng Option::<T>::deserialize(deserializer).map(Some) — cross-crate share KHÔNG duplicate.
  • Quy tắc chọn Option: DB column NOT NULL → Option<T> single (2 case); DB column NULLABLE → Option<Option<T>> double (3 case).
  • PATCH semantic chuẩn RFC 7396: missing = giữ nguyên (skip), null = xóa (SET NULL), value = set giá trị mới (SET value).
  • File path lock B42: crates/shop-common/src/dto/mod.rs + crates/shop-common/src/dto/product.rs — promote single file dto.rs ở B41 thành folder structure mỗi domain 1 module.
  • Handler match 3 arm rõ ràng: None = không update / Some(None) = SET NULL / Some(Some(v)) = SET value — service layer G7 B66 build dynamic SQL UPDATE incremental dựa trên 3 trạng thái.
  • UpdateProductDto Shop API đặt ở shop-common::dto::product cùng CreateProductDto — slug KHÔNG có trong PATCH DTO (natural key resource identifier không cho đổi qua PATCH).
10

Bài Tập Củng Cố

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

  1. Option<T> mặc định xử lý missingnull khác nhau ra sao? Vì sao cần thêm #[serde(default)] để missing trở thành None? Cho ví dụ input JSON trả ERROR khi không có attribute này.
  2. #[serde(default = "fn_name")] yêu cầu function signature gì? Function có argument không? Lock convention vị trí default value ở DTO hay handler — vì sao?
  3. Double-Option Option<Option<T>> map 3 trạng thái JSON ra sao? Liệt kê đầy đủ match arm trong handler PATCH với mô tả ngắn mỗi nhánh tương ứng SQL gì.
  4. Khi nào dùng Option<T> single, khi nào dùng Option<Option<T>> double? Quy tắc theo cột DB. Cho 3 ví dụ field nullable thật trong Shop domain cần double-Option.
  5. PATCH {"description": null} vs PATCH {} (không có key description) khác nhau gì trong SQL UPDATE thực tế? Tại sao client form UI cần 2 endpoint khác hay 1 endpoint xử lý 2 case này?
Đáp án
  1. Option<T> mặc định xử lý missing vs null. Hành vi serde default: với field kiểu Option<T>, serde deserialize value JSON null thành None tự động (qua impl Deserialize for Option<T> sẵn có), nhưng nếu key KHÔNG xuất hiện trong JSON object (missing) thì trả lỗi missing field `name` at line X column Y qua serde::de::Error. Lý do design: Option<T> chỉ semantic "value có thể null", KHÔNG semantic "field có thể bỏ qua" — 2 concept tách rời trong JSON spec mặc dù gần gũi. Ví dụ input JSON trả ERROR khi không có default: struct #[derive(Deserialize)] struct Dto { description: Option<String> } + input {"name": "abc"} (thiếu key description) → serde_json::from_str::<Dto>(input) trả Err(Error { code: Message("missing field `description`"), line: 1, column: 14 }). Phải gửi explicit {"description": null} để được None — phiền cho client. Lý do cần #[serde(default)]: attribute này instruct serde "nếu key missing, dùng Default::default() của field type thay vì trả error". Với Option<T>, Option::default() == None, nên missing → None. Sau khi thêm, 3 case input đều hợp lệ: {}None, {"description": null}None, {"description": "abc"}Some("abc"). Pattern này đủ cho PUT (full replace — mọi field bắt buộc) nhưng KHÔNG đủ cho PATCH có field nullable trong DB vì gộp missing và null về cùng None — không phân biệt được "không update" vs "set NULL". Convention lock: #[serde(default)] bắt buộc cho mọi field Option<T> trong PATCH DTO Shop API — không có exception (lock B42).
  2. Function signature #[serde(default = "fn_name")]. Signature bắt buộc: fn fn_name() -> T — không argument, return type khớp type field áp attribute. Function có thể là path tới function đơn lẻ (module-level fn default_page() -> u32 { 1 }) hoặc associated function (SomeType::default_value) — serde derive macro resolve path string literal tại compile time. Lý do không argument: serde không có context để truyền data lúc field missing — function phải tự sinh giá trị từ hằng số hoặc gọi system clock/random (nếu cần). Function được call 0..N lần per deserialize: 0 lần nếu key xuất hiện (kể cả với null), 1 lần nếu key missing. Performance: function nên là pure + cheap (vd trả hằng số, KHÔNG đọc env var/file mỗi lần) vì có thể call nhiều lần trên list/map deserialize. Visibility: tối thiểu fn module-level đủ — derive macro generate code trong cùng module nên truy cập được private function; pub chỉ cần khi function dùng cross-module. Path string literal: tên function trong attribute là string (vd "default_page"), serde dùng syn::parse resolve thành path Rust — sai tên fail compile với message cannot find function `default_page` in this scope. Function vs no-arg form: #[serde(default)] no-arg dùng T::default() qua trait Default — phù hợp khi default match impl trait sẵn có (vd Option::default() == None, Vec::default() == vec![], String::default() == ""); #[serde(default = "fn_name")] dùng khi default KHÔNG là T::default() (vd page default 1 không phải 0, per_page default 20). Lock convention vị trí default value: ở DTO struct definition qua attribute, KHÔNG hardcode trong handler body như let page = query.page.unwrap_or(1);. Lý do: (a) DTO là source-of-truth contract API — đọc 1 file biết toàn bộ default behavior, không phải tra cứu 10 handler scattered; (b) Test fixture đơn giản qua Dto::default() nếu impl Default match function path đặt ở attribute; (c) Documentation tool sinh OpenAPI spec (utoipa B8) đọc được default từ #[serde(default = "...")] attribute để export "default": 1 trong schema component — client SDK gen code (TypeScript, Kotlin) sẽ có default đồng bộ; (d) Refactor: đổi default value 1 chỗ trong DTO áp toàn handler; hardcode rải rác trong handler dễ inconsistent (vd 1 handler default page = 1 handler khác default page = 0); (e) Validate rule (B41 validator crate) áp thẳng lên field type T chứ KHÔNG áp lên giá trị unwrap trong handler — đặt default ở DTO đảm bảo validation chạy nhất quán cả case missing.
  3. Double-Option Option<Option<T>> mapping 3 trạng thái. Cấu trúc kết hợp: field type Option<Option<T>> + attribute #[serde(default, deserialize_with = "deserialize_optional_field")]. Outer Option phân biệt missing vs present qua #[serde(default)]: nếu key JSON không có (missing) → serde gọi Option::default() trả None ngay, custom deserializer KHÔNG chạy; nếu key có (null hoặc value) → serde gọi deserialize_optional_field để decode. Inner Option phân biệt null vs value qua deserializer logic: function gọi Option::<T>::deserialize(deserializer) decode tiếp value JSON đã chọn — null thành Ok(None), value thành Ok(Some(v)); sau đó wrap qua .map(Some) thành outer Ok(Some(...)). Mapping bảng đầy đủ: {} (key missing) → None | {"x": null} (key có null) → Some(None) | {"x": "abc"} (key có value) → Some(Some("abc")). Match arm trong handler PATCH kèm SQL tương ứng (preview G7 B66 ProductService::update):
    match dto.description {
        None => {
            // Missing — không thêm vào SET clause SQL
            // SQL effect: UPDATE products SET ... (không có description = ...) WHERE slug = $N
            // DB: giá trị description cũ giữ nguyên
        }
        Some(None) => {
            // Null explicit — xóa column
            set_clauses.push("description = NULL".to_string());
            // SQL effect: UPDATE products SET description = NULL WHERE slug = $N
            // DB: column description SET về NULL
        }
        Some(Some(value)) => {
            // Value cụ thể — set giá trị mới
            set_clauses.push(format!("description = ${}", bind_idx));
            binds.push(value);
            bind_idx += 1;
            // SQL effect: UPDATE products SET description = $1 WHERE slug = $2
            // DB: column description SET = value
        }
    }
    3 nhánh khác biệt rõ semantic: None không động đến column (skip), Some(None) SET column = NULL (xóa data), Some(Some(v)) SET column = value (cập nhật). Exhaustiveness check: Rust compiler ép match 3 nhánh — quên 1 nhánh fail compile với non-exhaustive patterns: ... not covered; tránh được bug "quên xử lý case null" thường gặp ở backend dynamic-typed (Node.js, Python, PHP).
  4. Quy tắc chọn Option theo cột DB + 3 ví dụ Shop domain. Quy tắc lock B42: (a) DB column NOT NULLOption<T> single + #[serde(default)]. Lý do: chỉ cần phân biệt 2 case — "không update" (None qua default kích hoạt khi missing, hoặc null explicit cũng được decode thành None nhưng tương đương semantic) vs "update giá trị mới" (Some(v)). Client gửi null cho field NOT NULL không có ý nghĩa — DB constraint sẽ reject; định nghĩa Some(None) không có giá trị business. (b) DB column NULLABLEOption<Option<T>> double + #[serde(default, deserialize_with = "deserialize_optional_field")]. Lý do: cần phân biệt 3 case — "không update" / "set NULL" / "set value mới"; nếu dùng single Option sẽ mất khả năng phân biệt "không update" vs "set NULL". (c) Validate vẫn áp dụng cho cả 2 form — validator crate auto skip rule khi field None hoặc inner None, chỉ check khi có giá trị Some(v) cụ thể. 3 ví dụ field nullable Shop domain cần double-Option: (1) products.description TEXT NULLABLE: mô tả sản phẩm — admin có thể tạo product chưa có mô tả (NULL), sau đó update thêm mô tả (set value), hoặc gỡ mô tả nếu sai (set NULL). UpdateProductDto.description: Option<Option<String>>. (2) orders.discount_code VARCHAR(50) NULLABLE: mã giảm giá order — user checkout có thể không nhập (NULL), apply mã (set value), hoặc remove mã đã apply trước khi pay (set NULL). UpdateCartDto.discount_code: Option<Option<String>>. (3) categories.parent_id BIGINT NULLABLE: ID category cha trong hierarchy tree — category root có parent_id = NULL, category con có parent_id = N; admin có thể move category sang parent khác (set value), promote category lên root (set NULL), hoặc không đổi cấu trúc (skip). UpdateCategoryDto.parent_id: Option<Option<i64>>. 3 ví dụ field NOT NULL chỉ cần single Option: (i) products.name VARCHAR(200) NOT NULL — tên không cho NULL (DB constraint), update name = set giá trị mới hoặc không động đến; (ii) products.price BIGINT NOT NULL — giá không cho NULL, default 0 hoặc giá trị thực; (iii) users.email VARCHAR(254) NOT NULL UNIQUE — email luôn phải có để user login. Anti-pattern cảnh báo: dùng double-Option cho field NOT NULL → handler phải xử lý case Some(None) không có nghĩa business, dễ confuse code reviewer; service layer build SQL SET name = NULL sẽ fail DB constraint runtime → 500 lỗi cho client thay vì 400/422 validate sớm. Quy tắc derive: schema DB là source-of-truth — DTO PATCH PHẢI mirror nullable constraint chính xác.
  5. PATCH {"description": null} vs PATCH {} — khác biệt SQL + form UI. Khác biệt SQL: (a) PATCH {"description": null} — extract trong Rust thành dto.description = Some(None) qua double-Option deserializer; service G7 B66 build SQL UPDATE products SET description = NULL WHERE slug = 'iphone-15'; DB execute → column description SET về NULL. Mất data cũ — không thể recover trừ khi có audit log/soft delete. (b) PATCH {} — extract thành dto.description = None qua default kích hoạt (key missing); service skip field description trong SET clause; SQL thực tế UPDATE products SET (các field khác có giá trị Some) WHERE slug = 'iphone-15'; nếu DTO không có field nào Some (PATCH body hoàn toàn rỗng), SQL UPDATE không có SET clause → service nên skip query luôn trả 304 Not Modified hoặc 400 Bad Request "no fields to update". DB: column description giữ nguyên giá trị cũ. Khác biệt semantic: case (a) client chủ động muốn xóa mô tả (vd admin xóa mô tả sản phẩm bị flagged), case (b) client chỉ update field khác (vd đổi tên + giá nhưng giữ mô tả). Tại sao 1 endpoint xử lý 2 case (KHÔNG 2 endpoint khác): (1) Atomicity — admin form sửa product cho phép user đổi nhiều field 1 lúc (đổi tên + xóa mô tả + giảm giá) trong 1 request → 1 transaction DB, hoặc rollback toàn bộ nếu lỗi; nếu tách endpoint xóa mô tả riêng (DELETE /api/v1/products/:slug/description), client phải gọi 3 request (tên, xóa mô tả, giá) → 3 transaction → có thể partial fail (sửa tên OK nhưng xóa mô tả fail → state inconsistent). (2) Network overhead — 1 request HTTP round-trip thay 3 — giảm latency 3x cho admin batch update. (3) Audit log — 1 entry audit "admin user X update product Y" thay 3 entry rời rạc — dễ trace history. (4) RFC 7396 standard — JSON Merge Patch lock semantic này là pattern industry-wide; mọi REST client tooling (Postman, Insomnia, axios) hiểu sẵn. (5) URL design — không có sub-resource đại diện cho field nullable; /products/:slug/description không phải resource độc lập (không có lifecycle riêng, không có own URL identity), tách endpoint là over-engineering vi phạm REST. Anti-pattern: backend dùng single Option<T> gộp 2 case → client gửi {"description": null} mà server không xóa (vì gộp về None tương đương missing skip), buộc client phải tạo endpoint riêng DELETE /api/v1/products/:slug/description → API surface phình to + vi phạm RFC 7396. Solution lock B42: dùng double-Option ngay từ đầu cho mọi PATCH DTO có field nullable, semantic chuẩn 1 endpoint xử lý 3 case rõ ràng. Client form UI: SPA admin trang sửa product, mỗi input có thể có 3 trạng thái: untouched (user không sửa) → KHÔNG gửi key vào body PATCH (missing); cleared (user xóa nội dung input) → gửi key với value null; modified (user nhập text mới) → gửi key với value text. Frontend logic: track touched flag mỗi input, cleared là touched && value === "", modified là touched && value !== "". JSON body PATCH build từ form state map đúng 3 trạng thái — 1 endpoint xử lý nhất quán.
11

Bài Tiếp Theo

— chi tiết serde enum representation: untagged (auto-detect theo shape), externally tagged default ({"variant": {...}}), internally tagged qua #[serde(tag = "type")] ({"type": "X", ...payload}), adjacently tagged qua #[serde(tag = "type", content = "data")] ({"type": "X", "data": {...}}); pitfall mỗi loại (untagged ambiguity + slow parse, internally tagged collision với field type, externally tagged verbose); áp PaymentMethod enum (Stripe / COD / BankTransfer) cho Shop API checkout B62-B68 + đặt ở shop-common::dto::payment theo folder structure lock B42.