Command Palette

Search for a command to run...

[Spring Boot Basics] HTTP và REST căn bản: method, status code và thiết kế URL chuẩn REST

Bài 3 kết thúc với một HelloController trả lời curl, và từ đó đến giờ mọi bài đều nói về những gì diễn ra bên trong application: bean, injection, config, logging. Chương 3 quay ra bên ngoài và xây dựng một REST API. Trước annotation đầu tiên, nên nhìn xem thứ gì thực sự đi qua socket, vì mọi quyết định thiết kế API — dùng method nào, status code nào, URL nào — đều là quyết định về chính đoạn văn bản đó.

Bài này nói về chính HTTP, quan sát trực tiếp trên đường truyền với một application Spring Boot nhỏ: cấu trúc của một request và một response, mỗi method cam kết điều gì theo RFC 9110, vì sao idempotency quyết định một lần retry có an toàn hay không, những status code và header mà API thực sự dùng, REST nghĩa là gì khi gạt cái buzzword sang một bên, và các quy tắc thiết kế URL. Cuối bài là bảng endpoint mà phần còn lại của chương sẽ implement.

Năm HTTP method tác động lên một URL resource, và resource trả lời bằng status code

Mọi lần trao đổi bên dưới đều lấy từ một lần chạy application Spring Boot 4.1.1 (Spring Framework 7.0.9, embedded Tomcat 11.0.24) trên OpenJDK 21.0.6, dùng curl 8.7.1 và nc có sẵn trên macOS. Các trích dẫn tiêu chuẩn lấy từ RFC 9110 (HTTP Semantics), RFC 9112 (HTTP/1.1) và RFC 5789 (PATCH).

Một API product nhỏ để thử nghiệm

Sinh project giống hệt bài 3:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web" -o demo.zip
unzip demo.zip -d demo

Rồi thêm một class. Nó giữ product trong một ConcurrentHashMap và dùng AtomicLong để cấp id, vì database đến Chương 4 mới xuất hiện:

src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    public record Product(Long id, String name, BigDecimal price, String category) {}
 
    private final Map<Long, Product> store = new ConcurrentHashMap<>();
    private final AtomicLong nextId = new AtomicLong();
 
    public ProductController() {
        insert(new Product(null, "Clean Code", new BigDecimal("32.50"), "books"));
        insert(new Product(null, "Mechanical Keyboard", new BigDecimal("89.00"), "electronics"));
    }
 
    @GetMapping
    public List<Product> list(@RequestParam(required = false) String category) {
        return store.values().stream()
                .filter(p -> category == null || category.equals(p.category()))
                .toList();
    }
 
    @GetMapping("/{id}")
    public Product get(@PathVariable Long id) {
        return find(id);
    }
 
    @PostMapping
    public ResponseEntity<Product> create(@RequestBody Product body) {
        Product created = insert(body);
        return ResponseEntity.created(URI.create("/api/products/" + created.id())).body(created);
    }
 
    @PutMapping("/{id}")
    public Product replace(@PathVariable Long id, @RequestBody Product body) {
        find(id);
        Product replaced = new Product(id, body.name(), body.price(), body.category());
        store.put(id, replaced);
        return replaced;
    }
 
    @PatchMapping("/{id}")
    public Product update(@PathVariable Long id, @RequestBody Product body) {
        Product current = find(id);
        Product updated = new Product(id,
                body.name() != null ? body.name() : current.name(),
                body.price() != null ? body.price() : current.price(),
                body.category() != null ? body.category() : current.category());
        store.put(id, updated);
        return updated;
    }
 
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        if (store.remove(id) == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        return ResponseEntity.noContent().build();
    }
 
    private Product find(Long id) {
        Product product = store.get(id);
        if (product == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        return product;
    }
 
    private Product insert(Product body) {
        long id = nextId.incrementAndGet();
        Product product = new Product(id, body.name(), body.price(), body.category());
        store.put(id, product);
        return product;
    }
}

Các annotation trong class này là chủ đề của bài 16 và 17. Ở đây nó chỉ là thứ để gửi request tới. Store bắt đầu với hai product: Clean Code có id 1 và Mechanical Keyboard có id 2. Build jar rồi chạy:

Bash
./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8115

Các lần chạy bên dưới dùng port 8115; port trống nào cũng được. Toàn bộ lần trao đổi trong bài đến từ một lần chạy duy nhất của application, theo đúng thứ tự xuất hiện, nên product tạo ở phần này vẫn còn ở phần sau.

HTTP là văn bản: request và response trên đường truyền

HTTP/1.1 là giao thức dạng văn bản. Muốn nói chuyện bằng nó không cần thư viện client nào, chỉ cần một cách ghi byte vào kết nối TCP. printf viết request, còn nc gửi nó tới port 8115 và in ra mọi thứ nhận về:

Bash
printf 'GET /api/products/1 HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc localhost 8115
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:03:15 GMT
 
{"id":1,"name":"Clean Code","price":32.50,"category":"books"}

Đi ra là một request line và một header; quay về là một status line, ba header và một body JSON. Đó là toàn bộ lần trao đổi, và mọi thứ web framework làm với HTTP đều xây trên văn bản như thế này. Các \r\n trong chuỗi printf không phải để trang trí: RFC 9112 kết thúc mỗi dòng của một message HTTP/1.1 bằng CRLF, tức hai byte carriage return và line feed, và đánh dấu hết phần header bằng một dòng không chứa gì khác.

Trên macOS, nc thoát ngay khi response về tới. Nếu nc của bạn cứ đứng chờ, hãy bấm Ctrl+C. RFC 9112 đặt persistent connection làm mặc định cho HTTP/1.1, nên server giữ kết nối mở để chờ request tiếp theo, và không phải bản nc nào cũng đóng phía của nó khi input kết thúc; bản OpenBSD có cờ -N dành đúng cho việc đó.

Cấu trúc một HTTP request

GET không mang body, nên phải dùng POST mới thấy đủ các phần. Body là 55 byte JSON, và request nói rõ điều đó:

Bash
printf 'POST /api/products HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 55\r\n\r\n{"name":"Refactoring","price":47.00,"category":"books"}' | nc localhost 8115

RFC 9112 quy định mọi message HTTP/1.1, dù là request hay response, có cùng một hình dạng:

Text
HTTP-message = start-line CRLF
               *( field-line CRLF )
               CRLF
               [ message-body ]

Trong request ở trên, các phần đó là:

  • Request linePOST /api/products HTTP/1.1: method, request target và phiên bản giao thức, cách nhau bởi đúng một dấu cách. RFC 9110 quy định method phân biệt hoa thường, nên post không phải POST.
  • Header field — mỗi dòng một Name: value. Tên header không phân biệt hoa thường, nên content-typeContent-Type là cùng một header. HTTP/1.1 bắt buộc có Host, nhờ đó một server trên một địa chỉ có thể phục vụ nhiều host name.
  • Dòng trống — một CRLF đứng một mình. Đó là tín hiệu duy nhất cho biết phần header đã hết.
  • Body — nội dung. Không có gì đánh dấu chỗ nó kết thúc: server đọc đúng số byte mà Content-Length đã báo, vì vậy chuỗi printf không có CRLF sau đoạn JSON.

Cấu trúc một HTTP response

Tomcat trả lời POST đó bằng:

Text
HTTP/1.1 201 
Location: /api/products/3
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
3e
{"id":3,"name":"Refactoring","price":47.00,"category":"books"}
0
  • Status lineHTTP/1.1 201: phiên bản và một status code ba chữ số. Cú pháp cho phép một reason phrase đứng sau code, status-line = HTTP-version SP status-code SP [ reason-phrase ], và Tomcat 11 không gửi: dòng này là HTTP/1.1 201, một dấu cách, rồi CRLF. Dù sao RFC 9112 cũng bảo client bỏ qua phrase này, vì nó "is not a reliable channel for information".
  • Header fieldLocation là URL của product vừa được tạo, Content-Type cho biết body là JSON, còn Date là thời điểm response được sinh ra.
  • Dòng trống — y như trong request.
  • Body — lần này là chunked. Transfer-Encoding: chunked là cách thay cho Content-Length khi kích thước nội dung không được báo trước: mỗi chunk gồm kích thước dạng hex, CRLF, dữ liệu, CRLF, và một chunk kích thước 0 kèm thêm một CRLF sẽ kết thúc body. 3e là 62, đúng độ dài đoạn JSON. Response của GET lúc nãy thì dùng Content-Length: 61. curl và mọi thư viện HTTP đều tự ghép các chunk lại, nên ngoài một bản capture thô bạn hiếm khi thấy cách đóng khung này.

Một POST request thật và response 201 của nó theo từng dòng, với start line, header field, dòng trống và body của mỗi bên được đóng ngoặc và ghi nhãn

Tomcat xử lý request thiếu header Host thế nào

Bỏ Host đi:

Bash
printf 'GET /api/products/1 HTTP/1.1\r\n\r\n' | nc localhost 8115
Text
HTTP/1.1 400 
Content-Type: text/html;charset=utf-8
Content-Language: en
Content-Length: 435
Date: Sat, 12 Sep 2026 07:03:15 GMT
Connection: close
 
<!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 – Bad Request</h1></body></html>

RFC 9112 không cho server lựa chọn nào khác: "A server MUST respond with a 400 (Bad Request) status code to any HTTP/1.1 request message that lacks a Host header field". Trang HTML kia là trang báo lỗi của chính Tomcat, không phải JSON mà Spring Boot tạo ra, vì request chưa hề tới được Spring. Trong lần chạy được capture, DispatcherServlet đang ghi log ở mức DEBUG và in một dòng cho mọi request khác trong bài, nhưng không in gì cho request này. Connection: close báo cho client biết Tomcat sẽ đóng kết nối sau response này.

Cùng lần trao đổi đó qua curl -v

Bash
curl -v http://localhost:8115/api/products/1
Text
* Host localhost:8115 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8115...
* Connected to localhost (::1) port 8115
> GET /api/products/1 HTTP/1.1
> Host: localhost:8115
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 
< Content-Type: application/json
< Content-Length: 61
< Date: Sat, 12 Sep 2026 07:03:16 GMT
< 
* Connection #0 to host localhost left intact
{"id":1,"name":"Clean Code","price":32.50,"category":"books"}

Dòng bắt đầu bằng > là request curl đã gửi, < là response nhận về, còn * là ghi chú của chính curl về kết nối. curl đã tự viết request line và ba header: Host kèm port, vì 8115 không phải port mặc định 80; một User-Agent; và Accept: */*, nghĩa là media type nào cũng được. curl -i in status line và header mà không có các ghi chú kết nối, và đó là lệnh được dùng trong phần còn lại của bài.

Toàn bộ bài này là HTTP/1.1. HTTP/2 và HTTP/3 mang cùng những method, status code và header đó trong các binary frame thay vì dòng văn bản, vì vậy phần ngữ nghĩa nằm ở RFC 9110, còn định dạng trên đường truyền của từng phiên bản nằm ở RFC riêng — 9112, 9113 và 9114.

Các HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD và OPTIONS

Method là từ đầu tiên của request line, cho biết client muốn làm gì với resource đích. RFC 9110 gán cho method ba tính chất quan trọng hơn nhiều so với cái tên:

  • Safe — "the client does not request, and does not expect, any state change on the origin server". Server vẫn có thể ghi một dòng access log cho GET; điều quan trọng là client không yêu cầu thay đổi nào.
  • Idempotent — "the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request."
  • Cacheable — response có thể được lưu lại và dùng để trả lời một request sau đó.
MethodYêu cầu serverSafeIdempotentCacheableRequest body
GETgửi một representation của resourcethường không; nó "has no generally defined semantics"
HEADlàm như GET nhưng không gửi nội dungkhông
POSTxử lý representation gửi kèm theo quy tắc riêng của resourcekhôngkhôngchỉ khi có thông tin freshness tường minh và Content-Location khớp
PUTtạo hoặc thay state của resource bằng state gửi kèmkhôngkhôngcó, toàn bộ state mới
PATCHáp một tập thay đổi lên resourcekhôngkhôngchỉ khi có thông tin freshness tường minh và Content-Location khớpcó, các thay đổi
DELETExoá resourcekhôngkhôngthường không; không có ngữ nghĩa chung
OPTIONSmô tả các tuỳ chọn giao tiếp của resourcekhôngthường không

Có ba chi tiết bảng không thể hiện. PATCH hoàn toàn không nằm trong RFC 9110: RFC 5789 bổ sung nó năm 2010, gọi nó là "neither safe nor idempotent" nhưng cũng lưu ý rằng một PATCH "can be issued in such a way as to be idempotent". RFC 9110 có định nghĩa caching cho POST, nhưng nói thêm rằng "the overwhelming majority of cache implementations only support GET and HEAD". Và đặc tả còn định nghĩa CONNECT và TRACE, hai method mà API không dùng.

PUT, PATCH và POST khác nhau thế nào?

Ba method gửi body là ba method hay bị nhầm nhất, trong khi các RFC nói rất chính xác về từng cái:

  • PUT thay thế. Nó "requests that the state of the target resource be created or replaced with the state defined by the representation enclosed in the request message content." Body là toàn bộ state mới: thứ gì body không có thì không được giữ lại.
  • PATCH sửa đổi. Body của nó "contains a set of instructions describing how a resource currently residing on the origin server should be modified to produce a new version." Chỉ những gì các chỉ dẫn đụng tới mới thay đổi.
  • POST xử lý. Nó yêu cầu resource đích "process the representation enclosed in the request according to the resource's own specific semantics." Trong một API, điều đó nghĩa là tạo một phần tử mới trong collection mà server tự chọn URL, hoặc chạy một thao tác không method nào khác mô tả được.

Khác biệt giữa hai method đầu hiện rõ trên product 2. Một PATCH chỉ gửi giá:

Bash
curl -i -X PATCH -H 'Content-Type: application/json' -d '{"price":79.00}' http://localhost:8115/api/products/2
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 76
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":2,"name":"Mechanical Keyboard","price":79.00,"category":"electronics"}

Tên và category vẫn còn nguyên. Giờ là một PUT có tên và cùng mức giá, nhưng không có category:

Bash
curl -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical Keyboard","price":79.00}' http://localhost:8115/api/products/2
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 67
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":2,"name":"Mechanical Keyboard","price":79.00,"category":null}

category giờ là null. Body của PUT được coi là toàn bộ product, và một product không có category chính là thứ nó mô tả.

⚠️ Client muốn "đổi một field" mà lại gửi PUT với body thiếu sẽ xoá sạch mọi field nó không gửi. Nếu client chỉ cập nhật vài field mỗi lần, hãy cho họ dùng PATCH.

PUT cũng có thể tạo mới. RFC 9110 bắt buộc trả 201 (Created) khi PUT tạo ra resource, và 200 hoặc 204 khi nó thay một resource đã có. Điều đó chỉ hợp lý khi client tự chọn URL, như khi upload một file lên path do chính nó đặt. Trong API này server cấp id, nên PUT tới một id chưa tồn tại là lỗi chứ không phải tạo mới:

Bash
curl -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical Keyboard","price":79.00}' http://localhost:8115/api/products/77
Text
HTTP/1.1 404 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.110Z","status":404,"error":"Not Found","path":"/api/products/77"}

Test target nhận body PATCH dạng application/json thông thường và thay đổi những field có mặt, đây là cách đơn giản hoá phổ biến. Hai format patch chuẩn là JSON Merge Patch (RFC 7396, application/merge-patch+json), trong đó null nghĩa là xoá field, và JSON Patch (RFC 6902, application/json-patch+json), một danh sách thao tác như add, replaceremove.

HEAD và OPTIONS trên đường truyền

curl -I gửi HEAD:

Bash
curl -I http://localhost:8115/api/products/1
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:03:16 GMT

Cùng Content-Type, cùng Content-Length: 61 như GET, và không có body. RFC 9110 nói server "MUST NOT send content in the response" cho HEAD và "SHOULD send the same header fields" như khi trả lời GET, nên HEAD là cách rẻ để hỏi một thứ có tồn tại không và lớn cỡ nào.

OPTIONS hỏi một URL hỗ trợ những method nào:

Bash
curl -i -X OPTIONS http://localhost:8115/api/products/1
curl -i -X OPTIONS http://localhost:8115/api/products
Text
HTTP/1.1 200 
Allow: PATCH,DELETE,PUT,GET,HEAD,OPTIONS
Accept-Patch: 
Content-Length: 0
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
HTTP/1.1 200 
Allow: POST,GET,HEAD,OPTIONS
Accept-Patch: 
Content-Length: 0
Date: Sat, 12 Sep 2026 07:03:16 GMT

Allow liệt kê các method: một product đơn lẻ có thể được đọc, thay thế, patch và xoá; collection thì đọc được và nhận POST. Có ba điều đáng biết trong các response này:

  • Controller không khai báo method HEAD hay OPTIONS nào, vậy mà cả hai vẫn được trả lời và liệt kê. Spring tự làm việc đó, và bài 16 sẽ cho thấy bằng cách nào.
  • Thứ tự trong Allow không mang ý nghĩa gì và thậm chí không ổn định. Một lần chạy trước đó của cùng file jar in ra DELETE,PATCH,GET,HEAD,PUT,OPTIONS cho đúng URL này.
  • Accept-Patch là header RFC 5789 định nghĩa để liệt kê các format patch mà resource chấp nhận. Nó rỗng vì controller không khai báo format nào.

Trình duyệt cũng tự gửi OPTIONS, dưới dạng CORS preflight trước một số request cross-origin. Phần đó thuộc về Chương 5.

Idempotency và retry: vì sao gửi một request hai lần lại quan trọng

Mạng hỏng đúng ở chỗ bất tiện nhất: sau khi server đã làm xong việc và trước khi client kịp đọc câu trả lời. Timeout của client nổ, và nó đứng trước một câu hỏi mà HTTP không trả lời thay được — request đó đã được thực hiện chưa? Việc duy nhất client làm được là gửi lại request, và chuyện đó có vô hại hay không hoàn toàn tuỳ vào method.

RFC 9110 nêu rõ quy tắc. Các method idempotent được tách riêng "because the request can be repeated automatically if a communication failure occurs before the client is able to read the server's response". Với các method còn lại, "a client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent", và "a proxy MUST NOT automatically retry non-idempotent requests".

Gửi cùng một request hai lần chính là điều một lần retry làm. Trước tiên là POST:

Bash
curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"Effective Java","price":45.00,"category":"books"}' http://localhost:8115/api/products

Lần thứ nhất:

Text
HTTP/1.1 201 
Location: /api/products/4
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":4,"name":"Effective Java","price":45.00,"category":"books"}

Lần thứ hai, đúng lệnh đó:

Text
HTTP/1.1 201 
Location: /api/products/5
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":5,"name":"Effective Java","price":45.00,"category":"books"}

Hai lần thành công, hai product: 4 và 5. Một client retry một order hay một khoản thanh toán sau khi timeout sẽ tạo ra bản trùng, và nhận về một response 201 hoàn toàn bình thường cho việc đó.

Giờ là PUT, cũng hai lần:

Bash
curl -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Clean Code","price":29.90,"category":"books"}' http://localhost:8115/api/products/1

Cả hai lần đều in ra đúng cùng một response:

Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"id":1,"name":"Clean Code","price":29.90,"category":"books"}

Toàn bộ collection sau đó:

Bash
curl -s http://localhost:8115/api/products
Text
[{"id":1,"name":"Clean Code","price":29.90,"category":"books"},{"id":2,"name":"Mechanical Keyboard","price":79.00,"category":null},{"id":3,"name":"Refactoring","price":47.00,"category":"books"},{"id":4,"name":"Effective Java","price":45.00,"category":"books"},{"id":5,"name":"Effective Java","price":45.00,"category":"books"}]

Product 1 chỉ có một, ở mức giá 29.90, đúng như sau một lần PUT. Product 4 và 5 là hai bản trùng.

Idempotency là phát biểu về tác động lên server, không phải về response. DELETE là idempotent, và xoá bản trùng hai lần sẽ cho thấy khác biệt:

Bash
curl -i -X DELETE http://localhost:8115/api/products/5
curl -i -X DELETE http://localhost:8115/api/products/5
Text
HTTP/1.1 204 
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
HTTP/1.1 404 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.155Z","status":404,"error":"Not Found","path":"/api/products/5"}

204, rồi 404. Sau request nào thì product 5 cũng đã biến mất, đúng tác động client yêu cầu. RFC 9110 đã tính trước chính trường hợp này: lặp lại một request idempotent vẫn có cùng tác động dự định "even if the original request succeeded, though the response might differ."

Hai timeline trong đó response đầu tiên bị mất và client retry: POST gửi lại tạo product 5 bên cạnh product 4, PUT gửi lại để product 1 vẫn ở 29.90

Header Idempotency-Key

Muốn retry POST an toàn thì cần server hợp tác. Pattern thường gặp: client sinh một key duy nhất cho mỗi thao tác logic, thường là UUID, và gửi nó trong một header như Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324. Server lưu key cùng kết quả của request đầu tiên, và khi cùng key đó đến lần nữa, server trả lại kết quả đã lưu thay vì xử lý request thêm lần nữa. Lần retry không tạo ra thứ gì mới.

Đây là một quy ước, không phải tiêu chuẩn. Working group HTTPAPI của IETF đã soạn nó thành draft-ietf-httpapi-idempotency-key-header, và bản draft liệt kê Stripe cùng Adyen trong số các implementation hiện có, nhưng bản sửa cuối cùng, -07 ngày 15 tháng 10 năm 2025, đã hết hạn vào ngày 18 tháng 4 năm 2026 mà không được xuất bản thành RFC. Implement nó cần một chỗ lưu bền vững cho key và kết quả, nên nó không nằm trong chương này.

HTTP status code cho REST API

Status code gồm ba chữ số, và chỉ chữ số đầu tiên mang cấu trúc. Theo lời RFC 9110, "the first digit of the status code defines the class of response. The last two digits do not have any categorization role."

NhómMô tả trong RFC 9110Với một API
1xxInformational: the request was received, continuing processserver và thư viện HTTP tự xử lý; bạn không trả về những code này
2xxSuccessful: the request was successfully received, understood, and acceptedthao tác thành công
3xxRedirection: further action needs to be taken in order to complete the requesthiếm gặp trong JSON API
4xxClient Error: the request contains bad syntax or cannot be fulfilledclient phải sửa gì đó, hoặc với 429 là chờ, trước khi thử lại
5xxServer Error: the server failed to fulfill an apparently valid requestlỗi nằm ở phía server; retry sau có thể thành công

Client không nhận ra một code thì phải coi nó như code x00 của nhóm tương ứng, thêm một lý do để chỉ dùng code đã đăng ký thay vì tự chế ra 299. Những code một API thực sự dùng:

CodeTênTrả về khi
200OKGET thành công, hoặc PUT, PATCH, POST thành công và có body trả về
201Createdmột resource mới vừa được tạo; gửi URL của nó trong Location
202Acceptedrequest đã được đưa vào hàng đợi để xử lý sau và chưa xong
204No Contentthao tác thành công và không có gì để gửi lại, điển hình là DELETE
304Not Modifiedmột GET có điều kiện thấy bản cache của client vẫn còn mới
400Bad Requestkhông đọc được request: JSON sai cú pháp, giá trị sai type, query parameter hỏng
401Unauthorizedthiếu hoặc sai credentials; response phải có WWW-Authenticate
403Forbiddenđã biết caller là ai nhưng caller không được phép làm việc này
404Not Foundkhông có gì ở URL này, hoặc server chọn không tiết lộ là có
405Method Not AllowedURL tồn tại nhưng không hỗ trợ method này; response phải có Allow
406Not Acceptableserver không tạo được media type nào mà header Accept cho phép
409Conflictrequest mâu thuẫn với state hiện tại: đã giao hàng, đã đăng ký, bị người khác sửa trước
415Unsupported Media Typeendpoint không đọc được Content-Type của request body
422Unprocessable Contentbody đúng cú pháp nhưng nội dung vi phạm quy tắc
429Too Many Requestschạm giới hạn rate limit; Retry-After có thể cho biết khi nào thử lại (RFC 6585)
500Internal Server Errorlỗi bất ngờ trên server, thường là bug
502Bad Gatewaygateway hoặc proxy nhận response không hợp lệ từ service phía sau
503Service Unavailableservice tạm thời quá tải hoặc đang bảo trì; Retry-After có thể cho biết bao lâu
504Gateway Timeoutgateway hoặc proxy không nhận được response kịp thời từ service phía sau

Hai trong số các header đó là yêu cầu bắt buộc chứ không phải lời khuyên: response 401 "MUST send a WWW-Authenticate header field", và response 405 "MUST generate an Allow header field". 422 cũng có lịch sử riêng. WebDAV đưa nó ra với tên Unprocessable Entity, rồi RFC 9110 đưa nó vào HTTP lõi với tên Unprocessable Content, vì thế HttpStatus của Spring có cả UNPROCESSABLE_ENTITY lẫn UNPROCESSABLE_CONTENT.

Status code từ test target

Test target đã tạo ra 201 kèm Location và 204. Một product không tồn tại:

Bash
curl -i http://localhost:8115/api/products/99
Text
HTTP/1.1 404 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.162Z","status":404,"error":"Not Found","path":"/api/products/99"}

Một method mà URL không hỗ trợ — xoá toàn bộ collection:

Bash
curl -i -X DELETE http://localhost:8115/api/products
Text
HTTP/1.1 405 
Allow: POST, GET
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.168Z","status":405,"error":"Method Not Allowed","path":"/api/products"}

Response 405 có header Allow đúng như RFC yêu cầu. Nó chỉ liệt kê POST, GET, trong khi response OPTIONS cho cùng URL còn liệt kê HEAD và OPTIONS, hai method Spring tự trả lời; bài 16 sẽ quay lại chuyện này. Các body JSON này là error response mặc định của Spring Boot, thứ mà bài 20 sẽ thay thế.

Những lỗi hay gặp khi chọn status code

200 kèm lỗi trong body. Một 200 mang {"success": false, "error": "not found"} nói với mọi phần mềm nằm giữa server và caller — thư viện HTTP, proxy, cache, monitoring, logic retry — rằng request đã thành công. Chỉ code nào parse đúng loại body đó mới biết sự thật. Hãy đặt kết quả vào status code và chi tiết vào body.

500 cho lỗi của client. Một NumberFormatException từ parameter sai hay một NullPointerException vì thiếu field, nếu không được xử lý, sẽ đến tay client dưới dạng 500. Điều đó nói rằng server đang hỏng, mời client retry với một input sẽ không bao giờ hợp lệ, và đánh thức người đang trực. Hãy kiểm tra input và trả lời bằng một 4xx.

400 so với 422. RFC 9110 tách bạch hai code này. 400 nghĩa là server "cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing)". 422 nghĩa là server "understands the content type of the request content (hence a 415 (Unsupported Media Type) status code is inappropriate), and the syntax of the request content is correct, but it was unable to process the contained instructions." JSON sai cú pháp là 400, và Spring cũng đồng ý như vậy:

Bash
curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"Effective Java","price":}' http://localhost:8115/api/products
Text
HTTP/1.1 400 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:03:16.198Z","status":400,"error":"Bad Request","path":"/api/products"}

Log của application nêu rõ nguyên nhân: HttpMessageNotReadableException: JSON parse error: Unexpected character ('}' (code 125)): expected a value. Một product đúng cú pháp nhưng có giá âm là loại request mà 422 mô tả. Nhiều API trả 400 cho cả hai loại vấn đề, và cách đó vẫn chấp nhận được miễn là nhất quán; thiết kế ở cuối bài tách riêng hai trường hợp.

401 so với 403. 401 nghĩa là request "lacks valid authentication credentials": server không biết ai đang gọi, và phải cho biết cách xác thực trong WWW-Authenticate. 403 nghĩa là server "understood the request but refuses to fulfill it": có thể nó biết chính xác ai đang gọi, và câu trả lời vẫn là không. Sau một 403, client "SHOULD NOT automatically repeat the request with the same credentials". Chương 5 sẽ tạo ra cả hai.

Dùng 404 để che giấu sự tồn tại, nhưng không nhất quán. Trả 404 thay cho 403 với thứ caller không được xem là điều được cho phép rõ ràng: "An origin server that wishes to 'hide' the current existence of a forbidden target resource MAY instead respond with a status code of 404 (Not Found)." Cách này ngăn một customer dò ra id order nào tồn tại bằng cách thử lần lượt. Nó chỉ có tác dụng khi mọi resource loại đó được đối xử như nhau. Một API trả 403 cho order có thật của customer khác và 404 cho id không tồn tại đã nói cho caller biết chính xác điều nó muốn giấu.

Những header quan trọng với một API

HeaderXuất hiện trongÝ nghĩa
Content-Typerequest và response có bodymedia type của body trong message này
Acceptrequest, và response 406 hoặc 415các media type client chấp nhận nhận về, hoặc server chấp nhận nhận vào
Locationresponse 201 và 3xxURL của resource vừa tạo, hoặc của đích redirect
Allowresponse 405 và OPTIONScác method resource hỗ trợ
Cache-Controlchủ yếu là responseresponse có được lưu lại không, và trong bao lâu
ETag / If-None-Matchresponse / requestnhãn phiên bản của một representation, và "chỉ gửi nếu nó đã thay đổi"
Authorization / WWW-Authenticaterequest / response 401credentials của caller, và lời thách thức yêu cầu credentials đó
Retry-Afterresponse 429 và 503nên chờ bao lâu trước khi thử lại

Content-TypeAccept là cặp hay bị nhầm. Content-Type mô tả body của chính message chứa nó. Accept liệt kê các media type client sẵn sàng nhận. Khi server không đọc được type của request body, nó trả 415; khi không tạo được type nào mà Accept cho phép, nó trả 406. Việc chọn representation như vậy gọi là content negotiation, và cách dễ nhất để kích hoạt nó là quên header Content-Type trong curl:

Bash
curl -i -X POST -d '{"name":"Effective Java","price":45.00,"category":"books"}' http://localhost:8115/api/products
Text
HTTP/1.1 415 
Accept: application/json, application/*+json
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.175Z","status":415,"error":"Unsupported Media Type","path":"/api/products"}

Nửa request trong output -v của cùng lệnh đó cho thấy lý do:

Text
> POST /api/products HTTP/1.1
> Host: localhost:8115
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Length: 58
> Content-Type: application/x-www-form-urlencoded
> 

Với -d mà không tự đặt Content-Type, curl gửi application/x-www-form-urlencoded, format của một HTML form. Endpoint chỉ đọc JSON nên trả 415, và header Accept trong response đó liệt kê những gì nó sẽ nhận. RFC 9110 gán cho Accept đúng ý nghĩa đó khi nằm trong response: nó "provides information about which content types are preferred in the content of a subsequent request to the same resource."

Location chứa một URI reference. Test target gửi một giá trị tương đối, /api/products/4, và RFC 9110 resolve giá trị tương đối dựa trên target URI của request, ra http://localhost:8115/api/products/4.

Cache-Control, ETagIf-None-Match đi cùng nhau. Cache-Control cho trình duyệt và proxy biết response có được lưu không và lưu bao lâu, bằng các directive như no-store hay max-age=60. ETag là một nhãn phiên bản opaque của representation. Client đang giữ một bản sao sẽ gửi lại nhãn đó trong If-None-Match, và nếu representation chưa đổi, server trả 304 Not Modified không có body, để client tiếp tục dùng bản của mình. Không response nào trong bài có các header này, vì test target không gửi thông tin caching nào; HTTP caching là chủ đề của khóa Advanced.

Authorization mang credentials trong mọi request, ví dụ Authorization: Bearer theo sau là một token, và là cặp đối ứng của WWW-Authenticate trong response 401. Cả hai sẽ xuất hiện ở Chương 5.

REST thực sự là gì?

REST, Representational State Transfer, là một architectural style mà Roy Fielding định nghĩa trong luận án tiến sĩ năm 2000. Nó không phải giao thức, không phải format dữ liệu, cũng không phải một đặc tả. Nó là một tập ràng buộc, và HTTP được định hình theo chúng: Fielding cũng là một trong các editor của RFC 9110.

Resource và representation. Resource là bất cứ thứ gì có định danh: product 1, collection các product, các order của customer 42. Representation là một ảnh chụp state của resource ở format truyền đi được, chẳng hạn 61 byte JSON mà request đầu tiên trong bài nhận về. RFC 9110 dùng đúng những từ đó: representation "is information that is intended to reflect a past, current, or desired state of a given resource". Client không bao giờ nhận được chính resource, chỉ nhận representation của nó, và một product có thể có cả representation JSON lẫn XML tại cùng một URL.

Các ràng buộc, nói ngắn gọn:

  • Client-server — giao diện người dùng và nơi lưu dữ liệu tách rời, nên mỗi bên thay đổi độc lập được.
  • Stateless — mỗi request mang đủ mọi thứ server cần để hiểu nó. Không có session phía server mà request này dựng lên và request sau dựa vào, vì vậy API gửi credentials trong Authorization ở mọi lần gọi.
  • Cacheable — response nói rõ có được dùng lại hay không, để khỏi phải làm lại việc trả lời cùng một request hai lần.
  • Uniform interface — mọi resource được xử lý theo cùng một cách: định danh bằng URL, thao tác thông qua representation, message tự mô tả (method, status code, Content-Type), và hypermedia trong response chỉ cho client biết có thể làm gì tiếp theo.
  • Layered system — client không phân biệt được mình đang nói chuyện với application hay với gateway, load balancer hoặc cache đứng trước nó. Status code 502 và 504 tồn tại chính vì các tầng đó.
  • Code on demand — tuỳ chọn: server có thể gửi code thực thi được, như gửi JavaScript cho trình duyệt.

Richardson Maturity Model, do Leonard Richardson trình bày tại QCon và được Martin Fowler viết lại năm 2010, chấm điểm xem một API dùng HTTP tới mức nào. Level 0 gửi mọi thứ qua một URL, thường bằng POST, với tên thao tác nằm trong body. Level 1 cho mỗi resource một URL riêng. Level 2 dùng các HTTP method và status code đúng ngữ nghĩa đã định nghĩa, nên PUT là idempotent và 404 nghĩa là không tìm thấy; chương này xây một API Level 2. Level 3 bổ sung hypermedia control, gọi là HATEOAS (Hypermedia as the Engine of Application State): mỗi response mang link tới các hành động có thể làm tiếp, để client đi theo API thay vì hard-code URL. HATEOAS được nói trong khóa Advanced.

REST không phải là "JSON over HTTP". JSON là format, HTTP là giao thức; không thứ nào trong hai làm cho API trở thành RESTful. POST /api/getProducts trả về JSON là JSON over HTTP ở Level 0. Fielding còn đi xa hơn: trong bài viết năm 2008 "REST APIs must be hypertext-driven", ông viết rằng một API mà application state không được hypertext điều khiển thì "cannot be RESTful and cannot be a REST API. Period." Trong cách dùng hằng ngày, "REST API" nghĩa là Level 2 — resource, method và status code được dùng đúng — và series này dùng nghĩa đó.

Quy tắc thiết kế URL chuẩn REST

URL định danh một resource, còn method cho biết làm gì với nó. Phần lớn các quy tắc dưới đây đến từ việc giữ tách bạch hai nhiệm vụ đó.

Danh từ số nhiều, định danh nằm trong path

NênKhông nên
GET /api/productsGET /api/product, GET /api/productList
GET /api/products/42GET /api/product/42, GET /api/products?id=42

Đặt tên collection bằng danh từ số nhiều và trỏ tới một phần tử bằng cách nối id vào sau. Khi đó collection và các phần tử của nó chung một prefix, và /api/products/42 đọc lên là product 42 trong số các product. Id thuộc về path vì nó định danh resource; query string dành cho việc thu hẹp một collection.

Sub-resource cho quan hệ sở hữu, và nên lồng sâu đến đâu

NênKhông nên
GET /api/customers/42/ordersGET /api/customerOrders/42
GET /api/orders/1001GET /api/customers/42/orders/1001/items/3

Khi một resource thuộc về resource khác, hãy lồng nó vào: /api/customers/42/orders là các order của customer 42, và một 404 cho URL này có thể nghĩa là customer đó không tồn tại. Lọc một collection cấp cao nhất bằng GET /api/orders?customerId=42 cũng là lựa chọn hợp lệ ngang bằng khi order thường được tìm xuyên qua nhiều customer; mỗi quan hệ chọn một cách.

Chỉ lồng một cấp: collection, id, sub-collection. Khi phần tử con đã có id riêng, hãy cho nó một URL cấp cao nhất. Một order đặt bằng POST /api/customers/42/orders được trả lời với Location: /api/orders/1001, và từ đó /api/orders/1001 là địa chỉ của nó. Path sâu hơn khiến mọi URL phải kéo theo cả chuỗi resource cha, kể cả khi client chỉ biết id của order.

Query string cho lọc, sắp xếp, phân trang và tìm kiếm

NênKhông nên
GET /api/products?category=booksGET /api/products/category/books, GET /api/books
GET /api/products?sort=price,desc&page=0&size=20GET /api/products/sorted-by-price/page/1
GET /api/products?q=keyboardGET /api/products/search/keyboard

Query string thu hẹp hoặc sắp xếp một collection mà không đặt tên cho resource mới: kết quả vẫn là product, chỉ ít hơn hoặc theo thứ tự khác. Dạng sort=price,desc&page=0&size=20, với page đánh số từ 0, là dạng mà Spring Data đọc được, và Chương 4 sẽ implement nó cùng database. Test target đã lọc được theo category:

Bash
curl -i "http://localhost:8115/api/products?category=books"
Text
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 192
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
[{"id":1,"name":"Clean Code","price":29.90,"category":"books"},{"id":3,"name":"Refactoring","price":47.00,"category":"books"},{"id":4,"name":"Effective Java","price":45.00,"category":"books"}]

Product 1, 3 và 4. Product 2 đã mất category vì lần PUT lúc trước nên không còn khớp. Dấu nháy quanh URL để shell không diễn giải ?&.

Không dùng động từ trong URL, và xử lý các action thế nào

NênKhông nên
GET /api/productsGET /api/getProducts
POST /api/productsPOST /api/createProduct
DELETE /api/products/42POST /api/products/42/delete, GET /api/deleteProduct?id=42

Method chính là động từ. Động từ trong path, tốt thì lặp lại method, tệ thì mâu thuẫn với nó: GET /api/deleteProduct?id=42 là một method safe nhưng lại xoá dữ liệu. RFC 9110 tách riêng các method safe chính là để "automated retrieval processes (spiders) and cache performance optimization (pre-fetching)" có thể gọi chúng "without fear of causing harm", và một URL như thế trao cho chúng đúng cơ hội gây hại đó.

Có những thao tác không phải tạo, đọc, sửa hay xoá trên resource nào: huỷ order, duyệt hoá đơn, gửi lại email. Ba cách thiết kế thường gặp:

Cách thiết kếVí dụĐánh đổi
sub-path hành độngPOST /api/orders/1001/cancelrõ ràng, dễ viết tài liệu, phân quyền và ghi log như một thao tác; path chứa động từ và kết quả không phải resource để GET
sub-resourcePOST /api/orders/1001/cancellationgiữ nguyên danh từ và cho việc huỷ một URL riêng, nên đọc lại được lý do và thời điểm; thêm một resource phải mô hình hoá chỉ cho một lần đổi state
field statePATCH /api/orders/1001 với {"status":"CANCELLED"}không thêm URL; các quy tắc nghiệp vụ — chuyển trạng thái nào được phép, hoàn tiền và trả lại tồn kho đi kèm — bị giấu sau một lần cập nhật field chung chung

Thiết kế mẫu bên dưới dùng POST /api/orders/{id}/cancel: huỷ là một thao tác có side effect, và không có gì về lần huỷ cần đọc lại. Nếu có — lý do, thời điểm, ai huỷ — thì sub-resource sẽ hợp hơn. Dù chọn cách nào, method vẫn là POST, không bao giờ là GET, vì thao tác này thay đổi state.

Chữ thường, dấu gạch ngang, dấu gạch chéo cuối, phần mở rộng file và version

NênKhông nên
/api/product-categories/api/productCategories, /api/Product_Categories
/api/products/api/products/ như một cách viết thứ hai của cùng URL
/api/products/42 kèm Accept: application/json/api/products/42.json

Chữ thường, nối bằng gạch ngang. RFC 3986 quy định scheme và host của URI không phân biệt hoa thường, nhưng "the other generic syntax components are assumed to be case-sensitive". Spring tuân theo điều đó với path:

Bash
curl -i http://localhost:8115/API/Products
Text
HTTP/1.1 404 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:16 GMT
 
{"timestamp":"2026-09-12T07:03:16.212Z","status":404,"error":"Not Found","path":"/API/Products"}

Cùng request tới /api/products trả về 200. Một URL chỉ chạy với một cách viết chính là lý do trộn chữ hoa với chữ thường là ý tồi: sẽ có người gõ theo cách kia. Gạch ngang giữ cho segment nhiều từ dễ đọc, trong khi camelCase dựa vào chữ hoa rất dễ gõ sai, còn gạch dưới thì biến mất dưới đường gạch chân của link.

Mỗi URL một dạng, không có dấu gạch chéo cuối. Với HTTP, /api/products/api/products/ là hai URL khác nhau. Hãy chọn dạng không có dấu gạch chéo và dùng nó ở mọi nơi: trong controller, trong header Location, trong tài liệu. Spring làm gì khi request vẫn đến kèm dấu gạch chéo sẽ được trình bày ở bài 16.

Không dùng phần mở rộng file. Format của response được thương lượng bằng Accept, không mã hoá vào path; /api/products/42 là một resource dù được gửi ở format nào.

Versioning, dạng /api/v1/products trong path hoặc dạng request header hay media type parameter, là quyết định phải đưa ra trước khi có client đầu tiên. Spring Framework 7 hỗ trợ sẵn các chiến lược này thông qua ApiVersionConfigurer; chọn giữa chúng thuộc về khóa Advanced.

Thiết kế mẫu: các endpoint của catalogue product

Bài 16 đến bài 20 sẽ implement API này. Nó áp dụng mọi quy tắc ở trên: danh từ số nhiều, id trong path, lồng một cấp cho order của customer, query string để lọc, một URL hành động, và status code lấy từ bảng.

MethodURLÝ nghĩaThành côngLỗi thường gặp
GET/api/productsliệt kê product; ?category=, ?q=, ?sort=, ?page=, ?size=200400
POST/api/productstạo một product201 + Location400, 415, 422
GET/api/products/{id}đọc một product200404
PUT/api/products/{id}thay thế một product200400, 404, 415, 422
PATCH/api/products/{id}đổi một số field của product200400, 404, 415, 422
DELETE/api/products/{id}xoá một product204404
GET/api/customersliệt kê customer; ?page=, ?size=200400
POST/api/customersđăng ký một customer201 + Location400, 409 email đã được đăng ký, 415, 422
GET/api/customers/{id}đọc một customer200404
GET/api/customers/{id}/orderscác order của customer đó; ?status=200400, 404 không có customer này
POST/api/customers/{id}/ordersđặt một order cho customer đó201 + Location: /api/orders/{orderId}400, 404 không có customer này, 409 không đủ tồn kho, 415, 422
GET/api/ordersmọi order; ?status=, ?page=, ?size=200400
GET/api/orders/{id}đọc một order200404
POST/api/orders/{id}/cancelhuỷ một order200404, 409 đã giao hàng

Vài quyết định trong bảng đáng một câu giải thích:

  • Id do server cấp. POST tạo mới; PUT và PATCH chỉ thay đổi thứ đã tồn tại, nên PUT tới id không có là 404, như test target đã cho thấy.
  • Order được tạo dưới customer của nó nhưng sống độc lập. POST /api/customers/{id}/orders trả 201 với Location: /api/orders/{orderId}. URL lồng nghĩa là các order của customer này, URL cấp cao nhất nghĩa là order này.
  • Order được huỷ chứ không bị xoá. Order là một bản ghi nghiệp vụ, nên không có DELETE /api/orders/{id}.
  • 400 và 422 được tách riêng. 400 cho body hoặc parameter không đọc được; 422 cho thứ đọc được nhưng vi phạm quy tắc, chẳng hạn giá âm.
  • 409 nói về state hiện tại: email đã được đăng ký, tồn kho đã hết, order đã giao.
  • Chỉ liệt kê những lỗi riêng của từng endpoint. Endpoint nào cũng có thể trả 500 khi có sự cố, và 401 hoặc 403 khi Chương 5 thêm security.
  • Parameter phân trang là một phần của hợp đồng ngay từ đầu. page, sizesort được implement cùng database ở Chương 4.

Thiết kế mẫu dưới dạng cây resource dưới /api, với các method mỗi URL nhận và mũi tên 201 Location từ các order của một customer tới URL riêng của order

FAQ

PUT và PATCH khác nhau thế nào?

PUT gửi toàn bộ state mới và thay thế resource, nên thứ gì body không có là mất, như "category":null ở trên. PATCH chỉ gửi phần thay đổi. PUT idempotent theo định nghĩa; PATCH thì không, dù một patch gán field thành giá trị cố định tình cờ lại idempotent. Dùng PATCH khi client cập nhật vài field, và PUT khi client nắm toàn bộ representation.

POST tạo resource nên trả về 200 hay 201?

201, kèm header Location chứa URL của resource mới. RFC 9110 xác định resource vừa tạo bằng header đó "or, if no Location header field is received, by the target URI", mà với POST /api/products thì target URI là collection, không phải product mới. Trả thêm representation vừa tạo trong body giúp client khỏi phải gửi request thứ hai.

GET request có được có body không?

Format của message không cấm, nhưng RFC 9110 nói nội dung đó "has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request and close the connection". Hãy đặt parameter của GET vào path và query string.

Vì sao Spring Boot trả về HTTP/1.1 200 mà không có OK?

Vì embedded Tomcat 11 không gửi reason phrase, và HTTP/1.1 cho phép điều đó: phrase là tuỳ chọn trong status line, và RFC 9112 bảo client bỏ qua nó. Client hành động dựa trên con số.

API nên trả 403 hay 404 khi caller không được xem resource?

RFC 9110 cho phép cả hai. 403 ổn khi việc resource tồn tại không phải bí mật. 404 che giấu việc nó có tồn tại hay không, điều quan trọng với những id mà caller có thể dò lần lượt, như order của customer khác. Chọn cách nào thì cũng áp dụng cho mọi resource loại đó, nếu không khác biệt giữa hai câu trả lời sẽ để lộ đúng thứ bạn muốn giấu.

Có phải API nào trả JSON cũng là REST API?

Không. JSON là format, còn REST là một tập ràng buộc kiến trúc. Một API dồn mọi thứ qua POST /api với tên thao tác nằm trong body là JSON over HTTP. Trong cách dùng hằng ngày, "REST API" nghĩa là resource có URL riêng, method và status code dùng đúng ngữ nghĩa, tức Level 2 của Richardson Maturity Model, còn Roy Fielding chỉ dùng thuật ngữ này cho API được hypermedia điều khiển.

Kết luận

HTTP là các dòng văn bản: một start line, các header field, một dòng trống và một body, và mọi phần của một API đều ứng với một trong số đó. Method mang theo cam kết — GET, HEAD và OPTIONS là safe, PUT và DELETE là idempotent, POST và PATCH không có tính chất nào trong hai — và cùng một POST gửi hai lần đã tạo ra hai product, trong khi cùng một PUT gửi hai lần chỉ để lại một state. Status code đặt kết quả vào chỗ mọi client và thành phần trung gian đều đọc được, cùng với Location, Allow, WWW-AuthenticateRetry-After đi kèm những code cần chúng. REST là một tập ràng buộc chứ không phải format dữ liệu, và các quy tắc URL đến từ việc để URL đặt tên cho resource còn method nói phải làm gì với nó. Thiết kế mẫu gói tất cả thành mười bốn endpoint.

Bài tiếp theo bắt đầu implement chúng: @RestController và request mapping — @GetMapping, @PostMapping, @PutMapping, @DeleteMapping.

Bài viết liên quan

[Spring Boot Basics] IoC và Dependency Injection trong Spring: vì sao không còn tự new object

Ý tưởng mà cả framework dựa lên, demo bằng code chạy thật trên Spring Boot 4.1.1 và Java 21: một object graph bốn class tự new ở mọi tầng cùng ba hậu quả kéo theo, phân biệt rạch ròi Inversion of Control với Dependency Injection, nối tay cùng graph đó trong main mà không có framework nào, rồi để Spring container nối và in identity của từng instance ra để chứng minh, kèm một test JUnit 5 với stub tự viết, một lần đổi implementation mà không đụng vào class đang dùng nó, và danh sách thành thật những cái giá phải trả.

[Spring Boot Basics] Profile và thứ tự ưu tiên cấu hình trong Spring Boot: biến môi trường và tham số dòng lệnh

Profile và thứ tự ưu tiên cấu hình của Spring Boot 4.1.1, đo trên jar đã đóng gói: application-dev.yml được merge lên application.yml theo từng key, mọi cách set spring.profiles.active và profile nào thắng khi hai profile cùng active, spring.profiles.default, file nhiều document, profile group và @Profile expression, thứ tự property source thật từ command-line argument xuống tới @PropertySource, nơi Spring Boot tìm file config, environment variable, spring.config.import và lỗi khởi động mà từng sai lầm gây ra.

[Spring Boot Basics] Bean và ApplicationContext trong Spring: @Component, stereotype và component scanning

Bean trong Spring thực chất là gì và object nào không bao giờ nên là bean, chứng minh @Component là meta-annotation nằm dưới @Service, @Repository và @Controller, mỗi stereotype thật sự thêm gì lúc runtime, component scanning biến file .class thành BeanDefinition ra sao, quy tắc đặt tên bean kể cả trường hợp hai chữ hoa, API của ApplicationContext cùng số bean thật của một app Spring Boot 4.1.1, và ba kiểu đăng ký bean thất bại kèm thông báo lỗi thật.

[Spring Boot Basics] JSON với Jackson 3 và DTO trong Spring Boot: serialize, deserialize và MapStruct

JSON trong Spring Boot 4.1.1 với Jackson 3.1.5, kiểm chứng trên project thật: JacksonJsonHttpMessageConverter và bean jacksonJsonMapper, package tools.jackson, JsonMapper immutable và exception unchecked, đo các giá trị mặc định của Jackson 3 so với use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enum và Optional, record, @JsonAlias và @JsonCreator, property spring.jackson và JsonMapperBuilderCustomizer, vì sao DTO tốt hơn để lộ entity, map bằng tay và MapStruct 1.6.3 với Gradle và Maven.