Генерация 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);     } 

Тут важно заметить, что:

  1. Тело генерируется отдельно для каждого ответа, в разделе Schemas объекта ErrorResponseDto нет. Это может быть проблемой при генерации клиентского кода из нашей спецификации.

  2. Конфигурация перезатрет другие тела ответов. То есть, даже если в аннотации 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, об аутентификации через телеграм и о том, как пройдет хакатон.

Подписывайся, чтобы не пропустить!


ссылка на оригинал статьи https://habr.com/ru/articles/814061/