Код проекта можно посмотреть здесь.
Для генерации будем использовать зависимость springdoc-openapi-starter-webmvc-ui.
Библиотека поддерживает:
OpenAPI 3
Spring Boot V3 (для V2 используется другая зависимость, более подробно в документации)
JSR-303
Swagger UI (будет сгенерирована страница с интерфейсом, через который мы сможем отправлять запросы на сервер)
OAuth 2 (это проверять не будем, но добавим токен типа Bearer)
GraalVM native images (не будем проверять)
Swagger UI будет генерироваться автоматически на основе наших DTO, контроллеров, ControllerAdvice’ов и дополнительных аннотаций.
Путь до сгенерированного ui — /swagger-ui/index.html, его можно изменить в application.properties с помощью springdoc.swagger-ui.path.
Также, можно получить сгенерированный open api файл в формате json по пути /api-docs, в формате yaml — /api-docs.yaml. Путь меняется с помощью springdoc.api-docs.path.
Генерация из DTO и контроллера
Для начала создадим UserDto:
@Data @AllArgsConstructor @Schema(description = "Пользователь") public class UserDto { private String name; @Schema(description = "Электронная почта", example = "junior@example.com") @Email @NotBlank private String email; @Schema(description = "Пароль должен содержать от 8 до 32 символов," + "как минимум одну букву, одну цифру и один специальный символ") @Size(min = 8, max = 32) @NotBlank @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\\\d)(?=.*[@$!%*#?^&])[A-Za-z\\\\d@$!%*#?^&]{3,}$") @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String password; }
С помощью аннотации @Schema указываем дополнительную информацию для генератора.
Все остальное сгенерируется на основании самого класса и аннотаций к нему.
Создаем контроллер:
@RestController @RequestMapping("/users") public class UserController { @PostMapping public UserDto addUser(@RequestBody @Validated UserDto userDto) { return userDto; } }
Запускаем приложение, переходим на /swagger-ui/index.html и получаем сгенерированную страничку. Заметьте, что все аннотации JSR-303 и @JsonProperty отразились в api.

@RequestParam, @PathVariable и проблемы их валидации
Добавим эндпоинты с параметрами запроса и переменной пути. Обратите внимание, что здесь используется @Parameter вместо @Schema. Использование @Schema перезатрет информацию из других аннотация дефолтными значениями.
@GetMapping("/{email}") public UserDto getUserByEmail(@PathVariable @Validated @Email @Parameter(description = "Электронная почта", example = "junior@example.com") String email) { return new UserDto("retrieved user", email, null); } @GetMapping public Collection<UserDto> getAllUsers(@RequestParam(required = false, defaultValue = "0") @Validated @Min(0) int page, @RequestParam(required = false, defaultValue = "10") @Validated @Min(1) int size) { return List.of(new UserDto("retrieved user", "junior@example.com", null)); }
К сожалению, аннотации @Min в эндпоинтах не отразились:

Но это проблема swagger ui, а не springdoc. Если мы перейдем на /api-docs.yaml, то увидим, что информация о минимуме действительно там есть:
/users: get: tags: - user-controller operationId: getAllUsers parameters: - name: page in: query required: false schema: minimum: 0 ## Вот он! type: integer format: int32 default: 0
Более того, если попытаться отправить запрос с page и size меньше нуля, ui выдаст ошибку.

Вы даже можете перейти на editor.swagger.io, вставить этот код и убедиться, что swagger ui именно так и работает.
openapi: 3.0.1 info: title: OpenAPI definition version: v0 servers: - url: <http://localhost:8080> description: Generated server url paths: /users: get: tags: - user-controller operationId: getAllUsers parameters: - name: page in: query required: false schema: minimum: 0 ##Вот он! type: integer format: int32 default: 0 responses: "200": description: OK
Это очень странная особенность swagger ui. Во-первых, мне бы хотелось видеть, какие есть ограничения на параметр, а во-вторых, я хочу проверять поведение сервера, а не интерфейса. Но как отключить валидацию в интерфейсе я, к сожалению, не нашел.
Единственное, что можно сделать — добавить информацию о минимуме в описание:
@GetMapping public Collection<UserDto> getAllUsers(@RequestParam(required = false, defaultValue = "0") @Parameter(description = "min: 0") //раз @Validated @Min(0) int page, @RequestParam(required = false, defaultValue = "10") @Parameter(description = "min: 1") //двас @Validated @Min(1) int size) { return List.of(new UserDto("retrieved user", "junior@example.com", null)); }

Page, Pageable и дженерики
У springdoc есть поддержка Page и Pageable из spring data. Давайте проверим, как она работает.
Добавим стартер org.springframework.boot:spring-boot-starter-data-jpa и h2 com.h2database:h2.
И добавим метод в контроллере:
@GetMapping("/page") public Page<UserDto> getAllUsersAsPage(Pageable pageable) { return Page.empty(pageable); }
Как видите, Page и Pageable автоматически добавились в api, а вместо дженерика в Page сгенерировался отдельный объект с массивом пользователей внутри:

@ControllerAdvice и ответы 4xx
Sprigdoc может автоматически добавлять ответы из нашего ControllerAdvice к документации:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Throwable.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponseDto handleThrowable(Throwable e) { return new ErrorResponseDto(e.getMessage()); } }


Проблема в том, что такие ответы из ControllerAdvice добавятся ко всем эндпоинтам, даже к тем, которые возможно и не выкидывают исключение.
Поэтому мы можем выключить генерацию ответов из ControllerAdvice: springdoc.override-with-generic-response=false.
Теперь будем добавлять ответы с ошибками на каждый отдельный эндпоинт:
@GetMapping("/{email}") // добавляем ответ с ошибкой @ApiResponse(responseCode = "404", description = "Пользователь не найден", content = @Content(schema = @Schema(implementation = ErrorResponseDto.class))) public UserDto getUserByEmail(@PathVariable @Validated @Email @Parameter(description = "Электронная почта", example = "junior@example.com") String email) { return new UserDto("retrieved user", email, null); }
Но добавление ответа с ошибкой удалит ответ с кодом 200, который раньше генерировался автоматически:

Чтобы вернуть дефолтный ответ 200, добавим такую аннотацию на контроллер:
@RestController @RequestMapping("/users") //дефолтный ответ для всех запросов @ApiResponses(@ApiResponse(responseCode = "200", useReturnTypeSchema = true)) public class UserController {

Fine-grained конфигурация: добавляем всем ошибкам дефолтное тело ответа
Для большинства ответов с ошибкой тело будет одинаковое, поэтому чтобы не писать каждый раз content = @Content(schema = @Schema(implementation = ErrorResponseDto.class)), добавим это тело ко всем ответам с кодами 4xx и 5xx.
Конфигурирование open api — это, по сути, ручное дописывание в файл open api, только обернутое в объекты. Из-за этого конфигурация выглядит достаточно сложно:
@Configuration public class OpenApiConfig { @Bean public OpenApiCustomizer openApiCustomizer() { return openApi -> { //Получаем схему ошибки var sharedErrorSchema = ModelConverters.getInstance() .read(ErrorResponseDto.class).get(ErrorResponseDto.class.getSimpleName()); if (sharedErrorSchema == null) { throw new IllegalStateException( "Не удалось сгенерировать ответ для ошибок 4xx и 5xx, поскольку отсутствует схема ошибки"); } //Добавляем тело ответа ко всем ответам с кодами 4xx и 5xx openApi.getPaths().values().forEach(pathItem -> pathItem.readOperations().forEach(operation -> operation.getResponses().forEach((status, response) -> { if (status.startsWith("4") || status.startsWith("5")) { response.getContent().forEach((code, mediaType) -> mediaType.setSchema(sharedErrorSchema)); } }))); }; } }
Теперь мы можем убрать добавление тела ответа из метода контроллера:
@GetMapping("/{email}") // тело ответа добавится в конфигурации @ApiResponse(responseCode = "404", description = "Пользователь не найден") public UserDto getUserByEmail(@PathVariable @Validated @Email @Parameter(description = "Электронная почта", example = "junior@example.com") String email) { return new UserDto("retrieved user", email, null); }

Тут важно заметить, что:
Тело генерируется отдельно для каждого ответа, в разделе Schemas объекта ErrorResponseDto нет. Это может быть проблемой при генерации клиентского кода из нашей спецификации.
Конфигурация перезатрет другие тела ответов. То есть, даже если в аннотации ApiResponse вы обозначили тело ответа, оно все равно будет заменено на ErrorResponseDto.
Как исправить второй пункт я не знаю, первый пункт можно исправить костылем.
Вместо получения схемы, создадим ссылку на нее и добавим эту ссылку ко всем ответам с ошибками:
@Bean public OpenApiCustomizer openApiCustomizer() { return openApi -> { //Создаем ссылку на схему ошибка var sharedErrorSchema = new Schema<>().$ref("#/components/schemas/" + ErrorResponseDto.class.getSimpleName()); //Добавляем тело ответа ко всем ошибкам openApi.getPaths().values().forEach(pathItem -> pathItem.readOperations().forEach(operation -> operation.getResponses().forEach((status, response) -> { if (status.startsWith("4") || status.startsWith("5")) { response.getContent().forEach((code, mediaType) -> mediaType.setSchema(sharedErrorSchema)); } }))); }; }
Добавим в OpenApiConfig статический класс-контроллер, который будет использовать ErrorResponseDto, тем самым добавив его в раздел Schemas:
/** * Костыль, чтобы добавить схему ошибки в open api */ @RestController @RequestMapping("/donotuse") static public class DoNotUse { @Operation(description = "Не использовать. Костыль, необходимый для конфигурации open api", deprecated = true, responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponseDto.class))) }) @DeleteMapping public void registerOpenApiComponent() { } }
Теперь ErrorResponseDto появилась в разделе Schemas, но также появился костыльный эндпоинт:

Security
Добавить авторизацию к нашим эндпоинтам довольно просто. Для начала сделаем глобальную авторизацию. Будем использовать токен Bearer:
@Configuration @OpenAPIDefinition( info = @Info(title = "Пример генерации OpenAPI из Spring MVC", version = "1.0.0"), security = @SecurityRequirement(name = "BearerAuth")) @SecurityScheme( type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "JWT", name = "BearerAuth") public class OpenApiConfig {
На эндпоинтах появились замочки:

Попробуем авторизоваться:

Теперь ко всем запросам будет добавляться заголовок «Authorization» с токеном, который мы указали:

Чтобы изменить авторизацию для конкретных эндпоинтов, можно добавить аннотацию @SecurityRequirements. Если использовать ее без параметров, эндпоинт перестанет быть защищен:
@PostMapping @SecurityRequirements public UserDto addUser(@RequestBody @Validated UserDto userDto) { return userDto; }

Заключение
Эта статья написана в процессе подготовки к хакатону и скоро будут другие!
Я уже писал о выборе между Spring и Ktor, далее напишу о работе docker compose со Spring, об аутентификации через телеграм и о том, как пройдет хакатон.
Подписывайся, чтобы не пропустить!
