#10 — Quy ước API & hợp đồng OpenAPI Phần 2
Ba điều bắt buộc:
- Mọi thành phần tiền đều hiện tường minh — khách không phải tự tính, và khi có tranh chấp ta tra được.
applied_promotionslà vết truy nguyên — trả lời được "vì sao đơn này giảm 20.000₫".available_payment_methodsdo server quyết (đã ápCodEligibilityPolicy) — client không được tự bày COD.
11.3 Đơn hàng — ba trục trạng thái tách bạch
{
"order_number": "HS26081300123",
"status": "shipped",
"payment_status": "unpaid",
"fulfilment_status": "partially_fulfilled",
"placed_at": "2026-08-13T09:12:33Z",
"grand_total": { "amount": "305091.0000", "currency": "VND" },
"paid_amount": { "amount": "0.0000", "currency": "VND" },
"payment_method": "cod",
"shipments": [
{ "id": "019ff...", "carrier": "ghn", "tracking_number": "GHN123456789",
"status": "in_transit", "tracking_url": "https://..." }
]
}
⚑ Không gộp ba trục thành một status (doc 02 §10.1). Ví dụ trên là trường hợp thật rất phổ biến với COD: đã gửi hàng, chưa thu tiền, giao được một phần — không biểu diễn được nếu chỉ có một trường.
12. Sinh code từ spec
# Lint spec (chạy trong CI)
npx --yes @redocly/cli@latest lint docs/05-api-spec/openapi.yaml
# Sinh TypeScript type cho storefront
npx --yes openapi-typescript docs/05-api-spec/openapi.yaml -o resources/js/api-types.d.ts
# Xem tài liệu ở local
npx --yes @redocly/cli@latest preview-docs docs/05-api-spec/openapi.yaml
⚠ Không sinh Controller PHP từ spec. Code sinh tự động cho phía server luôn phải sửa tay và rồi lệch khỏi spec. Thay vào đó: viết Controller tay (chúng rất mỏng — doc 04 §4.8) và để contract test bảo đảm khớp.
laravel/wayfinder v0.1.21 sinh được route type-safe cho frontend, nhưng chưa 1.0 (doc 01 §2.2) → chỉ dùng nếu đã tách Next.js ở P6, và pin version chính xác.
13. Contract test
// modules/Catalog/tests/Feature/Http/ProductContractTest.php
it('response chi tiết sản phẩm khớp hợp đồng OpenAPI', function () {
$product = Product::factory()->published()->withVariants(2)->create();
$response = $this->getJson("/api/v1/products/{$product->public_id}");
$response->assertOk();
// So response THẬT với schema trong openapi.yaml
expect($response->json())->toMatchOpenApiSchema('Product');
});
it('lỗi hết hàng trả đúng Problem Details', function () {
// ... dựng tình huống chỉ còn 2, yêu cầu 5
$response->assertStatus(409);
expect($response->json())
->toMatchOpenApiSchema('Problem')
->and($response->json('code'))->toBe('INSUFFICIENT_STOCK')
->and($response->json('meta.available'))->toBe(2);
});
⚑ Mọi endpoint công khai phải có ít nhất một contract test cho đường thành công và một cho đường lỗi chính. Nằm trong Definition of Done (doc 04 §12).
14. Chính sách thay đổi API
| Loại thay đổi | Có phá vỡ? | Cách làm |
|---|---|---|
| Thêm trường vào response | Không | Làm thoải mái |
| Thêm tham số tuỳ chọn | Không | Làm thoải mái |
| Thêm giá trị enum mới | ⚠ Có thể | Client phải xử lý enum lạ ngay từ đầu — ghi rõ trong spec |
| Xoá/đổi tên trường | Có | /v2, chạy song song ≥ 6 tháng |
Đổi code lỗi |
Có | Không bao giờ |
| Đổi kiểu trường | Có | /v2 |
| Siết validation | Có | Thông báo trước 30 ngày, đo tỷ lệ vi phạm trước khi bật |
| Đổi mặc định phân trang | Có | /v2 |
⚑ Deprecation: đánh dấu deprecated: true trong spec, trả header Sunset: <RFC 3339> và Deprecation: true, đo tần suất còn dùng trước khi tắt.
15. Cấu trúc thư mục spec
docs/05-api-spec/
├── openapi.yaml ← nguồn sự thật, tự chứa (P1+P2)
└── (P3+, khi file > ~2000 dòng thì tách:)
├── openapi.yaml chỉ còn info + paths $ref
├── paths/
└── components/schemas/
Giữ một file cho đến khi nó thực sự khó đọc. Tách sớm làm mất khả năng đọc tuần tự và khiến $ref rối.
16. Việc tiếp theo & cần quyết
| Tài liệu | Nội dung | Pha |
|---|---|---|
06-order-lifecycle.md |
Saga đơn hàng, kịch bản bù trừ | P2 |
07-payment-integration.md |
Đặc tả webhook VNPay/MoMo/ZaloPay/COD | P2 |
10-testing-strategy.md |
Contract test, dữ liệu test | P0 |
Cần quyết trước khi viết Controller đầu tiên:
- Tên miền API:
api.<domain>riêng, hay/<domain>/api? Ảnh hưởng CORS, cookie, cấu hình CDN. Đề xuất:/api/v1cùng miền ở P1–P5 (đơn giản, không CORS), tách sang subdomain khi làm headless ở P6. snake_casehaycamelCasetrong JSON? Tôi đã chọnsnake_case(§1) để khớp CSDL và domain event. Nếu storefront là TypeScript thuần thìcamelCasetự nhiên hơn với frontend — nhưng khi đó phải có tầng đổi tên và mọi log/event sẽ lệch với API. Đây là quyết định một chiều, nên chốt ngay.- Có công khai
order_numbertrong URL không? Tôi chọn có (khách đọc qua điện thoại). Điều kiện:order_numberphải chứa thành phần ngẫu nhiên, không đoán được từ đơn khác.
Tài liệu #5 · Lập 13/08/2026 · Nguồn sự thật máy đọc là 05-api-spec/openapi.yaml. Mã ⚑ tham chiếu doc 02 §23.
All rights reserved