Mục lục
- Mục Tiêu Bài Học
- 3 Trạng Thái Field JSON Mà Backend Phải Phân Biệt
Option<T>Mặc Định — Gộp Missing Và Null#[serde(default)]Cho Field Primitive Có Default Value#[serde(deserialize_with)]— Custom Deserializer- Double-Option
Option<Option<T>>— Phân Biệt Missing Vs Null UpdateProductDtoShop API Với PATCH Semantic- Verify Với Curl
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- 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 →Nonetự động, missing → ERRORmissing fieldnếu không kèm thêm attribute. - Áp
#[serde(default)]no-arg cho field có giá trị mặc định quaT::default(), và#[serde(default = "fn_name")]trỏ tới custom functionfn() -> T. - Dùng
#[serde(deserialize_with = "fn_name")]cho custom deserializer parse format không chuẩn (string"1,000,000"thànhu64 1000000) khiFrom/TryFromkhô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
UpdateProductDtoShop API trongshop-common::dto::productvới PATCH semantic chuẩn — match 3 armNone|Some(None)|Some(Some(v))trong handler. - Refactor file đơn
crates/shop-common/src/dto.rsđã có ở B41 thành folderdto/mỗi domain 1 module (product.rs, sau nàyuser.rs,order.rs,cart.rs). - Lock helper
deserialize_optional_fieldởshop-common::dto::mod— KHÔNG duplicate cross-crate.
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 columndescription = NULLtrong 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 columndescription = '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 missing và null 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.
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(vdname,price,stock) → dùngOption<T>single +#[serde(default)]— đủ phân biệt 2 case (không update / update giá trị mới). - Field DB
NULLABLE(vddescription,discount_code,parent_id) → dùng double-OptionOption<Option<T>>— phân biệt 3 case (không update / set NULL / update giá trị mới).
#[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ùngT::default()từ traitstd::default::Default. VớiOption<T>default làNone; vớiu32/i64/f64default là0; vớiStringdefault là chuỗi rỗng; vớiVec<T>default là vec rỗng.#[serde(default = "fn_name")]— gọi custom function. Function PHẢI là path tớifn() -> TKHÔNG argument, return type khớp type field. Function visibility tối thiểufnmodule-level đủ; KHÔNG cầnpub. 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 implDefaultmatch 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.
#[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 paramD: Deserializer<'de>— lifetime'delà "lifetime of the deserializer input" serde framework cung cấp. - Return type
Result<FieldType, D::Error>— error type lấy từ deserializer (serde_json::Errorkhi parse JSON,serde_urlencoded::Errorkhi parse query string). - Body 3 bước: (1) decode raw từ deserializer qua
String::deserialize(deserializer)?hoặcValue::deserialize, (2) transform sang type target, (3) map error quaserde::de::Error::custom(msg)nếu transform fail.
Phân biệt deserialize_with vs From/TryFrom:
From/TryFromchạ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_withchạy trong context serde — có access trực tiếp tớiDeserializertrait 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'dephứ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::Decimalprecision 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.
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ọiOption::<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>::deserializedecode 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_optionmodule — crateserde_withcung cấp sẵn helper tương đương. Shop API chọn impl tự helper trongshop-common::dtovì: (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ỡ patternOptionstandard library, cần implDeserializethủ công + JSON encoding không tự nhiên. Double-Option dùng type sẵn có Rust, ergonomic hơn cho pattern match.
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:
- Xóa file
crates/shop-common/src/dto.rs. - Tạo folder
crates/shop-common/src/dto/. - Tạo file
crates/shop-common/src/dto/mod.rs— chứa re-export module con + helperdeserialize_optional_fieldmodule-level. - Tạo file
crates/shop-common/src/dto/product.rs— chứaCreateProductDto+SLUG_REGEX(move từdto.rscũ) + NEWUpdateProductDto. crates/shop-common/src/lib.rsgiữ nguyên dòngpub mod dto;— Rust compiler auto detect folder thay file qua quy tắcdto/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êngPOST /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 khiSome(v). VdOption<String>với rulelength(min = 3, max = 200):Nonebypass,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)
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 name → description 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"
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 →Nonetự động, missing → ERRORmissing field; thêm#[serde(default)]để missing trở thànhNone.#[serde(default)]no-arg dùngT::default();#[serde(default = "fn_name")]dùng custom function pathfn() -> Tkhô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 signaturefn(D) -> Result<T, D::Error>vớiD: 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_fieldlock ởshop-common::dto::mod— body 1 dòngOption::<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 filedto.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. UpdateProductDtoShop API đặt ởshop-common::dto::productcùngCreateProductDto— slug KHÔNG có trong PATCH DTO (natural key resource identifier không cho đổi qua PATCH).
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
Option<T>mặc định xử lý missing và null khác nhau ra sao? Vì sao cần thêm#[serde(default)]để missing trở thànhNone? Cho ví dụ input JSON trả ERROR khi không có attribute này.#[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?- 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ì. - Khi nào dùng
Option<T>single, khi nào dùngOption<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. - PATCH
{"description": null}vs PATCH{}(không có keydescription) 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
Option<T>mặc định xử lý missing vs null. Hành vi serde default: với field kiểuOption<T>, serde deserialize value JSONnullthànhNonetự động (quaimpl 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ỗimissing field `name` at line X column Yquaserde::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 keydescription) →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}để đượcNone— phiền cho client. Lý do cần#[serde(default)]: attribute này instruct serde "nếu key missing, dùngDefault::default()của field type thay vì trả error". VớiOption<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ùngNone— không phân biệt được "không update" vs "set NULL". Convention lock:#[serde(default)]bắt buộc cho mọi fieldOption<T>trong PATCH DTO Shop API — không có exception (lock B42).- 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-levelfn 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ểufnmodule-level đủ — derive macro generate code trong cùng module nên truy cập được private function;pubchỉ cần khi function dùng cross-module. Path string literal: tên function trong attribute là string (vd"default_page"), serde dùngsyn::parseresolve thành path Rust — sai tên fail compile với messagecannot find function `default_page` in this scope. Function vs no-arg form:#[serde(default)]no-arg dùngT::default()qua traitDefault— phù hợp khi default match impl trait sẵn có (vdOption::default() == None,Vec::default() == vec![],String::default() == "");#[serde(default = "fn_name")]dùng khi default KHÔNG làT::default()(vdpagedefault1không phải0,per_pagedefault20). 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 quaDto::default()nếu implDefaultmatch function path đặt ở attribute; (c) Documentation tool sinh OpenAPI spec (utoipa B8) đọc được default từ#[serde(default = "...")]attribute để export"default": 1trong 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 defaultpage = 1handler khác defaultpage = 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. - Double-Option
Option<Option<T>>mapping 3 trạng thái. Cấu trúc kết hợp: field typeOption<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ọiOption::default()trảNonengay, custom deserializer KHÔNG chạy; nếu key có (null hoặc value) → serde gọideserialize_optional_fieldđể decode. Inner Option phân biệt null vs value qua deserializer logic: function gọiOption::<T>::deserialize(deserializer)decode tiếp value JSON đã chọn — null thànhOk(None), value thànhOk(Some(v)); sau đó wrap qua.map(Some)thành outerOk(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 B66ProductService::update):
3 nhánh khác biệt rõ semantic: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 } }Nonekhô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ớinon-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). - Quy tắc chọn Option theo cột DB + 3 ví dụ Shop domain. Quy tắc lock B42: (a) DB column NOT NULL →
Option<T>single +#[serde(default)]. Lý do: chỉ cần phân biệt 2 case — "không update" (Nonequa default kích hoạt khi missing, hoặc null explicit cũng được decode thànhNonenhư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ĩaSome(None)không có giá trị business. (b) DB column NULLABLE →Option<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 fieldNonehoặc innerNone, chỉ check khi có giá trị Some(v) cụ thể. 3 ví dụ field nullable Shop domain cần double-Option: (1)products.descriptionTEXT 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_codeVARCHAR(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_idBIGINT 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.nameVARCHAR(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.priceBIGINT NOT NULL — giá không cho NULL, default 0 hoặc giá trị thực; (iii)users.emailVARCHAR(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ý caseSome(None)không có nghĩa business, dễ confuse code reviewer; service layer build SQLSET name = NULLsẽ 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. - 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ànhdto.description = Some(None)qua double-Option deserializer; service G7 B66 build SQLUPDATE products SET description = NULL WHERE slug = 'iphone-15'; DB execute → columndescriptionSET về NULL. Mất data cũ — không thể recover trừ khi có audit log/soft delete. (b) PATCH{}— extract thànhdto.description = Nonequa default kích hoạt (key missing); service skip fielddescriptiontrong 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: columndescriptiongiữ 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/descriptionkhô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 singleOption<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êngDELETE /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 valuenull; modified (user nhập text mới) → gửi key với value text. Frontend logic: tracktouchedflag 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.
Bài Tiếp Theo
Bài 43: JSON Enum — Internally/Externally/Adjacently Tagged — 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.
