REST resource và API contract: thiết kế URL trước khi viết code

4 câu hỏi~25 phútTừ khóa: resource, noun, sub-resource, pagination, cursor, filter, sort, OpenAPI, contract-first, code-first

Bắt đầu chương API Design nhé. shop-app giờ phải mở public API cho web, mobile và cả partner. Thiết kế URL thế nào cho chuẩn?

Chương này nhìn API ở tầng thiết kế, không phụ thuộc framework. Chi tiết HTTP đã có ở bài HTTP; phần Spring chỉ dùng khi cần minh họa.

1. Bài toán: shop-app có 3 loại client

Web (React)      ──┐
Mobile app       ──┼──>  https://api.shop.example/v1/...
Partner (seller) ──┘

Web và mobile do team mình làm, sửa được. Partner thì không: họ viết code một lần, chạy nhiều năm. Một khi URL đã public, đổi là làm hỏng hệ thống người khác.

2. Vấn đề: API mọc tự phát

POST /createOrder
GET  /getOrdersByUser?uid=5
POST /order/cancel?id=10
GET  /api/products/list?pageNo=1&size=20
GET  /api/v1/Product-Search?page=0&limit=20

3. Giải pháp: resource + quy ước thống nhất + contract

Resource là danh từ, HTTP method là động từ:

Ý địnhĐúngTránh
Danh sách orderGET /ordersGET /getOrders
Một orderGET /orders/{id}GET /order?id=
Tạo orderPOST /ordersPOST /createOrder
Item của orderGET /orders/{id}/itemsGET /orderItems?orderId=
Order của tôiGET /me/ordersGET /orders?userId=5 (dễ BOLA, xem 11.6)

Quy tắc: danh từ số nhiều, chữ thường, gạch nối (/product-reviews), lồng tối đa 2 cấp.

Hành động không phải CRUD (hủy, thanh toán, hoàn tiền) thì mô hình thành sub-resource hoặc state:

POST /orders/{id}/cancellation        // tạo "sự hủy"
POST /orders/{id}/payments            // tạo một lần thanh toán
POST /orders/{id}/refunds
// hoặc nếu chỉ đổi state:
PATCH /orders/{id}  {"status":"CANCELLED"}

Sub-resource tốt hơn khi hành động có dữ liệu riêng (lý do hủy, người hủy) và có quy tắc nghiệp vụ, không chỉ là gán field.

Pagination / filter / sort: chọn một kiểu và dùng ở mọi endpoint.

GET /products?category=shoes&minPrice=100&sort=-createdAt,name&limit=20&cursor=eyJpZCI6MTAwfQ
{
  "data": [ ... ],
  "page": { "nextCursor": "eyJpZCI6MTIwfQ", "limit": 20 }
}

4. Code từng bước

Bước 1: viết contract trước (OpenAPI)

openapi: 3.1.0
info: { title: shop-app public API, version: 1.0.0 }
paths:
  /orders/{orderId}:
    get:
      operationId: getOrder
      parameters:
        - { name: orderId, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Order' } } } }
        "404": { $ref: '#/components/responses/NotFound' }
components:
  schemas:
    Order:
      type: object
      required: [id, status, total]
      properties:
        id: { type: string, example: ord_8f3k2 }
        status: { type: string, enum: [PENDING, PAID, SHIPPED, CANCELLED] }
        total: { type: string, example: "199000.00" }   # tiền là string/decimal, không dùng float

Bước 2: sinh interface bằng openapi-generator-maven-plugin (interfaceOnly=true), controller chỉ implement.

Bước 3: Code Java

@RestController
public class OrderController implements OrdersApi {   // OrdersApi sinh từ YAML

    private final OrderService service;

    public OrderController(OrderService service) { this.service = service; }

    @Override
    public ResponseEntity<OrderDto> getOrder(String orderId) {
        return ResponseEntity.ok(service.find(orderId));
    }
}

Bước 4: test

curl -s https://api.shop.example/v1/orders/ord_8f3k2 -H "Authorization: Bearer $TOKEN"
curl -s "https://api.shop.example/v1/products?limit=2&sort=-createdAt"

5. Cách hoạt động: contract-first

openapi.yaml (review như code, PR)
     │
     ├──> generate server interface ──> Spring controller
     ├──> generate client SDK  ──> mobile / partner
     ├──> mock server (Prism)  ──> frontend làm song song
     └──> lint (Spectral) + breaking-change check trong CI

6. So sánh: contract-first vs code-first

Contract-firstCode-first (springdoc)
Nguồn sự thậtFile YAMLAnnotation trong code
Tốc độ ban đầuChậm hơnNhanh
Client làm song songCó (mock)Phải chờ code
Rủi roYAML lệch code nếu không generateĐổi field Java là vô tình đổi API
Hợp vớiPublic/partner APIAPI nội bộ, prototype

7. Khi nào nên dùng / không nên dùng

?page=3&size=20 dễ hiểu hơn cursor nhiều. Sao bạn lại chọn cursor cho danh sách product?

Offset không sai, nhưng có hai điểm yếu khi dữ liệu lớn và thay đổi liên tục:

Offset: SELECT ... ORDER BY created_at DESC LIMIT 20 OFFSET 100000
        -> DB vẫn phải đọc và bỏ 100000 dòng

Trang 1 đọc xong, có 1 product mới chèn lên đầu
Trang 2 (OFFSET 20) -> dòng cuối trang 1 xuất hiện lại (trùng)

Cursor: WHERE (created_at, id) < (:lastCreatedAt, :lastId)
        ORDER BY created_at DESC, id DESC LIMIT 20
        -> dùng index, không trùng, không sót
OffsetCursor (keyset)
Nhảy tới trang 50ĐượcKhông
Tổng số trangCó (tốn COUNT)Thường không
Dữ liệu sâuChậmỔn định
Feed / infinite scroll / partner syncDễ trùng/sótPhù hợp

Admin panel cần "trang 7/42" thì dùng offset. Mobile feed và partner kéo dữ liệu thì cursor. Cursor nên là chuỗi opaque (base64) để client không tự chế và mình được đổi cách mã hóa.

Team có 8 dev, mỗi người một service. Làm sao giữ mọi API cùng một kiểu đặt tên, phân trang, lỗi?

Đúng rồi, quy ước chỉ nằm trong wiki thì sẽ bị quên. Phải biến thành thứ máy kiểm tra được:

  1. API style guide ngắn: tên resource, camelCase cho JSON, format ngày ISO-8601 UTC, tiền dạng string, envelope phân trang, error format (11.3).
  2. Linter (Spectral) chạy trên OpenAPI trong CI: bắt /getOrders, thiếu operationId, thiếu response 4xx.
  3. Breaking-change check (oasdiff): so YAML mới với bản đang chạy, fail build nếu xóa field, đổi type.
  4. Thư viện chung: PageResponse<T>, error handler dùng chung cho mọi service.
PR đổi openapi.yaml
  └─ CI: spectral lint ──> oasdiff breaking? ──> review ──> merge

Phỏng vấn hỏi về thiết kế REST resource và OpenAPI thì trả lời sao?

Q1. Nguyên tắc đặt URL cho REST API?

🇻🇳 Tiếng Việt:

URL là danh từ số nhiều chỉ resource, method HTTP là động từ. Lồng không quá hai cấp, ví dụ /orders/{id}/items. Hành động không phải CRUD như hủy hay hoàn tiền thì mô hình thành sub-resource POST /orders/{id}/refunds. Phân trang, filter, sort dùng một quy ước chung cho mọi endpoint.

🇬🇧 English:

URLs are plural nouns that identify resources, and HTTP methods are the verbs. I keep nesting to at most two levels, like /orders/{id}/items. Non-CRUD actions such as cancel or refund become sub-resources, for example POST /orders/{id}/refunds. Pagination, filtering and sorting follow one shared convention across all endpoints.

Q2. Offset vs cursor pagination?

🇻🇳 Tiếng Việt:

Offset đơn giản, nhảy trang được, nhưng chậm ở trang sâu và dễ trùng hoặc sót khi dữ liệu thay đổi. Cursor dùng keyset trên cột có index nên ổn định và nhanh, hợp với feed và đồng bộ dữ liệu, nhưng không nhảy tới trang bất kỳ.

🇬🇧 English:

Offset pagination is simple and supports jumping to a page, but it gets slow on deep pages and can duplicate or skip rows when data changes. Cursor pagination uses a keyset on indexed columns, so it is fast and stable, which fits feeds and data sync, but it cannot jump to an arbitrary page.

Q3. Contract-first hay code-first?

🇻🇳 Tiếng Việt:

Với public hoặc partner API em chọn contract-first: OpenAPI được review như code, sinh server interface và client SDK, có mock để frontend làm song song, CI kiểm tra breaking change. Với API nội bộ thay đổi nhanh thì code-first với springdoc nhanh hơn.

🇬🇧 English:

For public or partner APIs I prefer contract-first: the OpenAPI spec is reviewed like code, server interfaces and client SDKs are generated from it, a mock lets frontend teams work in parallel, and CI checks for breaking changes. For fast-moving internal APIs, code-first with springdoc is quicker.

Cách nhớ ngắn gọn


Tự kiểm tra

Thiết kế endpoint hoàn tiền một order thế nào?

POST /orders/{id}/refunds với body lý do, số tiền. Refund là resource có dữ liệu và trạng thái riêng.

Vì sao GET /me/orders an toàn hơn GET /orders?userId=5?

User lấy từ token, client không tự đổi được userId để xem order người khác.

Cursor pagination giải quyết vấn đề gì của offset?

Không phải quét bỏ N dòng ở trang sâu, và không trùng/sót khi có dữ liệu mới chèn vào.

Vì sao tiền nên là string/decimal trong JSON?

Float/double làm tròn sai (0.1 + 0.2). Client parse sang BigDecimal hoặc kiểu decimal.

Lợi ích lớn nhất của contract-first với partner?

Contract được chốt và review trước, sinh SDK, mock, và CI chặn breaking change trước khi tới partner.