0

#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"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à 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_idbị 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

Viblo
Let's register a Viblo Account to get more interesting posts.