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

d756b7d72de2428af638677237df9d9d.png

@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 в эндпоинтах не отразились:

382f866b062c65e05db1739abc9b14d2.png

Но это проблема 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 выдаст ошибку.

50e2befb7ba5a03e32836fd8068c2ed1.png

Вы даже можете перейти на https://editor.swagger.io/, вставить этот код и убедиться, что swagger ui именно так и работает.

openapi: 3.0.1
info:
  title: OpenAPI definition
  version: v0
servers:
- url: 
  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 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));
    }

50f0af7eb16f88be6034ae6b73bde6c8.png

Page, Pageable и дженерики

У springdoc есть поддержка Page и Pageable из spring data. Давайте проверим, как она работает.

Добавим стартер org.springframework.boot:spring-boot-starter-data-jpa и h2 com.h2database:h2.

И добавим метод в контроллере:

    @GetMapping("/page")
    public Page getAllUsersAsPage(Pageable pageable) {
        return Page.empty(pageable);
    }

Как видите, Page и Pageable автоматически добавились в api, а вместо дженерика в Page сгенерировался отдельный объект с массивом пользователей внутри:

c42b53d2fbb6d1ca1b1e06103ab5f82a.png

@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());
    }
}

7293947a5140d8d48417790d543f2174.png296813f7a46c9a96b8c9a342f810eba2.png

Проблема в том, что такие ответы из 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, который раньше генерировался автоматически:

76f0160d1bacac0067cb680c9b133bac.png

Чтобы вернуть дефолтный ответ 200, добавим такую аннотацию на контроллер:

@RestController
@RequestMapping("/users")
//дефолтный ответ для всех запросов
@ApiResponses(@ApiResponse(responseCode = "200", useReturnTypeSchema = true))
public class UserController { 

3e885411fe5262df08303f1e2b06f96e.png

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);
    }

8918cb68588c69eb43a7168f84c848d4.png

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

  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, но также появился костыльный эндпоинт:

10413601f77bd504c85dc3a3a6e2c23d.png

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 {

На эндпоинтах появились замочки:

55971c83183a74183818198c36961267.png

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

aa60844b7cf076dbd1931f97a76e77bc.png

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

aba1e94bffc7fb4fb93dca3e029149eb.png

Чтобы изменить авторизацию для конкретных эндпоинтов, можно добавить аннотацию @SecurityRequirements. Если использовать ее без параметров, эндпоинт перестанет быть защищен:

    @PostMapping
    @SecurityRequirements
    public UserDto addUser(@RequestBody @Validated UserDto userDto) {
        return userDto;
    }

b04c73df75a719fa339c7d9c2fb930ec.png

Заключение

Эта статья написана в процессе подготовки к хакатону и скоро будут другие!

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

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

© Habrahabr.ru