Генерация OpenAPI из Spring Boot MVC
Код проекта можно посмотреть здесь.
Для генерации будем использовать зависимость 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, об аутентификации через телеграм и о том, как пройдет хакатон.
Подписывайся, чтобы не пропустить!