Danh sách bài viết

Bài 23: Query Parameters Với Query<T>

Bài 23 của series Rust RESTful API — chi tiết Query<T> extractor trong axum 0.8: parse query string ?key=value&key2=value2 qua serde_urlencoded với T impl Deserialize; Option<T> cho field optional (missing key trả None, present parse T); #[serde(default = "fn_name")] + helper function (default_page, default_size) cho default value áp dụng cho pagination chuẩn page=1 size=20; multi-value query ?tag=red&tag=blue với axum default Query chỉ lấy giá trị CUỐI mất giá trị đầu — fix bằng serde_qs crate cho Vec<T>; phân biệt empty value ?key= (key có, string rỗng) và missing key ?other=foo (key không có, Option None) — anti-pattern handler treat empty = missing, fix qua validate ở DTO layer (B41 validator crate); tạo file mới crates/shop-common/src/pagination.rs với Pagination struct (page, size + method offset()/limit() cap 100 anti-DoS) + ListResponse<T> envelope chuẩn cho list endpoint { items, total, page, size, hasNext } (rename camelCase); refactor list_products dùng Query<Pagination> trả ListResponse skeleton chuẩn bị G7 implement đầy đủ; decision lock Shop API: serde_qs cho multi-value endpoint, single-value giữ axum default Query; custom AppQuery<T> rejection wrapper TBD B32 tương tự AppPath<T>.

2 lượt xem
1

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

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

  • Biết extract ?key=value&key2=value2 qua Query<T> với T impl Deserialize.
  • Nắm Pagination struct chuẩn: field page, size với #[serde(default)] + helper function fallback giá trị mặc định.
  • Hiểu Option<T> cho field optional — phân biệt missing (None) vs present (Some).
  • Biết handle multi-value query ?tag=red&tag=blue qua serde_qs crate vì axum default chỉ lấy 1 giá trị.
  • Phân biệt empty value ?key=missing key ? không có key — anti-pattern + fix qua validate DTO layer.
  • Tạo crates/shop-common/src/pagination.rs với Pagination + ListResponse<T> chuẩn cho mọi list endpoint Shop API.
  • Refactor handler list_products dùng Query<Pagination> trả ListResponse skeleton (chuẩn bị G7 implement đầy đủ với sqlx).
2

Query<T> Cốt Lõi

axum::extract::Query<T> là extractor parse query string (phần URL sau dấu ?) sang struct T Rust qua crate serde_urlencoded bên dưới. Yêu cầu duy nhất với T: implement trait Deserialize — thường derive qua #[derive(Deserialize)].

Format query string tuân RFC 3986: cặp key=value ngăn cách bởi &, ví dụ ?category=phone&min_price=500. Key và value đều percent-encoded; serde_urlencoded tự decode trước khi map sang field struct.

use axum::{extract::Query, response::Json};
use serde::Deserialize;
use serde_json::json;

#[derive(Debug, Deserialize)]
struct Filter {
    category: String,
    min_price: f64,
}

// Route: /products
async fn list_products(Query(filter): Query<Filter>) -> Json<serde_json::Value> {
    tracing::info!(?filter, "listing products");
    Json(json!({
        "category": filter.category,
        "min_price": filter.min_price,
    }))
}

Pattern destructure Query(filter): Query<Filter> giống Path ở B22 — Query là tuple struct 1 field, destructure ngay trên signature để lấy giá trị bên trong, dùng trực tiếp filter trong body handler.

Parse fail (vd request ?category=phone&min_price=abc với min_price: f64): axum mặc định trả 400 Bad Request với body plain text dạng "Failed to deserialize query string: invalid digit found in string". Tương tự Path, status đúng nhưng body không phải JSON envelope chuẩn Shop API { error, code, request_id } đã lock từ B3.

Shop API decision: override default rejection bằng custom AppQuery<T> wrapper map sang AppError::BadRequest + envelope JSON chuẩn — pattern tương tự AppPath<T> lock B22, chính thức impl ở B32 (Custom Extractor) cùng đợt với AppPathAppJson. B23 dùng axum::extract::Query default cho skeleton handler.

3

Optional Field Với Option<T>

Filter endpoint thực tế (vd GET /api/v1/products?category=phone&min_price=500&in_stock=true) hiếm khi yêu cầu client gửi đủ mọi field. Đa số filter là optional — client gửi field nào thì server filter theo field đó.

Khai báo field Option<T> trong struct: serde tự hiểu missing key → None, present key → Some(T) sau parse.

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct ProductFilter {
    category: Option<String>,
    min_price: Option<f64>,
    in_stock: Option<bool>,
}

async fn list_products(Query(filter): Query<ProductFilter>) -> Json<serde_json::Value> {
    // filter.category: None hoặc Some("phone")
    // filter.min_price: None hoặc Some(500.0)
    // filter.in_stock: None hoặc Some(true)
    Json(json!({
        "category": filter.category,
        "min_price": filter.min_price,
        "in_stock": filter.in_stock,
    }))
}

Behavior với 3 request mẫu:

Request                                  →  filter
-----------------------------------------+--------------------------------
GET /products                            →  category: None
                                            min_price: None
                                            in_stock: None
GET /products?category=phone             →  category: Some("phone")
                                            min_price: None
                                            in_stock: None
GET /products?category=phone&min_price=500 →  category: Some("phone")
                                              min_price: Some(500.0)
                                              in_stock: None

Handler dùng match hoặc if let để build SQL dynamic — chỉ append WHERE clause cho field Some. Pattern lock cho filter endpoint Shop API: catalog public (B98 Dynamic Filter Query G10 deep dive với sqlx::QueryBuilder).

Lưu ý: Option<T> KHÔNG cần #[serde(default)] attribute — serde mặc định cho phép missing field nếu type là Option<T>. Combine Option<T> với #[serde(default)] chỉ cần khi muốn giá trị mặc định KHÁC None (vd default Some(false) cho boolean), trường hợp này hiếm — thường đã chuyển sang dùng T trực tiếp với default value (xem bước 4).

4

serde(default) Với Helper Function

Pagination là trường hợp đặc biệt: field page, size KHÔNG nên là Option<u32> vì handler luôn cần một con số cụ thể để query OFFSET/LIMIT SQL. Dùng u32 trực tiếp + default value là pattern chuẩn — nếu client không gửi, fallback giá trị sensible (vd page=1, size=20).

Attribute #[serde(default = "fn_name")] chỉ định helper function trả default value khi key missing. Helper function ngoài struct, trả type matching field, không nhận tham số.

use serde::Deserialize;

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

    #[serde(default = "default_size")]
    pub size: u32,
}

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

Behavior với 3 request mẫu:

Request                          →  Pagination
---------------------------------+--------------------------
GET /products                    →  page: 1,  size: 20  (cả hai default)
GET /products?page=3&size=50     →  page: 3,  size: 50  (cả hai từ query)
GET /products?page=3             →  page: 3,  size: 20  (size default)
GET /products?size=50            →  page: 1,  size: 50  (page default)

Lý do dùng helper function thay vì attribute literal #[serde(default = "1")]: serde attribute default CHỈ chấp nhận tên function (path), KHÔNG chấp nhận giá trị literal. Helper function cho phép logic phức tạp nếu cần (đọc env, compute từ env config), reuse cross-struct, test riêng dễ.

Biến thể đơn giản #[serde(default)] (không có function name) gọi Default::default() — trả 0 cho u32. KHÔNG dùng cho pagination vì page=0 + size=0 là invalid (SQL OFFSET 0 LIMIT 0 trả 0 row, behavior khó hiểu cho client). Luôn dùng default = "fn_name" với function trả giá trị sensible.

Shop API decision lock: pattern Pagination trên là CHUẨN cho mọi list endpoint Shop API. File chính thức ở bước 7 — crates/shop-common/src/pagination.rs. Range validation (page >= 1, size <= 100) qua method offset()/limit() (bước 7) thay vì validator crate ở DTO layer — đơn giản hơn, gắn liền semantic SQL.

5

Multi-Value Query — ?tag=red&tag=blue

Use case phổ biến filter catalog: client gửi nhiều tag cùng tên, mỗi tag một giá trị. Ví dụ GET /api/v1/products?tag=red&tag=blue&tag=large — ý đồ "lọc product có tag red HOẶC blue HOẶC large".

Pitfall: axum default Query<T> dùng serde_urlencoded bên dưới — crate này theo spec application/x-www-form-urlencoded chỉ giữ giá trị CUỐI khi gặp key trùng. Field tag: String chỉ nhận "large", mất "red""blue" — silent bug, không error compile/runtime.

// ANTI-PATTERN — axum default Query KHÔNG support Vec
#[derive(Deserialize)]
struct ProductFilter {
    tag: Vec<String>,  // serde_urlencoded sẽ FAIL deserialize
}

// Request /products?tag=red&tag=blue
// → Deserialize error: "invalid type: string \"red\", expected a sequence"

Fix 1 — Dùng serde_qs crate: crate này hiểu cú pháp query string mở rộng (đặc tả Rack/PHP/Node.js querystring): key trùng tự collect vào array, key kiểu tag[]=red&tag[]=blue hoặc tag[0]=red&tag[1]=blue đều hợp lệ. axum-extra-style wrapper serde_qs::axum::QsQuery<T> drop-in replace Query<T>.

use serde::Deserialize;
use serde_qs::axum::QsQuery;

#[derive(Debug, Deserialize)]
struct ProductFilter {
    tag: Vec<String>,
}

// Route: /products
async fn list_products(QsQuery(filter): QsQuery<ProductFilter>) -> Json<serde_json::Value> {
    // Request /products?tag=red&tag=blue&tag=large
    // → filter.tag = vec!["red", "blue", "large"]
    Json(json!({ "tags": filter.tag }))
}

Workspace dependencies cần thêm khi handler đầu tiên dùng serde_qs (chưa add B23):

# shop/Cargo.toml (workspace root) — preview, CHƯA add ở B23
[workspace.dependencies]
serde_qs = { version = "0.13", features = ["axum"] }

Fix 2 — Custom extractor: tự impl FromRequestParts dùng serde_qs::from_str trên raw query string từ parts.uri.query(). Pattern này chính là cách serde_qs::axum::QsQuery impl bên trong — không lý do reinventing trừ khi cần custom rejection envelope. Shop API sẽ gộp vào AppQuery<T> wrapper ở B32 (tương tự AppPath<T>).

Shop API decision lock: dùng serde_qs cho endpoint cần multi-value (filter products by tags, brands, attributes), single-value endpoint (pagination, simple filter) giữ axum default Query để giảm dependency bề mặt. Crate serde_qs sẽ add vào workspace.dependencies khi đến B98 (Dynamic Filter Query G10) — bài đó implement filter ?tag=...&brand=...&color=... đầy đủ với sqlx::QueryBuilder build SQL dynamic WHERE tag = ANY($1). B23 KHÔNG add dep — chỉ note pattern.

6

Empty vs Missing Pitfall

Hai trạng thái dễ nhầm trong query string:

  • Missing key: ?other=foo không có key name trong query → field name: Option<String> nhận None.
  • Empty value: ?name= có key name nhưng value rỗng → field name: String nhận "" (chuỗi rỗng), field name: Option<String> nhận Some("").
Request                  →  filter (struct với name: Option)
-------------------------+-------------------------------------------
GET /products            →  name: None
GET /products?other=foo  →  name: None       (missing → None)
GET /products?name=      →  name: Some("")   (empty string → Some(""))
GET /products?name=phone →  name: Some("phone")

Anti-pattern: handler coi Some("") = None bằng cách check if !s.is_empty() { ... } ad-hoc trong handler body. Vấn đề: logic check rải rác mọi handler, dễ quên check ở vài chỗ, test khó cover hết — bug âm thầm khi client gửi ?name= mong đợi "không filter theo name" nhưng server filter ra 0 record.

// ANTI-PATTERN — check empty trong handler
async fn list_products(Query(filter): Query<ProductFilter>) -> ... {
    let name = filter.name.as_deref().filter(|s| !s.is_empty());
    // Logic ad-hoc rải rác, dễ quên
}

Fix: validate ở DTO layer qua validator crate (B41 deep dive) — annotate field với #[validate(length(min = 1))], validator tự reject empty string trước khi vào handler body. Response: 422 Unprocessable Entity + envelope chuẩn lock B3 với code = "VALIDATION_FAILED", field error nói rõ "name must not be empty".

// PATTERN — validate ở DTO layer (B41 preview)
use validator::Validate;

#[derive(Debug, Deserialize, Validate)]
struct ProductFilter {
    #[validate(length(min = 1))]
    name: Option<String>,
    // Validator skip None, chỉ check Some(s) — đúng ý đồ
}

Shop API decision lock: empty string trong query → reject 422 ở DTO layer (B41), KHÔNG silently treat = None. Lý do: client gửi ?name= 99% là bug client (UI binding field rỗng) — server reject sớm giúp client phát hiện bug nhanh thay vì nhận response empty list rồi loay hoay debug.

7

Tạo shop-common/src/pagination.rs

Đến phần áp dụng vào Shop API. Tạo file mới crates/shop-common/src/pagination.rs chứa hai struct cốt lõi cho mọi list endpoint xuyên suốt series: Pagination (input từ query string) và ListResponse<T> (envelope output JSON).

// File: crates/shop-common/src/pagination.rs
use serde::{Deserialize, Serialize};

/// Pagination query parameter chuẩn cho mọi list endpoint Shop API.
///
/// Default: page=1, size=20. Cap size = 100 (anti-DoS).
#[derive(Debug, Clone, Deserialize)]
pub struct Pagination {
    #[serde(default = "default_page")]
    pub page: u32,

    #[serde(default = "default_size")]
    pub size: u32,
}

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

impl Default for Pagination {
    fn default() -> Self {
        Self {
            page: default_page(),
            size: default_size(),
        }
    }
}

impl Pagination {
    /// OFFSET cho SQL query (zero-based: page 1 → offset 0).
    pub fn offset(&self) -> u32 {
        self.page.saturating_sub(1) * self.size
    }

    /// LIMIT cho SQL query — cap 100 anti-DoS.
    pub fn limit(&self) -> u32 {
        self.size.min(100)
    }
}

/// Envelope chuẩn cho response list endpoint Shop API.
///
/// Field `has_next` rename `hasNext` theo convention camelCase wire format
/// (DTO convention lock B15).
#[derive(Debug, Clone, Serialize)]
pub struct ListResponse<T> {
    pub items: Vec<T>,
    pub total: u64,
    pub page: u32,
    pub size: u32,
    #[serde(rename = "hasNext")]
    pub has_next: bool,
}

impl<T> ListResponse<T> {
    pub fn new(items: Vec<T>, total: u64, pagination: &Pagination) -> Self {
        let has_next = (pagination.page as u64 * pagination.size as u64) < total;
        Self {
            items,
            total,
            page: pagination.page,
            size: pagination.size,
            has_next,
        }
    }
}

Cập nhật crates/shop-common/src/lib.rs thêm dòng pub mod pagination; aggregate module mới:

// File: crates/shop-common/src/lib.rs
pub mod config;
pub mod error;
pub mod headers;
pub mod pagination;   // NEW B23
pub mod telemetry;

Note vài quyết định lock trong file:

  • offset() dùng saturating_sub(1) — nếu client gửi page=0 (invalid theo convention 1-based), saturating_sub trả 0 thay vì underflow panic. Combine với validator B41 reject page=0 để defensive double-check.
  • limit() cap 100 anti-DoS — client gửi size=99999 server tự cap về 100, tránh query SQL trả về quá nhiều record kéo timeout/memory. Cap 100 là giá trị reasonable cho catalog (UI hiếm hiển thị > 100 item/page). Range validation chặt hơn (vd size <= 50) qua validator B41 nếu cần.
  • has_next compute: (page × size) < total — nếu offset cuối page hiện tại nhỏ hơn total thì còn trang sau. Convert u32u64 trước khi nhân tránh overflow (max u32 × u32 = u64).
  • Field has_next rename hasNext qua #[serde(rename = "hasNext")] theo DTO convention lock B15: camelCase wire format cho frontend JS/TS. Field khác (items, total, page, size) đã là single word lowercase — không cần rename.
  • Derive Default cho Pagination giúp test fixture tạo nhanh: Pagination::default() trả {page: 1, size: 20}.
8

Refactor list_products Dùng Pagination

Cập nhật handler list_products trong crates/shop-api/src/routes/products.rs (skeleton B21 đang trả Json(json!({"items":[],"total":0}))) sang dùng Query<Pagination> + trả ListResponse chuẩn. Skeleton sẽ implement đầy đủ với sqlx + ListResponse<ProductDto> ở G7 (B64 List Resources Với Pagination).

// File: crates/shop-api/src/routes/products.rs (trích đoạn — update list_products)
use axum::{
    extract::{Path, Query},
    http::StatusCode,
    routing::{delete, get, patch, post, put},
    Json, Router,
};
use serde_json::json;
use shop_common::pagination::{ListResponse, Pagination};

use crate::state::AppState;

pub fn routes() -> Router<AppState> {
    Router::new()
        .route(
            "/api/v1/products",
            get(list_products).post(create_product),
        )
        .route("/api/v1/products/popular", get(list_popular))
        .route(
            "/api/v1/products/:slug/related/:related_slug",
            get(get_related_product),
        )
        .route(
            "/api/v1/products/:slug",
            get(get_product)
                .put(replace_product)
                .patch(update_product)
                .delete(delete_product),
        )
}

// UPDATED B23 — dùng Query + ListResponse envelope
async fn list_products(
    Query(pagination): Query<Pagination>,
) -> Json<serde_json::Value> {
    tracing::info!(
        page = pagination.page,
        size = pagination.size,
        "listing products",
    );

    // Skeleton: trả empty list với metadata pagination chuẩn.
    // G7 sẽ thay bằng sqlx query LIMIT/OFFSET + ListResponse.
    let response = ListResponse::<serde_json::Value>::new(vec![], 0, &pagination);

    Json(serde_json::to_value(response).unwrap())
}

// 7 handler còn lại từ B21+B22 GIỮ NGUYÊN — chỉ list_products đổi.

Verify bằng cargo run -p shop-api rồi curl 3 case:

# Case 1: default page=1 size=20
curl http://localhost:3000/api/v1/products
# {"items":[],"total":0,"page":1,"size":20,"hasNext":false}

# Case 2: cả page và size từ query
curl 'http://localhost:3000/api/v1/products?page=2&size=50'
# {"items":[],"total":0,"page":2,"size":50,"hasNext":false}

# Case 3: chỉ page, size fallback default
curl 'http://localhost:3000/api/v1/products?page=2'
# {"items":[],"total":0,"page":2,"size":20,"hasNext":false}

# Case 4: invalid page (string thay vì number) → axum default 400
curl -i 'http://localhost:3000/api/v1/products?page=abc'
# HTTP/1.1 400 Bad Request
# (body plain text — sẽ override AppQuery wrapper ở B32)

Quan sát response: field hasNext camelCase đúng convention DTO lock B15, các field khác giữ snake_case lowercase tự nhiên. Total = 0 vì skeleton — khi G7 implement đầy đủ với sqlx SELECT COUNT(*) FROM products + SELECT ... LIMIT $1 OFFSET $2 sẽ trả total thật + items đầy đủ.

Workspace state thay đổi B23: NEW crates/shop-common/src/pagination.rs, UPDATED crates/shop-common/src/lib.rs (thêm pub mod pagination;), UPDATED crates/shop-api/src/routes/products.rs (refactor list_products dùng Query<Pagination> + ListResponse). KHÔNG sửa file khác — Cargo.toml không cần update (serde đã có sẵn, không add serde_qs ở B23).

9

Tổng Kết

  • Query<T> extract ?key=value qua serde_urlencoded; T phải impl Deserialize.
  • Option<T> cho field optional — missing key trả None, present parse T trả Some(T).
  • #[serde(default = "fn_name")] + helper function cho default value; pagination chuẩn page=1 size=20.
  • Multi-value query ?tag=red&tag=blue: axum default Query chỉ lấy 1; dùng serde_qs cho Vec<T>.
  • Empty value ?key= ≠ missing key ? không có key — validate ở DTO layer (B41 validator crate) reject empty string.
  • shop_common::pagination::Pagination chuẩn: page (default 1), size (default 20, cap 100), method offset()/limit() compute SQL.
  • ListResponse<T> envelope: { items, total, page, size, hasNext } với hasNext rename camelCase (B15 DTO convention).
  • File path lock: crates/shop-common/src/pagination.rs — reuse cross-resource (products, orders, reviews, admin lists).
  • Custom AppQuery<T> rejection wrapper TBD ở B32 (tương tự AppPath<T> lock B22).
  • Shop API decision lock B23: serde_qs cho multi-value endpoint (sẽ add dep B98), single-value giữ axum default Query để giảm dependency.
10

Bài Tập Củng Cố

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

  1. Phân biệt Path<T> (B22) và Query<T> (B23). URL pattern mỗi loại nằm ở đâu? Cho ví dụ cụ thể cho endpoint /products/:slug?lang=vi.
  2. Pagination struct dùng #[serde(default = "default_page")] + helper function fn default_page() -> u32 { 1 }. Tại sao cần helper function mà không khai báo literal #[serde(default = "1")]? Tại sao không dùng #[serde(default)] trần?
  3. Request ?tag=red&tag=blue với axum default Query<T>tag: Vec<String>. Extract ra gì? Deserialize fail hay silently mất giá trị? Workaround là gì? Tên crate cụ thể?
  4. Empty value ?name= khác missing key ?other=foo ra sao đối với field name: Option<String>? Anti-pattern nào hay gặp trong handler? Fix Shop API chọn ở layer nào?
  5. ListResponse<T>::new(items, total, &pagination) compute has_next theo công thức nào? Tại sao phải convert sang u64 trước khi nhân? Cho ví dụ với total=100, page=3, size=20.
Đáp án
  1. Path<T> extract dynamic segment trong path — phần URL trước dấu ?, định danh resource theo cấu trúc :name trong route pattern (vd /products/:slug). Query<T> extract query string — phần URL sau dấu ?, các cặp key=value ngăn cách bởi &, không nằm trong route pattern. Endpoint /products/:slug?lang=vi: route khai báo "/products/:slug" chỉ có :slug trong pattern, lang không nằm trong route pattern; handler signature list_product(Path(slug): Path<String>, Query(filter): Query<LangFilter>) với struct LangFilter { lang: Option<String> }. Request /products/phone-x?lang=vislug = "phone-x", filter.lang = Some("vi"). Phân biệt semantic: Path là DEFINING resource (cần để biết resource nào), Query là MODIFYING (filter, sort, pagination, optional) — quy ước REST.
  2. Helper function vì serde attribute default CHỈ chấp nhận tên function (path), KHÔNG chấp nhận giá trị literal. Cú pháp #[serde(default = "1")] compile error: serde parse "1" như tên function rồi tìm không thấy. Helper function ngoài struct, không nhận tham số, trả type matching field. Lợi ích bonus: logic phức tạp nếu cần (vd đọc env PAGE_DEFAULT), reuse cross-struct (multiple struct dùng cùng default_size), test riêng dễ (assert helper trả đúng giá trị). KHÔNG dùng #[serde(default)] trần vì attribute đó gọi Default::default() trả 0 cho u32page=0 + size=0 là invalid (SQL OFFSET 0 LIMIT 0 trả 0 row, behavior khó hiểu cho client). Luôn dùng default = "fn_name" với function trả giá trị sensible (page=1, size=20).
  3. Với tag: Vec<String>, axum default Query<T> sẽ FAIL deserialize với lỗi "invalid type: string \"red\", expected a sequence" trả 400 Bad Request — vì serde_urlencoded bên dưới theo spec application/x-www-form-urlencoded chỉ giữ 1 giá trị per key (giá trị cuối cùng nếu trùng), không hiểu cú pháp array. Nếu field là tag: String (không phải Vec), serde_urlencoded sẽ silently chỉ giữ giá trị CUỐI "blue", mất "red" — bug âm thầm không error. Workaround: crate serde_qs (Q's = Query String), version 0.13 trở lên với feature axum. Wrapper serde_qs::axum::QsQuery<T> drop-in replace Query<T> trong signature handler, hiểu cú pháp mở rộng (key trùng tự collect array, hỗ trợ tag[]=red&tag[]=bluetag[0]=red&tag[1]=blue kiểu Rack/PHP). Shop API decision lock B23: dùng serde_qs cho endpoint multi-value (filter products by tags/brands), single-value endpoint (pagination) giữ axum default Query để giảm dependency. Crate serde_qs sẽ add workspace.dependencies khi đến B98 (Dynamic Filter Query G10), B23 chỉ note pattern.
  4. Hai trạng thái khác nhau hoàn toàn: (a) missing key ?other=foo không có key name → field name: Option<String> nhận None. (b) empty value ?name= có key name nhưng value rỗng → field name: Option<String> nhận Some("") (chuỗi rỗng wrapped trong Some). Anti-pattern hay gặp: handler check if !s.is_empty() ad-hoc trong body (vd let name = filter.name.as_deref().filter(|s| !s.is_empty());), logic check rải rác mọi handler dễ quên ở vài chỗ, test khó cover hết — bug âm thầm khi client gửi ?name= mong "không filter theo name" nhưng server filter ra 0 record. Shop API fix layer DTO: validate ở DTO layer qua validator crate (B41 deep dive) — annotate field với #[validate(length(min = 1))], validator tự reject empty string TRƯỚC khi vào handler body. Response: 422 Unprocessable Entity + envelope chuẩn lock B3 với code = "VALIDATION_FAILED" + field error nói rõ "name must not be empty". Lý do reject thay vì silently treat = None: client gửi ?name= 99% là bug client (UI binding field rỗng), server reject sớm giúp client phát hiện bug nhanh thay vì nhận empty list rồi loay hoay debug.
  5. Công thức: has_next = (page × size) < total. Ý nghĩa: nếu offset cuối page hiện tại (page × size) còn nhỏ hơn tổng record, nghĩa là còn record cho trang sau. Convert u32u64 trước khi nhân để tránh overflow: u32 × u32 tối đa 2^32 × 2^32 = 2^64 vượt phạm vi u32 (max 2^32 - 1); convert sang u64 trước, kết quả nhân vẫn nằm trong u64 (max 2^64 - 1). Trong code ListResponse::new: let has_next = (pagination.page as u64 * pagination.size as u64) < total;total đã là u64 nên so sánh hợp lệ. Ví dụ total=100, page=3, size=20: has_next = (3 × 20) < 100 = 60 < 100 = true — page 3 hiển thị record 41-60, còn record 61-100 cho page 4-5. Edge case total=100, page=5, size=20: has_next = (5 × 20) < 100 = 100 < 100 = false — page 5 cuối cùng, không còn next. Edge case total=0, page=1, size=20: has_next = (1 × 20) < 0 = 20 < 0 = false — không có record nào, không có next (đúng).
11

Bài Tiếp Theo

— chi tiết Router::nest("/api/v1", api_router) cho modular routing, multi-level nest, tradeoff vs flat path, refactor Shop API hiện full path /api/v1/products trong sub-router sang nest("/api/v1", ...) aggregate gọn hơn.