#09 — Quy ước API & hợp đồng OpenAPI Phần 1
Tài liệu #5. Tham chiếu:
01§11 ·02(nghiệp vụ) ·03(dữ liệu) ·04§4.8 (Controller). Ngày lập: 13/08/2026 · Trạng thái: Bản nháp 1 Hợp đồng máy đọc:05-api-spec/openapi.yaml— đó là nguồn sự thật, tài liệu này chỉ giải thích vì sao.
0. Contract-first: spec có trước code
1. Viết/sửa openapi.yaml ← thay đổi bắt đầu ở ĐÂY, không ở Controller
2. redocly lint ← CI chặn nếu spec sai
3. Review spec trong PR ← bàn về hợp đồng trước khi bàn về code
4. Sinh TypeScript client + type ← frontend làm song song, không chờ backend
5. Viết Controller + Resource ← doc 04 §4.8
6. Contract test: response THẬT phải khớp spec ← CI chặn nếu lệch
⚑ Bất biến của quy trình: nếu response thật khác spec, đó là bug, kể cả khi code "hoạt động". Contract test (§14) làm điều này trở thành sự thật máy móc chứ không phải thiện chí.
Vì sao contract-first cho dự án này: ở P6 storefront có thể tách sang Next.js và ở P8 sẽ có API cho đối tác/nhà bán. Cả hai đều cần một hợp đồng ổn định có thể sinh code. Viết spec sau khi code xong thì spec luôn lạc hậu và không ai tin nó.
1. Định dạng & quy ước cơ bản
| Chủ đề | Quyết định | Vì sao |
|---|---|---|
| Phong cách | REST + OpenAPI 3.1 | JSON:API nghiêm ngặt: payload dài, chi phí học cao, lợi ích không xứng ở quy mô này (doc 01 §11.1) |
| Version | Trong URL: /api/v1/... |
Nhìn thấy được trong log, cache, CDN rule. Header versioning làm cache khó |
| Đường dẫn | kebab-case, số nhiều: /checkout-sessions |
|
| Trường JSON | snake_case |
Khớp Eloquent, khớp cột CSDL, khớp payload domain event (doc 02 §20.1) → không có tầng đổi tên nào |
| Content type | application/json; lỗi dùng application/problem+json |
|
| Ngày giờ | RFC 3339, luôn UTC, luôn có Z: 2026-08-13T09:12:33Z |
Client tự đổi sang Asia/Ho_Chi_Minh |
| Boolean | true/false, không 0/1, không "yes" |
|
| Trường rỗng | null tường minh, không bỏ trường |
Client phân biệt được "không có" và "chưa nạp" |
| Enum | Chuỗi snake_case, khớp giá trị trong CHECK constraint của doc 03 |
Một từ vựng từ CSDL đến API |
1.1 Số tiền — không bao giờ là number
{ "unit_price": { "amount": "159000.0000", "currency": "VND" } }
⚠ "amount" là chuỗi. Lý do: JSON number được JavaScript parse thành IEEE-754 double. 0.1 + 0.2 !== 0.3, và với số lớn (grand_total của đơn B2B) sẽ mất chính xác. Chuỗi thập phân đi thẳng vào numeric(19,4) của PostgreSQL và Money VO của PHP (doc 02 §3.2) mà không qua float lần nào.
currency luôn đi kèm — không bao giờ giả định VND, kể cả khi hiện tại chỉ có VND.
1.2 Định danh — chỉ lộ public_id
⚑ Không bao giờ để bigint khoá chính nội bộ xuất hiện trong API. Mọi định danh công khai là uuid v7 (doc 03 §2).
| Sai | Đúng |
|---|---|
GET /v1/products/12345 |
GET /v1/products/019ffeec-7c08-7d2b-90c7-f1e1945201b5 |
GET /v1/orders/8842 |
GET /v1/orders/HS26081300123 (order_number — mã khách đọc được) |
Hai lý do: chặn liệt kê tuần tự (đối thủ đếm số đơn/ngày qua ID tăng dần) và chặn dò IDOR. Với Order ta lộ order_number vì khách cần đọc nó qua điện thoại — nhưng nó không đoán được (có thành phần ngẫu nhiên), khác với ID tuần tự.
2. Phân trang — cursor, không offset
GET /api/v1/products?limit=24&cursor=eyJpZCI6MTIzNDUsInB1YiI6IjAxOWYuLi4ifQ
{
"data": [ ... ],
"meta": {
"next_cursor": "eyJpZCI6MTIzNjksInB1YiI6IjAxOWYuLi4ifQ",
"has_more": true
}
}
Vì sao không ?page=500: OFFSET 12000 buộc PostgreSQL đọc và bỏ 12.000 dòng trước khi trả kết quả. Trên bảng 1 triệu variant, trang 500 chậm gấp hàng chục lần trang 1 — và bot cào giá sẽ đi thẳng đến những trang đó.
Cấu trúc cursor: base64url của {"id": <bigint nội bộ>, "k": <giá trị cột sắp xếp>}, khoá bằng chữ ký HMAC ngắn để client không tự chế. Cursor là thứ opaque — spec khai báo nó là string, không mô tả nội dung, để đổi được sau này.
⚠ Ngoại lệ có chủ ý: trang danh mục cho SEO cần ?page=N để có URL canonical ổn định cho crawler. Giải pháp: giới hạn page <= 50 cho đường SEO, còn app/mobile dùng cursor. Ghi rõ trong spec là hai endpoint khác nhau, không trộn.
| Tham số | Mặc định | Tối đa |
|---|---|---|
limit |
24 | 100 |
page (chỉ đường SEO) |
1 | 50 |
3. Lỗi — RFC 9457 Problem Details
Một định dạng lỗi duy nhất cho toàn bộ API:
{
"type": "https://api.example.com/problems/insufficient-stock",
"title": "Không đủ hàng trong kho",
"status": 409,
"detail": "Biến thể SKU-1-01 chỉ còn 2 sản phẩm, bạn yêu cầu 5.",
"instance": "/api/v1/checkout-sessions/019ffeec-.../complete",
"code": "INSUFFICIENT_STOCK",
"meta": {
"variant_id": "019ffeec-7c08-7d2b-90c7-f1e1945201b5",
"requested": 5,
"available": 2
},
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
| Trường | Vai trò |
|---|---|
type |
URI ổn định định danh loại lỗi. Client bắt theo trường này hoặc code |
title |
Tiếng Việt, hiện được cho người dùng |
detail |
Cụ thể cho lần gọi này |
code |
⭐ Mã máy đọc — client bắt theo đây (ngắn hơn URI) |
meta |
Dữ liệu để client xử lý thông minh (vd hạ số lượng về available) |
trace_id |
Nối vào trace OpenTelemetry — hỗ trợ CSKH tra được ngay |
⚑ Bất biến: detail có thể đổi tuỳ ngữ cảnh; code không bao giờ đổi sau khi công bố. Đổi code là breaking change.
3.1 Lỗi validation
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Dữ liệu không hợp lệ",
"status": 422,
"code": "VALIDATION_FAILED",
"errors": {
"shipping_address.phone": ["Số điện thoại không đúng định dạng Việt Nam."],
"items.0.quantity": ["Số lượng phải lớn hơn 0."]
},
"trace_id": "..."
}
3.2 Danh mục mã lỗi nghiệp vụ
Bảng này là hợp đồng — frontend viết logic dựa vào nó.
code |
HTTP | Khi nào | Client nên làm |
|---|---|---|---|
VALIDATION_FAILED |
422 | Dữ liệu sai định dạng | Hiện lỗi theo từng field |
UNAUTHENTICATED |
401 | Thiếu/hết hạn token | Chuyển sang đăng nhập |
FORBIDDEN |
403 | Không có quyền | Hiện thông báo, không retry |
NOT_FOUND |
404 | Không tồn tại hoặc không thuộc về bạn | Không phân biệt — chống dò IDOR |
VARIANT_NOT_PURCHASABLE |
409 | Variant đã archived hoặc product unpublished |
Bỏ dòng khỏi giỏ, thông báo |
INSUFFICIENT_STOCK |
409 | ⚑ I3 — không đủ tồn | Hạ số lượng về meta.available |
INSUFFICIENT_ALLOCATABLE_STOCK |
409 | Có hàng nhưng toàn lô cận hạn (⚑ I8) | Không hiện "hết hàng" cho khách; báo vận hành |
PRICE_CHANGED |
409 | ⚑ H3 — giá tăng giữa lúc checkout | Hiện màn hình xác nhận lại với meta.old_total/new_total |
CHECKOUT_SESSION_EXPIRED |
410 | Quá 15 phút (⚑ H2) | Bắt đầu checkout lại |
COUPON_INVALID |
409 | Mã sai/hết hạn | Xoá mã, hiện lý do |
COUPON_EXHAUSTED |
409 | ⚑ M2 — hết lượt dùng | Xoá mã |
COUPON_ALREADY_USED |
409 | ⚑ M3 — khách đã dùng rồi | Xoá mã |
COD_NOT_ELIGIBLE |
409 | Vượt hạn mức COD hoặc khách rủi ro | Ẩn COD, gợi ý trả trước |
ADDRESS_NOT_SERVICEABLE |
409 | ⚑ H5 — hãng vận chuyển không phục vụ | Đề xuất phương thức khác |
ILLEGAL_STATE_TRANSITION |
409 | ⚑ O3 — vd huỷ đơn đã giao | Tải lại trạng thái đơn |
ORDER_NOT_CANCELLABLE |
409 | Đã bàn giao hãng vận chuyển | Hướng khách sang luồng trả hàng |
REFUND_EXCEEDS_PAYMENT |
409 | ⚑ O4 | Lỗi nội bộ — log cảnh báo |
IDEMPOTENCY_KEY_REUSED |
409 | Cùng key, khác payload | Sinh key mới |
IDEMPOTENCY_IN_PROGRESS |
409 | Request cùng key đang chạy | Chờ rồi thử lại |
RATE_LIMITED |
429 | Vượt giới hạn | Chờ theo Retry-After |
PAYMENT_GATEWAY_ERROR |
502 | Cổng thanh toán lỗi | Cho chọn cổng khác |
INTERNAL_ERROR |
500 | Ngoài dự kiến | Hiện trace_id cho CSKH |
⚠ NOT_FOUND cho cả "không tồn tại" và "không phải của bạn" là quyết định bảo mật có chủ ý. Trả 403 cho đơn của người khác là tự xác nhận đơn đó tồn tại — đó là lỗ dò IDOR.
4. Xác thực & phân quyền
| Đối tượng | Cơ chế | Header |
|---|---|---|
| Storefront web/mobile (first-party) | Sanctum v4.3 token | Authorization: Bearer <token> |
| Khách vãng lai (giỏ hàng) | Không cần auth; cart_id là UUIDv7 khó đoán |
— |
| Admin | Session + MFA bắt buộc | cookie |
| Đối tác/nhà bán (P8) | Passport OAuth 2.1 + OIDC | Authorization: Bearer <jwt> |
| Webhook đi vào | Chữ ký nhà cung cấp | tuỳ nhà cung cấp |
| Webhook đi ra | HMAC-SHA256 của ta | X-Signature, X-Timestamp |
⚑ Hai tầng kiểm quyền, không phải một (doc 01 §11.2): middleware kiểm role ở Presentation, cộng với kiểm ownership trong Application layer. Chỉ có role mà không kiểm ownership chính là lỗ IDOR — GET /v1/orders/{n} phải kiểm đơn đó thuộc auth()->user().
4.1 Giỏ hàng khách vãng lai
cart_id là UUIDv7 (không đoán được) và là thông tin xác thực cho giỏ đó. Vì vậy:
- Giỏ vãng lai không được trả về thông tin cá nhân nào.
- Khi khách đăng nhập, giỏ được gộp (doc 02 §8.2) và
cart_idcũ bị vô hiệu. - TTL 30 ngày (⚑ K5).
5. Idempotency — hợp đồng chi tiết
Mọi endpoint POST thay đổi tiền hoặc tồn kho bắt buộc có header Idempotency-Key.
POST /api/v1/checkout-sessions/{id}/complete
Idempotency-Key: 019ffeec-7c08-7d2b-90c7-f1e1945201b5
Hợp đồng phía server (dùng bảng idempotency_keys, doc 03 §14):
| Tình huống | Phản hồi |
|---|---|
| Key mới | Xử lý, lưu response, trả kết quả |
Key đã có, cùng request_hash, đã xong |
Trả nguyên response đã lưu, kèm Idempotency-Replayed: true |
| Key đã có, cùng hash, đang xử lý | 409 IDEMPOTENCY_IN_PROGRESS |
| Key đã có, khác hash | 409 IDEMPOTENCY_KEY_REUSED |
| Thiếu header ở endpoint yêu cầu | 400 |
TTL của key: 24 giờ. Client nên dùng UUIDv7 sinh tại client và giữ nguyên qua mọi lần retry của cùng một ý định.
⚠ Endpoint bắt buộc Idempotency-Key: hoàn tất checkout, huỷ đơn, tạo hoàn tiền, tạo vận đơn. Endpoint không cần: thêm vào giỏ (đã idempotent theo UNIQUE(cart_id, variant_id)), mọi GET.
6. Giới hạn tần suất
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1786000000
Retry-After: 12
| Nhóm endpoint | Giới hạn | Theo |
|---|---|---|
| Đọc catalog/search | 120/phút | IP |
| Giỏ hàng | 60/phút | cart_id + IP |
| Checkout complete | 10/phút | user/IP |
| Đăng nhập | 5/phút rồi captcha | IP + email |
| Thanh toán thất bại | 5/giờ | IP + thẻ (chống card testing — doc 01 §11.3) |
| Webhook vào | Không giới hạn | (nhưng có chữ ký + idempotency) |
7. Đồng thời — ETag / If-Match
Cho các tài nguyên có thể bị hai người sửa cùng lúc (giỏ hàng nhiều thiết bị, đơn hàng ở admin):
GET /api/v1/carts/{id} → ETag: "W/cart-019ff-7"
PATCH /api/v1/carts/{id}/items/{itemId}
If-Match: "W/cart-019ff-7" → 412 Precondition Failed nếu đã đổi
⏳ P2 cho giỏ hàng; P3 cho admin. Không làm ở P1.
8. Webhook đi vào (VNPay/MoMo/GHN)
POST /api/v1/webhooks/{provider}
Trình tự xử lý bắt buộc (doc 02 §11.5):
1. Xác thực chữ ký nhà cung cấp → sai: 401 + log cảnh báo bảo mật
2. Kiểm timestamp trong ngưỡng 5 phút → quá: 400 (chống replay)
3. idempotency_key = hash(provider, txn_id, event_type)
4. INSERT ... ON CONFLICT DO NOTHING
0 dòng ⇒ đã xử lý ⇒ trả response đã lưu, HTTP 200
5. Xử lý trong transaction + ghi outbox
6. Trả 200
⚑ Luôn trả 200 khi đã xử lý hoặc đã từng xử lý. Trả lỗi cho một webhook trùng sẽ khiến cổng thanh toán retry vô hạn và có thể bị họ tạm ngưng kết nối.
⚠ Bước 4 dùng INSERT ... ON CONFLICT, không dùng SELECT rồi INSERT — hai webhook đến đồng thời sẽ vượt qua SELECT cùng lúc.
Webhook không nằm trong openapi.yaml công khai — nó là bề mặt nội bộ giữa ta và nhà cung cấp, đặc tả do họ quy định. Ghi trong 07-payment-integration.md.
9. Webhook đi ra (P8, cho đối tác)
OpenAPI 3.1 có khối webhooks ở cấp cao nhất — dùng để đặc tả những gì ta gửi đi:
POST https://partner.example.com/hooks
X-Signature: sha256=<hmac>
X-Timestamp: 1786000000
X-Event-Type: order.shipped
X-Delivery-Id: 019ffeec-...
Chữ ký = HMAC-SHA256(secret, timestamp + "." + raw_body). Đối tác phải kiểm cả timestamp (ngưỡng 5 phút) để chống replay. Retry: 5 lần, backoff lũy tiến 1′/5′/30′/2h/12h, sau đó vào DLQ.
10. Bề mặt API theo pha
P1 — Catalog (đọc)
| Method | Path | Auth |
|---|---|---|
GET |
/v1/products |
— |
GET |
/v1/products/{product_id} |
— |
GET |
/v1/products/by-slug/{slug} |
— |
GET |
/v1/categories |
— |
GET |
/v1/categories/{slug}/products |
— |
GET |
/v1/search |
— |
GET |
/v1/brands |
— |
POST |
/v1/auth/register, /v1/auth/login |
— |
GET |
/v1/me |
Bearer |
GET/POST/PATCH/DELETE |
/v1/me/addresses[/{id}] |
Bearer |
P2 — Bán hàng
| Method | Path | Idempotency | Auth |
|---|---|---|---|
POST |
/v1/carts |
— | tuỳ chọn |
GET |
/v1/carts/{cart_id} |
— | tuỳ chọn |
POST |
/v1/carts/{cart_id}/items |
— | tuỳ chọn |
PATCH |
/v1/carts/{cart_id}/items/{item_id} |
— | tuỳ chọn |
DELETE |
/v1/carts/{cart_id}/items/{item_id} |
— | tuỳ chọn |
POST |
/v1/carts/{cart_id}/merge |
— | Bearer |
POST |
/v1/shipping/quotes |
— | tuỳ chọn |
POST |
/v1/checkout-sessions |
— | tuỳ chọn |
PATCH |
/v1/checkout-sessions/{id} |
— | tuỳ chọn |
POST |
/v1/checkout-sessions/{id}/complete |
⚑ bắt buộc | tuỳ chọn |
GET |
/v1/orders |
— | Bearer |
GET |
/v1/orders/{order_number} |
— | Bearer |
POST |
/v1/orders/{order_number}/cancel |
⚑ bắt buộc | Bearer |
P3+ — Vận hành, đổi trả, đối tác
/v1/orders/{n}/shipments · /v1/returns · /v1/invoices/{id}/pdf · /v1/admin/** · /v1/partner/** (Passport, P8)
11. Ba mẫu response quan trọng
11.1 Sản phẩm — tồn kho là boolean, không phải số
{
"id": "019ffeec-7c08-7d2b-90c7-f1e1945201b5",
"slug": "kem-chong-nang-anessa",
"name": "Kem chống nắng Anessa dưỡng da",
"brand": { "id": "019ff...", "name": "Anessa", "slug": "anessa" },
"status": "active",
"attributes": { "skin_type": "oily", "origin_country": "JP" },
"rating": { "average": 4.7, "count": 1284 },
"price_range": {
"min": { "amount": "159000.0000", "currency": "VND" },
"max": { "amount": "289000.0000", "currency": "VND" }
},
"variants": [
{
"id": "019ff...",
"sku": "SKU-1-01",
"options": { "shade": "01", "size": "3g" },
"list_price": { "amount": "199000.0000", "currency": "VND" },
"sale_price": { "amount": "159000.0000", "currency": "VND" },
"in_stock": true,
"low_stock": false
}
]
}
⚑ Không trả số lượng tồn chính xác. in_stock: boolean + low_stock: boolean là đủ cho khách. Lộ available: 3 cho đối thủ biết tốc độ bán và cho bot biết chính xác khi nào giành hàng. Ngoại lệ duy nhất: khi low_stock thì hiện "chỉ còn vài sản phẩm", vẫn không nói số.
11.2 Checkout session — luôn trả bảng tính đầy đủ
{
"id": "019ffeec-...",
"status": "pricing_locked",
"expires_at": "2026-08-13T09:27:33Z",
"lines": [
{
"variant_id": "019ff...",
"sku": "SKU-1-01",
"name": "Kem chống nắng Anessa dưỡng da — 01",
"quantity": 2,
"list_price": { "amount": "199000.0000", "currency": "VND" },
"unit_price": { "amount": "159000.0000", "currency": "VND" },
"discount_total": { "amount": "20000.0000", "currency": "VND" },
"tax_rate": "0.1000",
"tax_total": { "amount": "27091.0000", "currency": "VND" },
"line_total": { "amount": "298000.0000", "currency": "VND" },
"applied_promotions": [
{ "campaign_id": "019ff...", "name": "Giảm 10% mỹ phẩm Nhật",
"amount": { "amount": "20000.0000", "currency": "VND" } }
]
}
],
"subtotal": { "amount": "298000.0000", "currency": "VND" },
"discount_total": { "amount": "20000.0000", "currency": "VND" },
"shipping_fee": { "amount": "0.0000", "currency": "VND" },
"tax_total": { "amount": "27091.0000", "currency": "VND" },
"grand_total": { "amount": "305091.0000", "currency": "VND" },
"available_payment_methods": ["vnpay", "momo", "cod"],
"warnings": [
{ "code": "PRICE_DROPPED", "message": "Giá đã giảm so với lúc bạn xem." }
]
}
All Rights Reserved