Spring REST API & Data JPA/OpenAPI & springdoc-openapi — auto-generate API doc từ controller
18/46
Bài 18 / 46~12 phútError, Validation & API DocsMiễn phí lượt xem

OpenAPI & springdoc-openapi — auto-generate API doc từ controller

OpenAPI 3.1 là chuẩn JSON/YAML mô tả REST API contract. springdoc-openapi tự scan @RestController và sinh spec, expose Swagger UI tại /swagger-ui.html. Bài này giải thích cơ chế quét annotation, customize qua @Operation/@Schema, và global metadata — đặc biệt tại sao spec auto-generated là lựa chọn đúng.

TL;DR: OpenAPI Specification 3.1 là file JSON/YAML mô tả toàn bộ contract REST API — endpoint, method, parameter, body schema, response. springdoc-openapi quét @RestController annotation lúc startup, xây dựng object OpenAPI trong bộ nhớ, rồi expose spec tại /v3/api-docs và Swagger UI tại /swagger-ui.html. Code là nguồn sự thật duy nhất — spec auto-generated đảm bảo doc không bao giờ lệch implementation. Customize qua @Operation, @Schema, @ApiResponse; global metadata qua @Bean OpenAPI.

Backend viết xong OrderController 20 endpoint. Frontend hỏi: "Endpoint nào? Body field gì? Status code nào trả về?" Câu trả lời truyền thống là viết tay Confluence, nhưng Confluence luôn lệch code sau vài sprint. OpenAPI giải quyết bằng cách để code tự mô tả chính nó — springdoc-openapi quét annotation Spring MVC đã có sẵn và sinh ra spec mà không cần viết thêm gì.

Bài này tập trung một mục tiêu: hiểu cơ chế springdoc quét và sinh spec, biết customize annotation cơ bản, và biết cấu hình global metadata. Bài tiếp theo (Security scheme, groups & API-first) đào sâu JWT security scheme, grouped API, và workflow API-first.

1. OpenAPI Specification 3.1 — chuẩn mô tả contract

OpenAPI Specification (OAS) — chuẩn JSON/YAML mô tả REST API, phiên bản hiện tại 3.1 (2021). Trước 2016 tên là Swagger Specification; từ 2016 trao cho Linux Foundation, đổi tên thành OpenAPI.

Spec mô tả toàn bộ contract — không phải guide, không phải tutorial, mà là machine-readable definition:

openapi: 3.1.0
info:
  title: Order API
  version: 1.0.0
paths:
  /orders/{id}:
    get:
      operationId: getOrder
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: Order found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderDto'
        '404':
          description: Order not found
components:
  schemas:
    OrderDto:
      type: object
      properties:
        id: { type: integer, format: int64 }
        customer: { type: string }
        total: { type: number }

Spec là fuel cho cả hệ sinh thái tool:

ToolDùng để
Swagger UIRender spec thành interactive HTML, "Try it out" trực tiếp
OpenAPI GeneratorSinh client SDK 50+ ngôn ngữ (TypeScript, Python, Kotlin)
Postman / BrunoImport spec, tự tạo collection
SpectralLint spec, bắt lỗi design sớm
Prism / MockoonChạy spec như mock server

Vì sao spec auto-generated thắng doc viết tay?

Doc viết tay Confluence có vấn đề cốt lõi: hai nguồn sự thật — code và doc, và chúng không tự đồng bộ. Sau khi team refactor đổi tên field orderDate thành createdAt, doc vẫn nói orderDate. Frontend gọi sai field, nhận 400, mất nửa ngày debug.

Auto-generated spec từ springdoc loại bỏ nguồn sự thật thứ hai: spec sinh trực tiếp từ code đang chạy. Code thay đổi thì spec thay đổi theo ngay ở lần request tiếp theo. Không bao giờ lệch.

Nguyên tắc Single Source of Truth

Code Spring controller là nguồn sự thật duy nhất cho cả implementation lẫn documentation. springdoc-openapi đọc annotation đã viết sẵn — không phải viết lại thông tin dưới dạng khác.

2. springdoc-openapi — cơ chế quét và sinh spec

Thêm một dependency vào pom.xml:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>

Không cần config gì thêm. Spring Boot autoconfiguration của springdoc tự kích hoạt, expose:

  • /v3/api-docs — spec dạng JSON
  • /v3/api-docs.yaml — spec dạng YAML
  • /swagger-ui.html — Swagger UI interactive

Cơ chế bên dưới — springdoc quét annotation ra sao

springdoc-openapi chạy một ApplicationListener nhận event WebApplicationContext ready. Tại thời điểm này, toàn bộ bean Spring đã được khởi tạo (xem BeanFactory vs ApplicationContext để hiểu pha startup). springdoc lấy danh sách bean, lọc những bean có annotation @RestController, rồi đọc reflection metadata:

flowchart TB
  subgraph STARTUP["Spring Boot startup"]
    direction TB
    A["ApplicationContext ready<br/>toan bo bean khoi tao"]
    B["springdoc ApplicationListener<br/>bat event ContextRefreshed"]
    A --> B
  end
  subgraph SCAN["springdoc quet annotation"]
    direction TB
    C["Tim bean co @RestController"]
    D["Doc @RequestMapping, @GetMapping<br/>@PostMapping, ... tren method"]
    E["Doc @PathVariable, @RequestParam<br/>@RequestBody, @RequestHeader"]
    F["Doc @NotNull, @Size, @Min<br/>--> schema constraint"]
    G["Doc return type --> response schema"]
    C --> D --> E --> F --> G
  end
  subgraph BUILD["Xay dung spec"]
    direction TB
    H["Build OpenAPI 3.1 object<br/>trong bo nho"]
    I["paths: moi method = 1 path item"]
    J["components/schemas: moi DTO = 1 schema"]
    H --> I --> J
  end
  subgraph EXPOSE["Expose qua HTTP"]
    direction TB
    K["GET /v3/api-docs --> serialize JSON"]
    L["GET /swagger-ui.html --> render UI"]
  end
  B --> C
  G --> H
  J --> K
  J --> L

Điểm then chốt: springdoc không phân tích bytecode mà đọc reflection — annotation là metadata tồn tại ở runtime, Spring annotation (@GetMapping, @RequestParam) và Bean Validation annotation (@NotNull, @Size) đều available qua Class.getAnnotations(). Đây là lý do auto-detection work mà không cần xử lý compile-time.

Những gì springdoc tự detect được

springdoc đọc và map sang spec:

Annotation Spring/JakartaSinh ra field nào trong spec
@RestController + @RequestMappingPath item trong paths
@GetMapping("/orders/{id}")GET /orders/{id} operation
@PathVariable Long idParameter in: path, required: true
@RequestParam(required=false)Parameter in: query, required: false
@RequestBody OrderRequestrequestBody với schema ref tới DTO
@ResponseStatus(CREATED)Response code 201
@NotNull, @Size(min=2)Schema constraint required, minLength: 2
Return type ResponseEntity<OrderDto>Response 200 với schema

Khoảng 80% thông tin auto-detect đủ. 20% còn lại cần annotation bổ sung (section 3).

Sau khi add dependency và chạy app, mở http://localhost:8080/swagger-ui.html:

Order API (1.0.0)
├── Orders
│   ├── GET /api/v1/orders               List orders
│   ├── GET /api/v1/orders/{id}          Get order by ID
│   ├── POST /api/v1/orders              Create order
│   ├── PUT /api/v1/orders/{id}          Update order
│   └── DELETE /api/v1/orders/{id}       Delete order
└── Schemas
    ├── OrderRequest
    ├── OrderDto
    └── ProblemDetail

Click endpoint → expand → "Try it out" → fill parameter → Execute. Response hiển thị status code, header, body. Tiện hơn Postman cho onboard dev mới vì không cần cấu hình riêng.

3. Customize spec — annotation bổ sung

20% thông tin auto-detection không capture được là những thứ Java type system không encode: description nghiệp vụ, example value, semantic của từng response code. Dùng annotation từ package io.swagger.v3.oas.annotations.

@Operation — mô tả endpoint

@Operation(
    summary = "Get order by ID",
    description = "Retrieves full order details including items. Returns 404 if order ID does not exist in this tenant.",
    tags = {"Orders"}
)
@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable Long id) { ... }

summary xuất hiện trong list endpoint. description hiện khi expand. tags group endpoint thành section riêng trong UI — quan trọng khi controller có nhiều endpoint.

@Parameter — mô tả parameter

@GetMapping("/orders")
public List<OrderDto> list(
    @Parameter(description = "Filter by order status", example = "ACTIVE")
    @RequestParam(required = false) OrderStatus status,

    @Parameter(description = "Page number, 0-indexed", example = "0")
    @RequestParam(defaultValue = "0") int page,

    @Parameter(description = "Page size (1-100)", example = "20")
    @RequestParam(defaultValue = "20") int size
) { ... }

example là trường quan trọng nhất — user thấy ngay value hợp lệ, không phải đoán format.

@Schema — mô tả DTO field

@Schema(description = "Order creation request")
public record OrderRequest(

    @Schema(description = "Customer full name", example = "Nguyen Van A", minLength = 2, maxLength = 100)
    @NotBlank
    String customer,

    @Schema(description = "Order total in VND, must be positive", example = "299000", minimum = "1")
    @NotNull @Positive
    BigDecimal total,

    @Schema(description = "Requested delivery date (ISO 8601 date)", example = "2026-07-15")
    @NotNull @Future
    LocalDate deliveryDate
) {}

@Schema trên record component map 1-1 với properties trong spec JSON. example cực kỳ quan trọng — "Try it out" pre-fill value này.

@ApiResponse — explicit response code

@Operation(summary = "Create order")
@ApiResponses({
    @ApiResponse(responseCode = "201", description = "Order created successfully",
                 content = @Content(schema = @Schema(implementation = OrderDto.class))),
    @ApiResponse(responseCode = "400", description = "Validation failed — check errors array",
                 content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
    @ApiResponse(responseCode = "409", description = "Duplicate order within 5 minutes"),
    @ApiResponse(responseCode = "422", description = "Business rule violation")
})
@PostMapping("/orders")
@ResponseStatus(HttpStatus.CREATED)
public OrderDto create(@Valid @RequestBody OrderRequest req) { ... }

springdoc auto-detect 201 từ @ResponseStatus. Nhưng 409422 không có annotation Spring tương ứng — phải khai báo explicit. Quan trọng hơn: description phân biệt semantic của từng code.

Mỗi annotation map thẳng sang một field trong OpenAPI spec:

flowchart LR
  subgraph ANN["Java annotation"]
    direction TB
    A1["@Operation(summary, description, tags)"]
    A2["@Parameter(description, example)"]
    A3["@Schema(description, example, minLength)"]
    A4["@ApiResponse(responseCode, description)"]
  end
  subgraph SPEC["OpenAPI Spec"]
    direction TB
    B1["paths./orders.get.summary<br/>paths./orders.get.tags"]
    B2["paths./orders.get<br/>.parameters[].description + example"]
    B3["components.schemas<br/>.OrderRequest.properties[]"]
    B4["paths./orders.post<br/>.responses.201 / .400 / .409"]
  end
  A1 -->|"mo ta operation"| B1
  A2 -->|"mo ta tham so"| B2
  A3 -->|"mo ta field DTO"| B3
  A4 -->|"mo ta response code"| B4

Pitfall: Quên @Schema(example = ...) trên DTO

❌ Không có example:

public record OrderRequest(
    @NotBlank String customer,
    @NotNull @Positive BigDecimal total
) {}

Swagger UI "Try it out" hiển thị "customer": "string", "total": 0 — placeholder vô nghĩa. User phải tự biết format.

✅ Có example:

public record OrderRequest(
    @Schema(example = "Nguyen Van A") @NotBlank String customer,
    @Schema(example = "299000") @NotNull @Positive BigDecimal total
) {}

UI hiển thị "customer": "Nguyen Van A", "total": 299000 — user copy và chỉnh sửa, tiết kiệm thời gian onboard.

4. Global metadata — cấu hình info và server

Customize title, version, server list qua @Bean OpenAPI:

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("TaskFlow API")
                .version("v1.0")
                .description("REST API cho TaskFlow project management platform")
                .contact(new Contact()
                    .name("OLHub Engineering")
                    .email("[email protected]"))
                .license(new License()
                    .name("Apache 2.0")
                    .url("https://www.apache.org/licenses/LICENSE-2.0")))
            .servers(List.of(
                new Server().url("http://localhost:8080").description("Local dev"),
                new Server().url("https://staging.olhub.org").description("Staging"),
                new Server().url("https://api.olhub.org").description("Production")
            ));
    }
}

servers list xuất hiện trong dropdown phía trên Swagger UI — user chọn môi trường trước khi "Try it out", request tự đi đúng URL.

Ngoài Java config, nhiều property cấu hình qua application.yml:

springdoc:
  api-docs:
    path: /v3/api-docs        # default
    enabled: true
  swagger-ui:
    path: /swagger-ui.html    # default
    operationsSorter: method   # sort theo HTTP method trong UI
    tagsSorter: alpha          # sort tag theo alphabet
    tryItOutEnabled: true      # bat Try it out mac dinh
    filter: true               # thanh search endpoint trong UI
  packages-to-scan: com.olhub.api   # chi quet package nay
  paths-to-match: /api/**           # chi match URL pattern nay
  show-actuator: false              # loai actuator endpoint khoi spec

packages-to-scanpaths-to-match quan trọng cho app lớn — tránh scan toàn bộ classpath, bao gồm endpoint nội bộ không nên expose.

Vì sao không dùng springdoc.api-docs.enabled: false ở dev?

Một số team disable Swagger UI từ sợ lộ thông tin, nhưng làm vậy ở môi trường dev mất đi toàn bộ lợi ích. Pattern đúng:

# application-dev.yml
springdoc:
  api-docs:
    enabled: true
  swagger-ui:
    enabled: true

# application-prod.yml
springdoc:
  api-docs:
    enabled: false
  swagger-ui:
    enabled: false

Hoặc auth-protect endpoint spec trong production (detail ở bài tiếp theo).

Liên hệ các bài khác

Bài này là nền cho các bài liên quan trong course:

  • @RestController & mapping: springdoc đọc @GetMapping, @PostMapping, @PathVariable trên controller để sinh path item trong spec — annotation bài đó học là annotation springdoc quét ở bài này. Hiểu rõ mapping thì biết spec sinh ra từ đâu.
  • Validation với @Valid@Validated (bài trước): annotation @NotNull, @Size, @Min trên DTO vừa trigger validation, vừa được springdoc đọc để sinh schema constraint trong spec — một annotation, hai công dụng.
  • Exception handling & ProblemDetail (bài trước): @RestControllerAdvice handler của bài đó được springdoc detect và sinh error response schema trong spec — đặc biệt ProblemDetail class.
  • Security scheme, groups & API-first (bài tiếp theo): sau khi nắm cơ chế cơ bản ở bài này, bài đó đào sâu JWT @SecurityScheme, GroupedOpenApi cho multi-API, và workflow API-first với OpenAPI Generator.

Tóm tắt

  • OpenAPI Specification 3.1 là chuẩn JSON/YAML mô tả REST API contract — machine-readable, fuel cho cả hệ sinh thái tool (Swagger UI, Generator, Postman, Spectral).
  • Vì sao auto-generated thắng doc viết tay: loại bỏ nguồn sự thật thứ hai, code thay đổi thì spec thay đổi theo ngay, không bao giờ lệch.
  • springdoc-openapi: thêm 1 dependency, Boot autoconfig tự kích hoạt. Quét @RestController qua reflection lúc ContextRefreshed, xây OpenAPI object, expose /v3/api-docs + /swagger-ui.html.
  • Auto-detect: controller annotation, parameter binding, Bean Validation constraint, return type, @ResponseStatus.
  • Customize annotation từ io.swagger.v3.oas.annotations: @Operation (summary/description/tags), @Parameter (description/example), @Schema (description/example/constraint), @ApiResponse (explicit response code + semantic).
  • Pitfall quan trọng nhất: thiếu @Schema(example = ...) trên DTO — Swagger UI "Try it out" không dùng được vì placeholder vô nghĩa.
  • Global metadata qua @Bean OpenAPI — title, version, contact, server list với environment dropdown.

Tự kiểm tra

Tự kiểm tra
Q1
springdoc-openapi quét @RestController và sinh spec vào lúc nào trong vòng đời Spring Boot? Vì sao không phải lúc compile?

springdoc chạy một ApplicationListener nhận event ContextRefreshedEvent — thời điểm ApplicationContext đã refresh() xong, tức toàn bộ bean đã khởi tạo. Lúc này springdoc có thể lấy danh sách bean, lọc theo @RestController, và đọc reflection metadata.

Không phải lúc compile vì annotation Spring là runtime retention (@Retention(RUNTIME)) — tồn tại trong bytecode và đọc được qua Class.getAnnotations() ở runtime. springdoc không cần annotation processor hay bytecode manipulation — chỉ cần reflection trên bean đã load.

Hệ quả thực tế: mỗi lần request tới /v3/api-docs, springdoc serialize object OpenAPI đã build sẵn trong bộ nhớ. Spec không rebuild từ đầu mỗi request — chỉ rebuild khi context refresh lại (ví dụ dev hot-reload).

Q2
Kể 3 loại thông tin springdoc auto-detect được từ controller và 3 loại cần khai báo thủ công qua annotation. Vì sao 20% còn lại không auto được?

3 thứ auto-detect được:

  • URL path và HTTP method từ @GetMapping("/orders/{id}") → path item GET /orders/{id}.
  • Parameter binding từ @PathVariable, @RequestParam(required=false) → parameter schema với in: path/queryrequired: true/false.
  • Validation constraint từ @NotNull, @Size(min=2), @Min(1) → schema field required, minLength: 2, minimum: 1.

3 thứ phải khai báo thủ công:

  • Description nghiệp vụ của endpoint và field — Java type không encode "Get order by ID, returns 404 if tenant mismatch". Cần @Operation(description = "...")@Schema(description = "...").
  • Example value@Schema(example = "2026-07-15") cho LocalDate, @Parameter(example = "ACTIVE") cho enum. Không có thì UI hiện "string" / 0 vô nghĩa.
  • Semantic của response code không chuẩn409 Conflict hay 422 Unprocessable không có annotation Spring tương ứng. Phải dùng @ApiResponse(responseCode = "409", description = "...").

20% không auto được vì đây là ngữ nghĩa nghiệp vụ — thông tin tồn tại trong đầu developer, không encode trong Java type system. springdoc đọc được kiểu dữ liệu, không đọc được ý nghĩa.

Q3
@Schema(example = "299000") trên field DTO có tác dụng gì trong Swagger UI? Điều gì xảy ra nếu thiếu nó?

@Schema(example = "299000") map sang field example trong JSON Schema của spec. Swagger UI đọc field này và pre-fill value vào "Try it out" khi user click endpoint.

Với example đầy đủ, request body trong UI hiển thị:

{
"customer": "Nguyen Van A",
"total": 299000,
"deliveryDate": "2026-07-15"
}

User chỉ cần chỉnh số/tên rồi Execute — không phải đọc code để biết format.

Nếu thiếu, UI hiện placeholder mặc định của JSON Schema: {"customer": "string", "total": 0, "deliveryDate": "string"}. Dev mới không biết deliveryDate phải là ISO 8601 (2026-07-15) hay timestamp hay epoch. Thử sai nhiều lần trước khi hiểu.

example cũng xuất hiện trong spec JSON tại components.schemas.OrderRequest.properties.total.example: 299000 — client generator dùng nó để sinh test fixture.

Q4
Một endpoint có @ResponseStatus(HttpStatus.CREATED) và ném OrderNotFoundException xử lý bởi @RestControllerAdvice. springdoc tự sinh response nào? Response nào cần khai báo thủ công?

springdoc tự sinh:

  • 201 Created — đọc từ @ResponseStatus(HttpStatus.CREATED).
  • Schema của response body — đọc từ return type (vd OrderDto).
  • Nếu @RestControllerAdvice@ExceptionHandler(OrderNotFoundException.class) trả ProblemDetail với @ResponseStatus(NOT_FOUND) — springdoc detect được 404 và schema ProblemDetail.

Cần khai báo thủ công:

  • @ApiResponse(responseCode = "404", description = "Order ID does not exist in this tenant") — description phân biệt semantic "order không tồn tại" vs "endpoint không tồn tại" (cùng 404 status code).
  • Response code không có annotation tương ứng: 409 Conflict khi duplicate, 422 Unprocessable Entity khi business rule fail — phải khai báo explicit.
  • @ApiResponse(responseCode = "400", description = "Validation failed — see errors array") với schema ProblemDetail — dù auto-detect được code, description nghiệp vụ thiếu.

Quy tắc thực tế: mọi response code mà consumer API cần handle trong code của họ đều xứng đáng có @ApiResponse explicit với description rõ ràng.

Q5
springdoc.packages-to-scanspringdoc.paths-to-match dùng để làm gì? Vì sao cần giới hạn scope quét thay vì để quét toàn bộ?

Tác dụng:

  • springdoc.packages-to-scan: com.olhub.api — chỉ quét bean trong package này, bỏ qua bean ở package khác (vd com.olhub.internal).
  • springdoc.paths-to-match: /api/** — chỉ include endpoint có URL match pattern, bỏ qua /internal/**, /actuator/**.

Vì sao cần giới hạn:

  • Security: không có filter, springdoc mặc định quét mọi @RestController kể cả controller nội bộ — actuator, admin, service-to-service. Những endpoint này không nên expose trong Swagger UI public.
  • Performance: app lớn có hàng trăm controller, quét tất cả tốn thời gian startup và bộ nhớ. Giới hạn package giảm scan scope.
  • Noise: spec bao gồm endpoint debug/internal làm frontend team rối — họ không biết endpoint nào dùng được.

Pattern thực tế TaskFlow:

springdoc:
packages-to-scan: com.olhub.taskflow.api    # chi quet public controller
paths-to-match: /api/v1/**                   # chi URL public
show-actuator: false                         # loai actuator

Kết hợp với @Hidden trên endpoint cụ thể cần ẩn khỏi spec nhưng vẫn functional.

Bài tiếp theo: Security scheme, groups & API-first

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên