原创

Spring Boot 集成 Swagger UI 完整教程:OpenAPI 文档与接口结果一起验证

Swagger UI 能打开,却没有接口;字段旁边写了数量最小为 1,发 0 仍然成功;文档标注了 404,实际却吐出另一种错误结构。接入接口文档时,这些问题比“有没有一个漂亮页面”更值得检查。

这篇把一个很小的文具报价接口接入 springdoc:读取商品、提交报价、在页面发出真实请求,再读取生成的 OpenAPI JSON 校验字段与错误响应。最后关闭文档入口,确认业务接口仍正常。所有请求都在本机,不需要文档平台账号。

先选对与 Boot 对应的依赖线

示例固定使用 JDK 17、Maven 3.8.8、Spring Boot 3.5.0、springdoc-openapi 2.8.9。这是一个明确的可复现组合,不是要求所有项目使用这两个版本。springdoc 2.x 面向 Boot 3,不能把当前官网首页其他大版本的依赖直接贴进来;2.8.9 的发布记录也专门包含 Boot 3.5.0 适配。

项目采用 Servlet MVC,因此选 springdoc-openapi-starter-webmvc-ui,它同时提供描述文档与 Swagger UI。不要再混入旧的 Springfox 启动器或 Swagger 2 注解。若项目是 WebFlux,需要选相应模块,而不是把两套 Web 技术栈一起加上。

新建 openapi-lab 目录,保存以下文件。

pom.xml:

<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>3.5.0</version><relativePath/></parent>
  <groupId>lab</groupId><artifactId>openapi-lab</artifactId><version>1.0.0</version>
  <properties><java.version>17</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
    <dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>2.8.9</version></dependency>
  </dependencies>
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>

src/main/resources/application.properties:

server.address=127.0.0.1
server.port=31898
springdoc.swagger-ui.disable-swagger-default-url=true
spring.main.banner-mode=off

src/main/java/lab/apidocs/ApiDocsApplication.java:

package lab.apidocs;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ApiDocsApplication {
    public static void main(String[] args) { SpringApplication.run(ApiDocsApplication.class, args); }
}

文档信息、字段约束和真实行为各归各位

配置 Bean 给描述文档写上标题与版本,并定义 public 分组,只收录 /api/** 下的接口。默认描述地址是 /v3/api-docs,这个分组的描述地址是 /v3/api-docs/public。分组决定文档内容,不会替你更改 Controller 的路由。

src/main/java/lab/apidocs/OpenApiConfig.java:

package lab.apidocs;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class OpenApiConfig {
    @Bean OpenAPI apiInfo() {
        return new OpenAPI().info(new Info().title("文具报价 API").version("1.0").description("商品读取与报价计算的本机练习接口"));
    }
    @Bean GroupedOpenApi publicApi() {
        return GroupedOpenApi.builder().group("public").pathsToMatch("/api/**").build();
    }
}

请求对象同时包含两类注解。@Schema 描述字段含义和示例,帮助调用者填写请求;@NotBlank、@Min、@Max 表达实际 Bean Validation 约束,配合 Controller 上的 @Valid 才会在这条请求路径执行校验。文档里的 example 不是默认值,不会自动为缺失字段补上商品编号。

src/main/java/lab/apidocs/QuoteModels.java:

package lab.apidocs;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public class QuoteModels {
    public record QuoteInput(
        @NotBlank @Schema(description = "商品编码", example = "BOOK-01") String sku,
        @Min(1) @Max(99) @Schema(description = "购买数量,范围1到99", example = "2") int quantity) {}
    public record ItemView(String sku, String name, @Schema(description = "单价,单位分") long unitPriceCents) {}
    public record QuoteView(String sku, int quantity, @Schema(description = "总价,单位分") long totalCents) {}
    public record ErrorView(String code) {}
}

Controller 使用固定的两件文具作为数据,报价单价来自服务端,客户端只提供编码和数量。注解明确写出成功与失败的响应类型,避免文档把所有响应都描述成报价成功对象。

下面把模型外的辅助异常和错误处理器也放在同一个 Java 文件中,便于完整复制;它们不是 public 顶层类。实际项目可以按已有组织方式拆文件,不影响这组协议。

src/main/java/lab/apidocs/QuoteController.java:

package lab.apidocs;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.*;
import static lab.apidocs.QuoteModels.*;
@RestController
@Tag(name = "文具报价")
public class QuoteController {
    private final Map<String, ItemView> items = Map.of("BOOK-01", new ItemView("BOOK-01", "笔记本", 1990), "PEN-01", new ItemView("PEN-01", "签字笔", 500));
    @GetMapping("/api/items/{sku}")
    @Operation(summary = "读取商品", operationId = "readItem")
    @ApiResponse(responseCode = "200", description = "商品存在", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemView.class)))
    @ApiResponse(responseCode = "404", description = "商品不存在", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorView.class)))
    public ItemView item(@Parameter(description = "商品编码", example = "BOOK-01") @PathVariable String sku) {
        ItemView item = items.get(sku);
        if (item == null) throw new ApiProblem(HttpStatus.NOT_FOUND, "ITEM_NOT_FOUND");
        return item;
    }
    @PostMapping("/api/quotes")
    @Operation(summary = "计算报价", operationId = "calculateQuote")
    @ApiResponse(responseCode = "200", description = "报价成功", content = @Content(mediaType = "application/json", schema = @Schema(implementation = QuoteView.class)))
    @ApiResponse(responseCode = "400", description = "数量非法、JSON错误或商品编码不存在", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorView.class)))
    public QuoteView quote(@Valid @RequestBody QuoteInput input) {
        ItemView item = items.get(input.sku());
        if (item == null) throw new ApiProblem(HttpStatus.BAD_REQUEST, "UNKNOWN_SKU");
        return new QuoteView(item.sku(), input.quantity(), item.unitPriceCents() * input.quantity());
    }
    @Hidden
    @GetMapping("/internal/ping")
    public String ping() { return "pong"; }
}
class ApiProblem extends RuntimeException {
    final HttpStatus status;
    final String code;
    ApiProblem(HttpStatus status, String code) { this.status = status; this.code = code; }
}
@RestControllerAdvice
class QuoteErrors {
    @ExceptionHandler(ApiProblem.class)
    ResponseEntity<ErrorView> problem(ApiProblem failure) { return ResponseEntity.status(failure.status).body(new ErrorView(failure.code)); }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ErrorView> invalid() { return ResponseEntity.badRequest().body(new ErrorView("INVALID_INPUT")); }
    @ExceptionHandler(HttpMessageNotReadableException.class)
    ResponseEntity<ErrorView> unreadable() { return ResponseEntity.badRequest().body(new ErrorView("INVALID_JSON")); }
}

@Operation 的 operationId 固定为有意义的名字,便于客户端工具识别,不能在不同操作上重复使用同一个值。@ApiResponse 负责说明状态码,真正返回 404 或 400 的仍是 Java 分支与异常处理器;给方法写一条“404”注解,不会自动判断商品是否存在。

从 JSON 描述查到页面实际请求

从项目根目录运行:

mvn clean package
java -jar target/openapi-lab-1.0.0.jar

先访问 http://127.0.0.1:31898/v3/api-docs/public,应该得到 OpenAPI JSON,而不是一段 Swagger 网页。里面应有两个路径:/api/items/{sku} 和 /api/quotes,报价操作包含必填请求体和 400 响应描述。

然后打开 http://127.0.0.1:31898/swagger-ui/index.html。若页面上有分组选项,选择 public。

本机实际启动的Swagger UI读取public分组,两个接口及请求模型来自当前应用。 本机实际启动的Swagger UI读取public分组,两个接口及请求模型来自当前应用。展开“计算报价”,点 Try it out,输入:

{"sku":"BOOK-01","quantity":2}

点击 Execute,应看到真正的 HTTP 200,正文包含 totalCents: 3980。把数量改成 0 再执行,应得到 HTTP 400 与 {"code":"INVALID_INPUT"}。这里验证的是页面发出的请求与服务器返回,不是只看 Schema 区域展示出来的示例。

同样的结果可以绕过页面直接核对:

curl -i http://127.0.0.1:31898/api/items/BOOK-01
curl -i http://127.0.0.1:31898/api/items/NONE
curl -i -H 'Content-Type: application/json'   -d '{"sku":"BOOK-01","quantity":2}' http://127.0.0.1:31898/api/quotes
curl -i -H 'Content-Type: application/json'   -d '{"sku":"BOOK-01","quantity":0}' http://127.0.0.1:31898/api/quotes

商品不存在返回 404、ITEM_NOT_FOUND,而合法商品数量为 0 返回 400、INVALID_INPUT。不要用一份手工粘贴的成功 JSON 代替这些实际结果。若已有项目里的全局异常处理器改变了错误包装,就需要同步响应模型和描述,不能只复用这里的注解。

下面的脚本直接读取生成文档,检查分组路径、操作名、请求体与数量范围。保存后执行 python3 check_openapi.py,成功会输出 OPENAPI_OK。

check_openapi.py:

import json
import urllib.request

opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open("http://127.0.0.1:31898/v3/api-docs/public", timeout=5) as response:
    spec = json.load(response)
assert spec["info"]["title"] == "文具报价 API"
assert set(spec["paths"]) == {"/api/items/{sku}", "/api/quotes"}
quote = spec["paths"]["/api/quotes"]["post"]
assert quote["operationId"] == "calculateQuote"
assert "400" in quote["responses"]
assert quote["requestBody"]["required"] is True
ref = quote["requestBody"]["content"]["application/json"]["schema"]["$ref"].rsplit("/", 1)[-1]
schema = spec["components"]["schemas"][ref]
assert "sku" in schema["required"]
assert schema["properties"]["quantity"]["minimum"] == 1
assert schema["properties"]["quantity"]["maximum"] == 99
print("OPENAPI_OK: two paths, request schema and error response")

这个脚本只检查描述契约,不能替代真实 HTTP 断言。可以在练习副本里去掉报价方法参数上的 @Valid,重新构建并启动:文档仍能展示 DTO 上的最小值,但数量 0 会进入计算并返回总价 0。这就是“文档写了约束”与“服务器执行了约束”的区别。验证后恢复 @Valid 再构建。

文档不显示,先分清哪一层没工作

页面打不开时,先请求 /v3/api-docs:描述 JSON 也失败,查依赖版本、应用启动日志与文档启用配置;JSON 正常、UI 失败,再看是否误用了只提供 API 描述的模块,以及 UI 静态资源请求。已有登录拦截器或 Spring Security 的项目,还需要按自己的访问策略处理文档路由,本文工程没有加入这些组件。

页面正常但列表没有某个接口,再检查扫描范围、路径分组与 @Hidden。示例的 /internal/ping 被隐藏,直接请求它仍返回 pong,所以隐藏接口不是鉴权。分组同样只是选择文档内容,不是访问控制规则。

如果不希望某个环境提供文档,在资源目录再保存一个 profile 配置:

src/main/resources/application-prod.properties:

springdoc.api-docs.enabled=false
springdoc.swagger-ui.enabled=false

保存新增的 profile 文件后,先执行 mvn clean package,把 application-prod.properties 打入新 Jar。再停止当前应用,执行 java -jar target/openapi-lab-1.0.0.jar --spring.profiles.active=prod。此时 /v3/api-docs、分组描述和 Swagger UI 入口应为 404,但 /api/items/BOOK-01 仍返回商品。只关闭 UI 会留下 JSON 描述,两个开关的职责不同。

已有工程接入时,可以先保留这份生成文档检查,再补上当前业务最重要的成功与失败请求。这样以后修改字段、校验范围或错误结构时,页面与描述也有机会一起被核对,而不会悄悄变成另一套说法。

正文到此结束
评论插件初始化中...
Loading...
本文目录