Xây REST API với Spring Boot

Xem thử

Một API đặt lịch có hai ứng dụng khách: web và di động. Nhóm di động báo "API trả lỗi lung tung". Khi kiểm tra, các lỗi có ba hình dạng khác nhau: dữ liệu sai trả {"errors": [...]}, không tìm thấy trả một trang HTML của Tomcat, và JSON sai cú pháp trả {"timestamp":..., "error":"Bad Request"} kiểu mặc định. Mỗi hình dạng cần một đoạn mã xử lý riêng ở phía client, và mỗi lần backend thêm một loại lỗi mới, ứng dụng di động lại vỡ. Không ai quyết định hình dạng lỗi: nó là kết quả của những mặc định khác nhau ở những chỗ khác nhau.

Bài này xây một API REST với Spring Boot theo một hợp đồng thống nhất: tài nguyên và động từ HTTP, mã trạng thái có nghĩa, và một dạng lỗi duy nhất (RFC 9457, ProblemDetail) cho mọi lỗi từ kiểm tra dữ liệu tới JSON hỏng. Mọi mã và nội dung dưới đây được đo bằng yêu cầu HTTP thật tới ứng dụng đang chạy.

Sau bài này, bạn có thể

  • Thiết kế các endpoint CRUD cho một tài nguyên và chọn đúng mã trạng thái (200, 201, 204, 404, 405, 406, 415, 400).
  • Kiểm tra dữ liệu vào bằng @Valid và trả lỗi theo từng trường.
  • Dùng ProblemDetail và ResponseEntityExceptionHandler để mọi lỗi có cùng hình dạng.
  • Phân trang an toàn với giới hạn kích thước trang.
  • Biết các mặc định của Jackson trên Boot (ví dụ trường lạ bị bỏ qua) và đổi chúng khi cần hợp đồng chặt.

Chuẩn bị môi trường và dự án

Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, Spring Boot 4.1.1, Spring Framework 7.0.9, Jackson 3.1.5). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 08-spring-boot/03-rest-api, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.

Yêu cầu môi trường

Thành phầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Spring Boot4.1.1khai báo ở parent trong pom.xml
Dockerkhông cầnbài này không dùng cơ sở dữ liệu

Cấu trúc

text
03-rest-api/
  pom.xml
  src/main/java/dev/canhdominich/boot/rest/
    RestApplication.java        @SpringBootApplication
    Course.java                 record tài nguyên (id, tiêu đề, giá)
    CourseRequest.java          record dữ liệu vào, có ràng buộc Bean Validation
    PageResponse.java           record một trang kết quả
    CourseNotFoundException.java ngoại lệ nghiệp vụ
    CourseStore.java            kho dữ liệu trong bộ nhớ
    CourseController.java       các endpoint /api/courses
    ApiExceptionHandler.java    @RestControllerAdvice trả ProblemDetail
    RestDemo.java               khởi động ứng dụng, gửi yêu cầu HTTP và in kết quả

Khai báo thư viện: pom.xml

pom.xml

Dự án Maven dùng spring-boot-starter-parent 4.1.1 với spring-boot-starter-webmvc (Spring MVC, Tomcat nhúng, Jackson), spring-boot-starter-validation (Bean Validation cho @Valid) và spring-boot-starter-test. Plugin exec-maven-plugin chạy RestDemo bằng mvn -q compile exec:exec; spring-boot-maven-plugin đóng gói ứng dụng.

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>dev.canhdominich.javabackend</groupId>
    <artifactId>boot-rest-api</artifactId>
    <version>1.0.0</version>
    <name>Spring Boot - REST API</name>

    <properties>
        <java.version>17</java.version>
        <main.class>dev.canhdominich.boot.rest.RestDemo</main.class>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <configuration>
                    <executable>java</executable>
                    <arguments>
                        <argument>-classpath</argument>
                        <classpath/>
                        <argument>${main.class}</argument>
                    </arguments>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Tài nguyên và dữ liệu

API quản lý một tài nguyên Course (khóa học). Course là một record gồm id, tiêu đề và giá, và cũng là hình dạng trả về trong JSON:

src/main/java/dev/canhdominich/boot/rest/Course.java

java
package dev.canhdominich.boot.rest;

public record Course(Long id, String title, int price) {
}

Dữ liệu client gửi lên khi tạo hoặc sửa là một kiểu khác, CourseRequest: nó không có id (do server cấp), và mang các ràng buộc @NotBlank, @Size(max = 60) cho tiêu đề và @Min(0) cho giá. Tách kiểu vào và kiểu ra giúp client không tự đặt id:

src/main/java/dev/canhdominich/boot/rest/CourseRequest.java

java
package dev.canhdominich.boot.rest;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CourseRequest(
        @NotBlank @Size(max = 60) String title,
        @Min(0) int price) {
}

PageResponse là cách trả một trang kết quả: các phần tử, số trang, kích thước và tổng số phần tử, để client biết còn bao nhiêu trang:

src/main/java/dev/canhdominich/boot/rest/PageResponse.java

java
package dev.canhdominich.boot.rest;

import java.util.List;

public record PageResponse<T>(List<T> items, int page, int size, long total) {
}

CourseNotFoundException là ngoại lệ nghiệp vụ khi không có tài nguyên:

src/main/java/dev/canhdominich/boot/rest/CourseNotFoundException.java

java
package dev.canhdominich.boot.rest;

public class CourseNotFoundException extends RuntimeException {

    public CourseNotFoundException(long id) {
        super("Course " + id + " does not exist");
    }
}

CourseStore là nơi lưu dữ liệu, ở đây chỉ là một bản đồ trong bộ nhớ có bộ đếm id (bài Spring Data JPA sẽ thay nó bằng cơ sở dữ liệu). Phương thức page cắt một trang từ danh sách theo số trang và kích thước:

src/main/java/dev/canhdominich/boot/rest/CourseStore.java

java
package dev.canhdominich.boot.rest;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;

@Component
public class CourseStore {

    private final Map<Long, Course> courses = new ConcurrentSkipListMap<>();
    private final AtomicLong ids = new AtomicLong();

    public Course create(CourseRequest request) {
        Course course = new Course(ids.incrementAndGet(), request.title(), request.price());
        courses.put(course.id(), course);
        return course;
    }

    public Optional<Course> find(long id) {
        return Optional.ofNullable(courses.get(id));
    }

    public Optional<Course> replace(long id, CourseRequest request) {
        if (!courses.containsKey(id)) {
            return Optional.empty();
        }
        Course course = new Course(id, request.title(), request.price());
        courses.put(id, course);
        return Optional.of(course);
    }

    public boolean delete(long id) {
        return courses.remove(id) != null;
    }

    public PageResponse<Course> page(int page, int size) {
        List<Course> all = new ArrayList<>(courses.values());
        int from = Math.min(page * size, all.size());
        int to = Math.min(from + size, all.size());
        return new PageResponse<>(all.subList(from, to), page, size, all.size());
    }

    public void clear() {
        courses.clear();
        ids.set(0);
    }
}

Các endpoint

CourseController là lõi: năm endpoint cho một tài nguyên. Chú ý mã trả về của từng cái và hai ràng buộc trên tham số phân trang của list (@Min(0) cho page, @Min(1) @Max(50) cho size):

src/main/java/dev/canhdominich/boot/rest/CourseController.java

java
package dev.canhdominich.boot.rest;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.net.URI;
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.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;

@RestController
@RequestMapping("/api/courses")
public class CourseController {

    private final CourseStore store;

    public CourseController(CourseStore store) {
        this.store = store;
    }

    @GetMapping
    public PageResponse<Course> list(@RequestParam(defaultValue = "0") @Min(0) int page,
            @RequestParam(defaultValue = "5") @Min(1) @Max(50) int size) {
        return store.page(page, size);
    }

    @GetMapping("/{id}")
    public Course one(@PathVariable long id) {
        return store.find(id).orElseThrow(() -> new CourseNotFoundException(id));
    }

    @PostMapping
    public ResponseEntity<Course> create(@Valid @RequestBody CourseRequest request) {
        Course created = store.create(request);
        return ResponseEntity.created(URI.create("/api/courses/" + created.id())).body(created);
    }

    @PutMapping("/{id}")
    public Course replace(@PathVariable long id, @Valid @RequestBody CourseRequest request) {
        return store.replace(id, request).orElseThrow(() -> new CourseNotFoundException(id));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable long id) {
        if (!store.delete(id)) {
            throw new CourseNotFoundException(id);
        }
        return ResponseEntity.noContent().build();
    }
}

ApiExceptionHandler đưa mọi lỗi về ProblemDetail. Nó kế thừa ResponseEntityExceptionHandler, lớp của Spring đã biết biến các ngoại lệ chuẩn của MVC (JSON hỏng, sai kiểu nội dung, phương thức không hỗ trợ, ...) thành ProblemDetail. Lớp của bài thêm hai thứ: notFound cho ngoại lệ nghiệp vụ, và một bản ghi đè handleMethodArgumentNotValid để thêm thuộc tính errors liệt kê lỗi theo từng trường:

src/main/java/dev/canhdominich/boot/rest/ApiExceptionHandler.java

java
package dev.canhdominich.boot.rest;

import java.util.Map;
import java.util.TreeMap;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(CourseNotFoundException.class)
    public ProblemDetail notFound(CourseNotFoundException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Request validation failed");
        Map<String, String> errors = new TreeMap<>();
        e.getBindingResult().getFieldErrors().forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        problem.setProperty("errors", errors);
        return handleExceptionInternal(e, problem, headers, status, request);
    }
}

RestApplication là lớp ứng dụng như các bài trước:

src/main/java/dev/canhdominich/boot/rest/RestApplication.java

java
package dev.canhdominich.boot.rest;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RestApplication {

    public static void main(String[] args) {
        SpringApplication.run(RestApplication.class, args);
    }
}

Chạy và đọc kết quả

RestDemo khởi động ứng dụng ở cổng ngẫu nhiên rồi gửi một chuỗi yêu cầu bằng HttpClient và in từng câu trả lời (mã trạng thái, kiểu nội dung, các header đáng chú ý và thân):

src/main/java/dev/canhdominich/boot/rest/RestDemo.java

java
package dev.canhdominich.boot.rest;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.web.server.context.WebServerApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;

public class RestDemo {

    public static void main(String[] args) throws Exception {
        run();
    }

    public static List<String> run() throws Exception {
        List<String> lines = new ArrayList<>();
        SpringApplication application = new SpringApplication(RestApplication.class);
        application.setDefaultProperties(Map.of("server.port", "0", "spring.main.banner-mode", "off", "logging.level.root", "OFF"));
        try (ConfigurableApplicationContext context = application.run()) {
            int port = ((WebServerApplicationContext) context).getWebServer().getPort();
            Client client = new Client(port, lines);
            client.send("POST", "/api/courses", "application/json", "{\"title\":\"Spring Boot\",\"price\":100}");
            client.send("POST", "/api/courses", "application/json", "{\"title\":\"\",\"price\":-1}");
            client.send("POST", "/api/courses", "application/json", "{\"title\":\"Extra field ignored\",\"price\":5,\"unknown\":true}");
            client.send("POST", "/api/courses", "application/json", "{\"title\":");
            client.send("POST", "/api/courses", "text/plain", "Spring Boot");
            for (int i = 4; i <= 12; i++) {
                client.quiet("POST", "/api/courses", "application/json", "{\"title\":\"Course " + i + "\",\"price\":" + i + "}");
            }
            client.send("GET", "/api/courses/1", null, null);
            client.send("GET", "/api/courses/99", null, null);
            client.send("GET", "/api/courses?page=1&size=5", null, null);
            client.send("GET", "/api/courses?size=0", null, null);
            client.send("GET", "/api/courses?size=abc", null, null);
            client.send("PUT", "/api/courses/1", "application/json", "{\"title\":\"Spring Boot 4\",\"price\":150}");
            client.send("PUT", "/api/courses/99", "application/json", "{\"title\":\"x\",\"price\":1}");
            client.send("DELETE", "/api/courses/1", null, null);
            client.send("DELETE", "/api/courses/1", null, null);
            client.send("PATCH", "/api/courses/2", "application/json", "{}");
            client.send("GET", "/api/courses/2", "Accept: application/xml", null);
        }
        return lines;
    }

    static final class Client {

        private final int port;
        private final List<String> lines;
        private final HttpClient http = HttpClient.newHttpClient();

        Client(int port, List<String> lines) {
            this.port = port;
            this.lines = lines;
        }

        void quiet(String method, String path, String type, String body) throws Exception {
            request(method, path, type, body);
        }

        void send(String method, String path, String type, String body) throws Exception {
            HttpResponse<String> response = request(method, path, type, body);
            StringBuilder out = new StringBuilder(method + " " + path + (body == null ? "" : " " + body) + " -> " + response.statusCode());
            response.headers().firstValue("Content-Type").ifPresent(contentType -> out.append(" ").append(contentType.split(";")[0]));
            response.headers().firstValue("Location").ifPresent(location -> out.append(" Location: ").append(location));
            response.headers().firstValue("Allow").ifPresent(allow -> out.append(" Allow: ").append(String.join(", ", new java.util.TreeSet<>(List.of(allow.split(", "))))));
            String text = response.body().replaceAll("\"instance\":\"[^\"]*\",?", "").replaceAll(",}", "}").replaceAll("\\s+", " ").trim();
            if (!text.isEmpty()) {
                out.append(" | ").append(text);
            }
            System.out.println(out);
            lines.add(out.toString());
        }

        private HttpResponse<String> request(String method, String path, String type, String body) throws Exception {
            HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path));
            if (type != null && type.startsWith("Accept: ")) {
                builder.header("Accept", type.substring("Accept: ".length()));
            } else if (type != null) {
                builder.header("Content-Type", type);
            }
            builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
            return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
        }
    }
}

Chạy bằng mvn -q compile exec:exec. Kết quả (đã bỏ log ở stderr; header Allow được sắp xếp để ổn định):

text
POST /api/courses {"title":"Spring Boot","price":100} -> 201 application/json Location: /api/courses/1 | {"id":1,"title":"Spring Boot","price":100}
POST /api/courses {"title":"","price":-1} -> 400 application/problem+json | {"detail":"Request validation failed","status":400,"title":"Bad Request","errors":{"price":"must be greater than or equal to 0","title":"must not be blank"}}
POST /api/courses {"title":"Extra field ignored","price":5,"unknown":true} -> 201 application/json Location: /api/courses/2 | {"id":2,"title":"Extra field ignored","price":5}
POST /api/courses {"title": -> 400 application/problem+json | {"detail":"Failed to read request","status":400,"title":"Bad Request"}
POST /api/courses Spring Boot -> 415 application/problem+json | {"detail":"Content-Type 'text/plain;charset=UTF-8' is not supported.","status":415,"title":"Unsupported Media Type"}
GET /api/courses/1 -> 200 application/json | {"id":1,"title":"Spring Boot","price":100}
GET /api/courses/99 -> 404 application/problem+json | {"detail":"Course 99 does not exist","status":404,"title":"Not Found"}
GET /api/courses?page=1&size=5 -> 200 application/json | {"items":[{"id":6,"title":"Course 7","price":7},{"id":7,"title":"Course 8","price":8},{"id":8,"title":"Course 9","price":9},{"id":9,"title":"Course 10","price":10},{"id":10,"title":"Course 11","price":11}],"page":1,"size":5,"total":11}
GET /api/courses?size=0 -> 400 application/problem+json | {"detail":"Validation failure","status":400,"title":"Bad Request"}
GET /api/courses?size=abc -> 400 application/problem+json | {"detail":"Failed to convert 'size' with value: 'abc'","status":400,"title":"Bad Request"}
PUT /api/courses/1 {"title":"Spring Boot 4","price":150} -> 200 application/json | {"id":1,"title":"Spring Boot 4","price":150}
PUT /api/courses/99 {"title":"x","price":1} -> 404 application/problem+json | {"detail":"Course 99 does not exist","status":404,"title":"Not Found"}
DELETE /api/courses/1 -> 204
DELETE /api/courses/1 -> 404 application/problem+json | {"detail":"Course 1 does not exist","status":404,"title":"Not Found"}
PATCH /api/courses/2 {} -> 405 application/problem+json Allow: DELETE, GET, PUT | {"detail":"Method 'PATCH' is not supported.","status":405,"title":"Method Not Allowed"}
GET /api/courses/2 -> 406 application/problem+json | {"detail":"Acceptable representations: [application/json, application/*+json].","status":406,"title":"Not Acceptable"}

(Ở phần chạy thật, chín yêu cầu POST tạo thêm khóa học "Course 4" tới "Course 12" được gửi giữa dòng 5 và dòng 6 mà không in, để có đủ dữ liệu cho phân trang; tổng cộng 11 khóa học vì hai yêu cầu POST đầu bị từ chối.)

Mã trạng thái: mỗi mã nói điều gì

  • 201 Created kèm Location. Tạo thành công trả 201, header Location: /api/courses/1 cho client biết địa chỉ tài nguyên mới, và thân là chính tài nguyên (kèm id do server cấp). Client không cần gọi GET thêm.
  • 200 cho đọc và sửa, 204 cho xóa. GET và PUT thành công trả 200 kèm thân; DELETE thành công trả 204 No Content: thành công và không có gì để trả.
  • 404 khi không có tài nguyên. GET, PUT và DELETE trên id không tồn tại đều trả 404. DELETE hai lần trên cùng id: lần đầu 204, lần hai 404. Kết quả trên máy chủ giống nhau (tài nguyên đã mất), nhưng mã trả về khác; đây là lựa chọn của bài, có API chọn trả 204 cho cả hai lần.
  • 405 kèm Allow. PATCH không được hỗ trợ trả 405 và header Allow liệt kê các phương thức hợp lệ cho đường dẫn đó (DELETE, GET, PUT).
  • 406 và 415 cho kiểu nội dung. Accept: application/xml trả 406 (không sinh được XML); thân text/plain trả 415 (không đọc được).
  • 400 cho dữ liệu sai. Thân sai ràng buộc, JSON hỏng và tham số truy vấn sai đều trả 400.

Cách đọc hình: hình trên liệt kê phương thức và đường dẫn với câu trả lời đo được; hình dưới nhóm các mã trạng thái: 2xx là thành công, 4xx là lỗi do client (sai dữ liệu, sai đường dẫn, sai phương thức, sai kiểu nội dung).

Một dạng lỗi cho mọi trường hợp

Điều đáng nhìn nhất trong kết quả là mọi lỗi đều có cùng hình dạng: Content-Type: application/problem+json và các trường status, title, detail. Lỗi kiểm tra dữ liệu có thêm errors liệt kê từng trường (price và title) và thông báo của nó; 404 mang thông điệp nghiệp vụ Course 99 does not exist; JSON hỏng, sai kiểu nội dung, sai phương thức, sai Accept cũng cùng hình dạng. Với phần lớn các lỗi, bạn không viết mã nào (ResponseEntityExceptionHandler lo); bạn chỉ viết cho những lỗi nghiệp vụ của mình và những chỗ muốn bổ sung thông tin. Client chỉ cần một đoạn mã đọc lỗi.

Đang tải Animation…

Cách đọc hình: ba nhóm lỗi bên trái (kiểm tra dữ liệu, không tìm thấy, JSON hoặc kiểu nội dung sai) cùng đổ vào ProblemDetail rồi tới client với cùng các trường.

Phân trang và giới hạn kích thước

GET /api/courses?page=1&size=5 trả năm phần tử (id 6 đến 10) cùng "page":1,"size":5,"total":11: client tính được có 3 trang. Hai điều đáng chú ý: số trang bắt đầu từ 0, và size bị ràng buộc @Min(1) @Max(50) ngay trên tham số. size=0 trả 400 (Validation failure) và size=abc trả 400 (Failed to convert 'size' with value: 'abc'): một lời gọi không thể đòi "tất cả" hay một trang rỗng vô nghĩa, và không phải viết dòng kiểm tra nào trong phương thức.

Cách đọc hình: hàng trên là một yêu cầu phân trang hợp lệ và câu trả lời; hàng dưới là hai kiểu size sai và ràng buộc trong chữ ký phương thức chặn chúng.

Mặc định của Jackson: trường lạ bị bỏ qua

Yêu cầu thứ ba gửi thêm trường "unknown": true không có trong CourseRequest. Kết quả là 201: trường lạ bị bỏ qua lặng lẽ. Đó là mặc định của Boot (Jackson được cấu hình không báo lỗi trên thuộc tính lạ), tiện cho client có phiên bản mới hơn nhưng cũng che giấu lỗi chính tả như "prce". Với API cần hợp đồng chặt, bạn đặt spring.jackson.deserialization.fail-on-unknown-properties=true; bài thực hành đo hiệu quả của dòng này.

Cách đọc hình: trường lạ trong JSON đúng cú pháp bị bỏ qua và tạo thành công; JSON cụt trả 400. Ô cuối nhắc rằng muốn hợp đồng chặt phải đổi cấu hình có chủ đích.

Tình huốngMãContent-TypeDo ai tạo ra
Tạo thành công201application/jsonController
Dữ liệu vi phạm ràng buộc400application/problem+jsonApiExceptionHandler (bản ghi đè)
JSON cụt400application/problem+jsonResponseEntityExceptionHandler
Sai kiểu nội dung415application/problem+jsonResponseEntityExceptionHandler
Không có tài nguyên404application/problem+jsonApiExceptionHandler.notFound
Phương thức không hỗ trợ405application/problem+jsonResponseEntityExceptionHandler

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Hình dạng lỗiProblemDetail (RFC 9457) cho mọi lỗiMọi client phải hiểu một chuẩn, đổi lấy một đoạn mã đọc lỗi
Kiểu vào và kiểu raCourseRequest tách khỏi CourseThêm một lớp, đổi lấy việc client không đặt được id
Xóa lần haiTrả 404Client phải chấp nhận 404 là bình thường khi thử lại
Phân trangGiới hạn size ngay trên tham sốGiới hạn cố định; muốn đổi phải sửa mã
Trường JSON lạMặc định bỏ quaChe lỗi chính tả; đổi bằng thuộc tính nếu cần chặt
Lưu dữ liệuTrong bộ nhớ ở bài nàyMất dữ liệu khi tắt; bài Spring Data JPA thay bằng cơ sở dữ liệu

Playbook: gặp sự cố API thì làm gì

Triệu chứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
Client nhận 415Header Content-TypeGửi text/plain hoặc quên headerGửi application/json
406Header AcceptClient chỉ nhận kiểu mà API không sinhSửa Accept
400 nhưng không rõ trường nàoThân phản hồiLỗi JSON cú pháp (không có errors)Kiểm tra JSON; lỗi ràng buộc mới có errors
Lỗi mới có hình dạng khácHandler cho lỗi đóNgoại lệ không đi qua ResponseEntityExceptionHandlerThêm @ExceptionHandler trả ProblemDetail
Tạo thành công nhưng client không biết địa chỉHeader LocationTrả 200 thay vì 201Dùng ResponseEntity.created(...)
Dữ liệu sai lọt quaCó @Valid và ràng buộc khôngThiếu @Valid hoặc ràng buộcThêm vào tham số và record

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
Số total khác bàiBộ đếm id và kho dữ liệu dùng chung giữa các lần chạy trong cùng một tiến trìnhMỗi lần mvn compile exec:exec là một tiến trình mới; chạy đúng lệnh trong README
Port ... already in use khi tự chạy RestApplicationCổng 8080 đang bị chiếmmvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8081

Bài thực hành

Làm trong dự án của bài (thư mục 08-spring-boot/03-rest-api). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.

  1. Chạy thí nghiệm. mvn -q compile exec:exec. Tiêu chí: mười sáu dòng khớp bài.
  2. Bắt trường lạ. Tạo src/main/resources/application.properties với một dòng spring.jackson.deserialization.fail-on-unknown-properties=true và chạy lại. Tiêu chí: yêu cầu "unknown": true đổi từ 201 thành 400 application/problem+json với Failed to read request.
  3. Thêm ràng buộc. Đổi @Size(max = 60) thành @Size(min = 3, max = 60) và thêm một yêu cầu với "title":"ab". Tiêu chí: 400 và errors có "title":"size must be between 3 and 60".
  4. Đổi quy ước xóa. Sửa delete để xóa id không tồn tại cũng trả 204. Tiêu chí: lần DELETE thứ hai đổi từ 404 thành 204; viết một câu giải thích khi nào bạn chọn mỗi cách.
  5. Thêm sắp xếp. Thêm tham số sort (title hoặc price) cho list và sắp xếp trong CourseStore.page trước khi cắt trang. Tiêu chí: GET /api/courses?sort=price&size=3 trả ba khóa học có giá thấp nhất trước.
  6. Chạy bộ test (tùy chọn). Bộ test nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone, mvn test cho Tests run: 5, Failures: 0, Errors: 0.

Hiểu lầm và giới hạn

  • "REST là dùng JSON." REST là cách đặt tên tài nguyên và dùng động từ, mã trạng thái cho đúng nghĩa; JSON chỉ là định dạng.
  • "Trả 200 cho mọi thứ và đặt lỗi trong thân." Mã trạng thái là phần của hợp đồng; client và proxy dựa vào nó.
  • "@Valid kiểm tra cả tham số truy vấn." Ràng buộc trên tham số (như @Min) được kiểm tra, nhưng qua cơ chế khác và cho ra thông báo khác (Validation failure), như đã đo.
  • Số đo thuộc về Spring Boot 4.1.1 và Jackson 3.1.5 do Boot quản lý. Bài chưa nói về bảo mật, HATEOAS, phiên bản hóa API, ETag và điều kiện, tài liệu OpenAPI, tải tệp, và kiểm thử bằng MockMvc.

Tự kiểm tra

  1. Tạo tài nguyên thành công trả mã nào, kèm header nào?
  2. Vì sao dùng ProblemDetail cho mọi lỗi?
  3. Phân biệt 405, 406 và 415.
  4. Vì sao tách CourseRequest khỏi Course?
  5. Yêu cầu có trường JSON lạ trả gì theo mặc định của Boot, và làm sao đổi?

Gợi ý đáp án. (1) 201 Created, header Location trỏ tới tài nguyên mới. (2) Client xử lý mọi lỗi bằng một đoạn mã; mã trạng thái cho loại, thân cho chi tiết. (3) 405: phương thức không hỗ trợ (kèm Allow); 406: không sinh được kiểu client muốn (Accept); 415: không đọc được kiểu của thân yêu cầu (Content-Type). (4) Client không nên đặt id do server cấp; kiểu vào chỉ chứa trường client được phép gửi. (5) 201: trường lạ bị bỏ qua; đổi bằng spring.jackson.deserialization.fail-on-unknown-properties=true để trả 400.

Điều cần nhớ

  • Tài nguyên là danh từ trong URL, động từ HTTP là hành động; chọn mã trạng thái có nghĩa (201 kèm Location, 204, 404).
  • @Valid cộng ràng buộc trên record kiểm tra dữ liệu vào; ràng buộc ngay trên tham số truy vấn giới hạn phân trang.
  • Một dạng lỗi ProblemDetail cho mọi lỗi: kế thừa ResponseEntityExceptionHandler và bổ sung phần nghiệp vụ.
  • Biết mặc định của Jackson (trường lạ bị bỏ qua) và đổi có chủ đích.

Đọc thêm

Bài tiếp theo thêm khả năng quan sát cho ứng dụng: Actuator, log, và công cụ phát triển của Boot.