Как стать автором
Поиск
Написать публикацию
Обновить

Генерация OpenAPI из Spring Boot MVC

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров16K

Код проекта можно посмотреть здесь.

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

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

Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+10
Комментарии6

Публикации

Ближайшие события