Method semantics, PUT vs PATCH và cây quyết định status code
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 đề
- Dùng PUT mà gửi thiếu
note: đúng chuẩn thì server phải xóanote. Partner không hề muốn vậy. - Dùng PATCH "tự chế": gửi
"note": nullnghĩa là xóa hay bỏ qua? Mỗi service hiểu một kiểu. - Retry sau timeout: method nào gọi lại an toàn?
3. Giải pháp: hiểu cam kết của từng method
| Method | Safe | Idempotent | Ý 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 format | Sử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-Type | application/merge-patch+json | application/json-patch+json |
| Body | Một phần document | Danh sách operation |
| Xóa field | null | {"op":"remove"} |
| Đặt field = null thật | Không được | Được |
| Sửa phần tử mảng | Phải gửi cả mảng | /items/2/qty |
| Idempotent | Có | Không chắc (add vào mảng) |
| Dễ dùng cho partner | Rấ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
| PUT | PATCH (Merge) | |
|---|---|---|
| Body | Toàn bộ resource | Chỉ phần đổi |
| Field thiếu | Bị reset/xóa | Giữ nguyên |
| Upsert (tạo nếu chưa có) | Được, nếu client chọn ID | Thường không |
| Rủi ro | Ghi đè field client không biết | Mơ hồ null nếu không theo chuẩn |
7. Khi nào nên dùng / không nên dùng
- PUT: resource nhỏ, client nắm đủ dữ liệu (setting, avatar,
PUT /carts/{id}/items/{sku}). - Merge Patch: mặc định cho cập nhật một phần ở public API.
- JSON Patch: cần thao tác mảng chính xác hoặc đặt null thật.
- Đổi state có quy tắc (hủy, duyệt): dùng sub-resource POST (11.1), không PATCH status bừa.
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:
- Server trả 204 cả khi item đã không còn (DELETE "đảm bảo không tồn tại"). Hợp với cart.
- Hoặc giữ 404, và client coi 404 sau retry DELETE là thành cô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ống | Nê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ập | 401 |
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-Matchvớ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-Matchwith 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
- Safe = không đổi state; idempotent = N lần như 1 lần.
- PUT thay hết, Merge Patch sửa phần, null = xóa.
- 401 ai đấy? 403 không được. 404 giấu luôn.
- 400 hỏng cú pháp, 422 sai dữ liệu, 409 xung đột state, 412 sai version.
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.