Method semantics, PUT vs PATCH và cây quyết định status code

4 câu hỏi~25 phútTừ khóa: safe, idempotent, PUT, PATCH, JSON Merge Patch, JSON Patch, If-Match, 201, 204, 409, 412, 422

Tiếp đi: method semantics. Partner cập nhật địa chỉ giao hàng của order thì nên cho họ dùng PUT hay PATCH, và body trông thế nào?

Lý do PATCH tồn tại đã nói ở bài HTTP. Ở đây ta chốt hợp đồng: method nào hứa gì với client, và format PATCH cụ thể.

1. Bài toán

Order của shop-app:

{ "id":"ord_1", "status":"PENDING", "note":"Gọi trước khi giao",
  "shippingAddress": { "street":"12 Lê Lợi", "city":"HCM", "phone":"0901..." } }

Partner chỉ muốn đổi city.

2. Vấn đề

3. Giải pháp: hiểu cam kết của từng method

MethodSafeIdempotentÝ nghĩa
GET, HEAD✔✔Đọc, không đổi state
PUT✘✔Thay toàn bộ resource bằng body
DELETE✘✔Xóa; gọi lần 2 state vẫn vậy (có thể trả 404)
PATCH✘Tùy formatSửa một phần
POST✘✘Tạo / xử lý; cần Idempotency-Key (11.3)

Idempotent nói về state server, không nói response phải giống nhau.

Hai format PATCH chuẩn:

JSON Merge Patch (RFC 7396)JSON Patch (RFC 6902)
Content-Typeapplication/merge-patch+jsonapplication/json-patch+json
BodyMột phần documentDanh sách operation
Xóa fieldnull{"op":"remove"}
Đặt field = null thậtKhông đượcĐược
Sửa phần tử mảngPhải gửi cả mảng/items/2/qty
IdempotentCóKhông chắc (add vào mảng)
Dễ dùng cho partnerRất dễKhó hơn

4. Code từng bước

Bước 1: chọn Merge Patch cho public API, ghi rõ trong OpenAPI.

Bước 2: request

PATCH /orders/ord_1
Content-Type: application/merge-patch+json
If-Match: "v7"

{ "shippingAddress": { "city": "Hà Nội" }, "note": null }

Kết quả: city đổi, street/phone giữ nguyên, note bị xóa.

Bước 3: Code Java (Jackson, merge vào DTO được phép sửa, không merge vào entity):

@PatchMapping(path = "/orders/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<OrderDto> patch(@PathVariable String id,
                                      @RequestHeader("If-Match") String ifMatch,
                                      @RequestBody JsonNode patch) throws IOException {
    Order order = service.load(id);
    if (!order.etag().equals(ifMatch)) {
        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();   // 412
    }
    OrderUpdate current = OrderUpdate.from(order);            // chỉ field được sửa
    OrderUpdate merged = mapper.readerForUpdating(current).readValue(patch);
    validator.validate(merged);                                // sai -> 422
    OrderDto saved = service.apply(id, merged);
    return ResponseEntity.ok().eTag(saved.etag()).body(saved);
}

Lưu ý: readerForUpdating merge nested object nhưng không tự xử lý null = xóa ở mọi trường hợp; nếu cần đúng RFC 7396 tuyệt đối thì dùng jakarta.json.JsonMergePatch.

Bước 4: test

curl -i -X PATCH https://api.shop.example/v1/orders/ord_1 \
  -H "Authorization: Bearer $TOKEN" -H 'If-Match: "v7"' \
  -H "Content-Type: application/merge-patch+json" \
  -d '{"shippingAddress":{"city":"Hà Nội"}}'
# 200 + ETag: "v8"   |  gửi lại với "v7" -> 412

5. Cách hoạt động: cây quyết định status code

Request tới
 ├─ Không có/sai token ..................... 401 (+ WWW-Authenticate)
 ├─ Có token nhưng không đủ quyền .......... 403 (hoặc 404 để giấu tồn tại)
 ├─ Resource không có ...................... 404
 ├─ Method không hỗ trợ .................... 405
 ├─ Content-Type không hỗ trợ .............. 415
 ├─ JSON hỏng / sai kiểu ................... 400
 ├─ Đúng cú pháp, sai nghiệp vụ/validation . 422
 ├─ Xung đột state (đã PAID, trùng) ........ 409
 ├─ If-Match không khớp .................... 412
 ├─ Quá rate limit ......................... 429 (+ Retry-After)
 └─ Thành công
     ├─ Tạo mới ............................ 201 + Location
     ├─ Nhận, xử lý sau .................... 202 + link theo dõi
     ├─ Không có body ...................... 204
     └─ Có body ............................ 200

Cách trả các code này bằng ResponseEntity trong Spring xem bài 4.2.9.

6. So sánh: PUT vs PATCH

PUTPATCH (Merge)
BodyToàn bộ resourceChỉ phần đổi
Field thiếuBị reset/xóaGiữ nguyên
Upsert (tạo nếu chưa có)Được, nếu client chọn IDThường không
Rủi roGhi đè field client không biếtMơ hồ null nếu không theo chuẩn

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

Mobile gọi DELETE /cart/items/42, timeout nên gọi lại, lần hai nhận 404. App báo lỗi đỏ cho user. Vậy DELETE có thật sự idempotent không?

Có. Idempotent nghĩa là state cuối giống nhau sau 1 hay N lần: item 42 không còn trong giỏ. Response được phép khác (204 rồi 404).

Lần 1: DELETE -> xóa xong -> response mất do timeout
Lần 2: DELETE -> đã không còn -> 404
State: giỏ không có item 42 (giống nhau)

Hai cách làm cho client dễ sống:

Chọn một và ghi rõ trong contract.

User A mở /orders/ord_99 là order của user B. Trả 403 cho rõ ràng hay 404?

Với public API, thường trả 404. 403 xác nhận "order này có tồn tại", attacker dò ID được (enumeration). 404 không lộ gì.

Tình huốngNên trả
Resource của người khác (dữ liệu riêng)404
User biết resource tồn tại nhưng thiếu role (admin page)403
Chưa đăng nhập401

Quan trọng hơn mã lỗi: phải có kiểm tra ownership. Thiếu nó là lỗi BOLA, đứng đầu OWASP API Top 10 (11.6).

Phỏng vấn hỏi về safe/idempotent, PUT vs PATCH và chọn status code thì trả lời sao?

Q1. Safe và idempotent khác nhau thế nào?

🇻🇳 Tiếng Việt:

Safe là không đổi state server, như GET, HEAD. Idempotent là gọi nhiều lần thì state cuối như gọi một lần: GET, PUT, DELETE. POST không idempotent, PATCH tùy format. Idempotent quyết định client có được retry tự động không.

🇬🇧 English:

Safe means the request does not change server state, like GET and HEAD. Idempotent means repeating the request leaves the same final state as doing it once: GET, PUT and DELETE. POST is not idempotent and PATCH depends on the format. Idempotency decides whether clients can retry automatically.

Q2. PUT vs PATCH, JSON Merge Patch vs JSON Patch?

🇻🇳 Tiếng Việt:

PUT thay toàn bộ resource, field thiếu bị xóa. PATCH sửa một phần. Merge Patch gửi một phần document, null nghĩa là xóa, đơn giản nên em dùng cho public API. JSON Patch là danh sách operation add, remove, replace, mạnh hơn cho mảng nhưng phức tạp hơn. Kèm If-Match với ETag để tránh lost update.

🇬🇧 English:

PUT replaces the whole resource, so missing fields are cleared. PATCH applies a partial change. JSON Merge Patch sends a partial document where null means remove; it is simple, so I use it for public APIs. JSON Patch is a list of add, remove and replace operations; it is more precise for arrays but harder to use. I add If-Match with an ETag to prevent lost updates.

Q3. 400, 409, 422 khác nhau ra sao?

🇻🇳 Tiếng Việt:

400 là request hỏng cú pháp, JSON sai. 422 là cú pháp đúng nhưng dữ liệu vi phạm validation hoặc quy tắc. 409 là xung đột với state hiện tại, ví dụ hủy order đã giao. Thay đổi dựa trên version cũ thì 412.

🇬🇧 English:

400 means the request is malformed, such as invalid JSON. 422 means the syntax is fine but the data breaks validation or business rules. 409 means a conflict with the current state, like cancelling an already shipped order. An update based on a stale version gets 412.

Cách nhớ ngắn gọn


Tự kiểm tra

PUT body thiếu field note thì server nên làm gì?

Theo ngữ nghĩa thay toàn bộ: note bị xóa/reset về mặc định.

Merge Patch có đặt một field thành null thật được không?

Không. null nghĩa là xóa field. Cần null thật thì dùng JSON Patch.

Tạo order thành công nên trả gì?

201 Created + header Location: /orders/{id}, có thể kèm body.

If-Match sai version trả gì?

412 Precondition Failed. Client đọc lại rồi sửa tiếp.

Vì sao xem order người khác hay trả 404?

Không xác nhận ID tồn tại, chống dò ID. Vẫn phải kiểm tra ownership.