Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Biết extract
?key=value&key2=value2quaQuery<T>vớiTimplDeserialize. - Nắm
Paginationstruct chuẩn: fieldpage,sizevớ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=bluequaserde_qscrate vì axum default chỉ lấy 1 giá trị. - Phân biệt empty value
?key=và missing key?không có key — anti-pattern + fix qua validate DTO layer. - Tạo
crates/shop-common/src/pagination.rsvớiPagination+ListResponse<T>chuẩn cho mọi list endpoint Shop API. - Refactor handler
list_productsdùngQuery<Pagination>trảListResponseskeleton (chuẩn bị G7 implement đầy đủ với sqlx).
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 AppPath và AppJson. B23 dùng axum::extract::Query default cho skeleton handler.
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).
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.
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" và "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.
Empty vs Missing Pitfall
Hai trạng thái dễ nhầm trong query string:
- Missing key:
?other=fookhông có keynametrong query → fieldname: Option<String>nhậnNone. - Empty value:
?name=có keynamenhưng value rỗng → fieldname: Stringnhận""(chuỗi rỗng), fieldname: Option<String>nhậnSome("").
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.
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ùngsaturating_sub(1)— nếu client gửipage=0(invalid theo convention 1-based), saturating_sub trả 0 thay vì underflow panic. Combine với validator B41 rejectpage=0để defensive double-check.limit()cap 100 anti-DoS — client gửisize=99999server 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 (vdsize <= 50) qua validator B41 nếu cần.has_nextcompute:(page × size) < total— nếu offset cuối page hiện tại nhỏ hơn total thì còn trang sau. Convertu32→u64trước khi nhân tránh overflow (maxu32 × u32 = u64).- Field
has_nextrenamehasNextqua#[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
DefaultchoPaginationgiúp test fixture tạo nhanh:Pagination::default()trả{page: 1, size: 20}.
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).
Tổng Kết
Query<T>extract?key=valuequaserde_urlencoded;Tphải implDeserialize.Option<T>cho field optional — missing key trảNone, present parseTtrảSome(T).#[serde(default = "fn_name")]+ helper function cho default value; pagination chuẩnpage=1 size=20.- Multi-value query
?tag=red&tag=blue: axum defaultQuerychỉ lấy 1; dùngserde_qschoVec<T>. - Empty value
?key=≠ missing key?không có key — validate ở DTO layer (B41 validator crate) reject empty string. shop_common::pagination::Paginationchuẩn:page(default 1),size(default 20, cap 100), methodoffset()/limit()compute SQL.ListResponse<T>envelope:{ items, total, page, size, hasNext }vớihasNextrename 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_qscho multi-value endpoint (sẽ add dep B98), single-value giữ axum defaultQueryđể giảm dependency.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- 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. Paginationstruct dùng#[serde(default = "default_page")]+ helper functionfn 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?- Request
?tag=red&tag=bluevới axum defaultQuery<T>vàtag: Vec<String>. Extract ra gì? Deserialize fail hay silently mất giá trị? Workaround là gì? Tên crate cụ thể? - Empty value
?name=khác missing key?other=foora sao đối với fieldname: Option<String>? Anti-pattern nào hay gặp trong handler? Fix Shop API chọn ở layer nào? ListResponse<T>::new(items, total, &pagination)computehas_nexttheo công thức nào? Tại sao phải convert sangu64trước khi nhân? Cho ví dụ vớitotal=100, page=3, size=20.
Đáp án
Path<T>extract dynamic segment trong path — phần URL trước dấu?, định danh resource theo cấu trúc:nametrong route pattern (vd/products/:slug).Query<T>extract query string — phần URL sau dấu?, các cặpkey=valuengă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ó:slugtrong pattern,langkhông nằm trong route pattern; handler signaturelist_product(Path(slug): Path<String>, Query(filter): Query<LangFilter>)vớistruct LangFilter { lang: Option<String> }. Request/products/phone-x?lang=vi→slug = "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.- Helper function vì serde attribute
defaultCHỈ 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 envPAGE_DEFAULT), reuse cross-struct (multiple struct dùng cùngdefault_size), test riêng dễ (assert helper trả đúng giá trị). KHÔNG dùng#[serde(default)]trần vì attribute đó gọiDefault::default()trả0chou32—page=0+size=0là invalid (SQLOFFSET 0 LIMIT 0trả 0 row, behavior khó hiểu cho client). Luôn dùngdefault = "fn_name"với function trả giá trị sensible (page=1, size=20). - Với
tag: Vec<String>, axum defaultQuery<T>sẽ FAIL deserialize với lỗi"invalid type: string \"red\", expected a sequence"trả 400 Bad Request — vìserde_urlencodedbên dưới theo specapplication/x-www-form-urlencodedchỉ 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_urlencodedsẽ silently chỉ giữ giá trị CUỐI"blue", mất"red"— bug âm thầm không error. Workaround: crateserde_qs(Q's = Query String), version0.13trở lên với featureaxum. Wrapperserde_qs::axum::QsQuery<T>drop-in replaceQuery<T>trong signature handler, hiểu cú pháp mở rộng (key trùng tự collect array, hỗ trợtag[]=red&tag[]=bluevàtag[0]=red&tag[1]=bluekiểu Rack/PHP). Shop API decision lock B23: dùngserde_qscho endpoint multi-value (filter products by tags/brands), single-value endpoint (pagination) giữ axum defaultQueryđể giảm dependency. Crateserde_qssẽ addworkspace.dependencieskhi đến B98 (Dynamic Filter Query G10), B23 chỉ note pattern. - Hai trạng thái khác nhau hoàn toàn: (a) missing key
?other=fookhông có keyname→ fieldname: Option<String>nhậnNone. (b) empty value?name=có keynamenhưng value rỗng → fieldname: Option<String>nhậnSome("")(chuỗi rỗng wrapped trong Some). Anti-pattern hay gặp: handler checkif !s.is_empty()ad-hoc trong body (vdlet 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 quavalidatorcrate (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ớicode = "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. - 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. Convertu32→u64trước khi nhân để tránh overflow:u32 × u32tối đa2^32 × 2^32 = 2^64vượt phạm viu32(max2^32 - 1); convert sangu64trước, kết quả nhân vẫn nằm trongu64(max2^64 - 1). Trong codeListResponse::new:let has_next = (pagination.page as u64 * pagination.size as u64) < total;—totalđã làu64nê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 casetotal=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 casetotal=0, page=1, size=20:has_next = (1 × 20) < 0 = 20 < 0 = false— không có record nào, không có next (đúng).
Bài Tiếp Theo
Bài 24: Nested Routes — Router::nest — 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.
