Подготовка репозитория

Создаём новый репозиторий и обзываем его "api" или "specifications". Ввиду того что генерация по спецификациям будет начинаться с чтения файликов из этого репозитория по ссылкам на них, то лучше сразу потратить некоторое время на продумывание и оформление структуры (мы же не хотим потом менять ссылки во всех наших сервисах). В нашем случае мы пришли к следующему виду:

1  api
2    integration
3      some-integration
4        1.0.0
5          api.yaml
6          models
7            some-endpoint.yaml
8            components.yaml
9    service
10     some-service
11       1.0.0
12         api.yaml
13         models
14           some-endpoint.yaml
15           another-endpoint.yaml
16           third-endpoint.yaml
17           components.yaml
18       1.0.1
19         api.yaml
20         models
21           some-endpoint.yaml
22           another-endpoint.yaml
23           third-endpoint.yaml
24           components.yaml

Строки 2 и 9.
Основное глобальное деление важное для нас - является ли спецификация нашей или принадлежит системе с который мы взаимодействуем.

В случае если это наша спецификация - мы помещаем её в сервис, который является владельцем некоторого API. То есть сервис, в рамках которого мы генерируем серверную часть.

В случае если мы работает с API некоторой системы, на основании предоставляемого этой системой контракта мы сами пишем такую спецификацию которая пригодна для генерации (да, даже если у системы уже есть описанная OpenAPI спецификация, это не значит что по ней можно что-то генерировать) и помещаем её в соответствующую систему.

Строки 3 и 10.
Уровень названия систем и сервисов. В качестве примера мы работаем с системой 'some-integration' и нашим сервисом 'some-service'.

Строки 4, 11 и 16.
Уровень версий контракта. В рамках каждого сервиса и системы может находиться более одной версии контракта, и мы обозначаем их отдельными директориями.

Строки 5-8, 12-17, 19-24
Чтобы объяснить структуру самих yaml файлов спецификации, нужно рассмотреть их все подробно, поэтому дальше много символов

api.yaml - файл первого уровня контракта, в котором описаны эндпоинты, теги и id.

1  openapi: 3.0.3
2  info:
3    title: API сервиса some-service
4    version: 1.0.1
5  servers:
6    - url: http://localhost:8080
7    - url: http://url-development.ru/some-service
8    - url: http://url-preproduction.ru/some-service
9    - url: http://url-production.ru/some-service
10 tags:
11   - name: SomeService
12     description: SomeService API
13 paths:
14   /api/v1/some-endpoint:
15     post:
16       tags:
17         - SomeService
18       summary: Описание логики спрятанной за эндпоинтом
19       operationId: someEndpoint
20       requestBody: 
21         $ref: 'models/some_endpoint.yaml#/components/requestBodies/SomeEndpointRequest'
22       responses: 
23         '200':
24           description: Логика успешно выполнена
25         '400':
26           description: Нарушение контракта
27           content: 
28             application/json:
29               schema: 
30                 $ref: 'models/some_endpoint.yaml#/components/schemas/SomeEndpointErrorResponse'
31         '500':
32           description: Какая-то страшная внутренняя ошибка
33           content:
34             application/json:
35               schema:
36                 $ref: 'models/some_endpoint.yaml#/components/schemas/SomeEndpointErrorResponse'           

Важные моменты:

Строка 4.
Не используется при генерации, но помогает с определением версии спецификации с которой мы работаем.

Строка 6.
При генерации сервера в качестве базового url контроллера будет использоваться первый из перечисленных серверов. Чтобы избежать лишних конфигураций стоит использовать конструкцию из примера и получить '/' в качестве базового пути.

Строки 10-12, 16-17.
Тэги используются как часть имени генерируемого контроллера и feing клиента. Рекомендуется указывать camel case название класса.

Строка 19.
operationId используется как часть имени генерируемого метода контроллера и feign клиента с окончанием 'Request'. Рекомендуется указывать camel case название метода.

Строки 21, 30, 36.
Ввиду объёмности описания моделей и полей запросов и ответов, на этом уровне располагаются только ссылки на них.

В директории models расположены поля и модели тех самых запросов и ответов из пункта выше. При этом на каждый эндпоинт создаётся отдельный файл (some-endpoint.yaml, another-endpoint.yaml, third-endpoint.yaml), и один общий файл components.yaml

some-endpoint.yaml - файл второго уровня контракта, в котором описаны модели запросов и ответов конкретных эндпоинтов.

1  openapi: 3.0.3
2  info:
3    title: Модели запросов и ответов эндпоинта some-endpoint
4    version: 1.0.1
5  paths:
6    /:
7  components:
8    schemas:
9      SomeEndpointErrorResponse:
10       description: Ответ при ошибке
11       type: object
12       properties:
13         errorMessage:
14           $ref: 'components.yaml#/components/schemas/errorMessage'
15   requestBodies:
16     SomeEndpointRequest:
17       description: Тело запроса на выполнение какой-то логики
18       required: true
19       content:
20         application/json:
21           schema:
22             required:
23               - id
24               - code
25             type: object
26             properties:
27               id:
28                 $ref: 'components.yaml#/components/schemas/id'
29               code:
30                 $ref: 'components.yaml#/components/schemas/code'

Важные моменты:

Строка 6.
Нужно указать хотя бы один путь чтобы спецификация считалась валидной.

Строки 8 и 15.
Можно использовать и другие типы моделей, но requestBodies и schemas должно хватать.

Строки 14, 28, 30.
Ввиду объёмности описания конкретных полей моделей и их атрибутов на этом уровне располагаются только ссылки на них.

components.yaml - файл третьего уровня контракта, в котором развёрнуто описаны все используемые в конкретных моделях поля. В отдельный файл они вынесены ввиду возможности переиспользования одних полей в разных эндпоинтах и моделях.

1 openapi: 3.0.3
2 info:
3   title: Поля моделей API SomeService
4   version: 1.0.1
5 paths:
6   /:
7 components:
8   schemas: 
9     id:
10      description: Какой-нибудь важный идентификатор
11      type: string
12      example: "1234"
13    code:
14      description: Не менее важный код
15      type: string
16      example: "4321"
17    errorMessage:
18      description: Описание ошибки при обращении к сервису
19      type: string
20      example: "Сервис временно недоступен"

Важные моменты:

Строка 6.
Нужно указать хотя бы один путь чтобы спецификация считалась валидной.

Со структурой разобрались. Далее нам необходимо создать технического пользователя, дать ему права на чтение репозитория, сгенерировать токен на чтение.

Далее рекомендуется все изменения спецификаций осуществлять через PR/MR и ревью разработчиков/аналитиков/тестировщиков. При этом необходимо понимать что есть две категории изменений.

Первая категория - не ломающие генерацию изменения эксплуатируемой версии спецификации. То есть исправление examples, опечаток, валидация полей и т.д. Если мы поменяем, например, название модели в такой спецификации, то все последующие сборки сервисов использующих её будут падать потому что в написанном нами коде мы будем пытаться импортировать старый класс модели.

Вторая категория - ломающие генерацию изменения. В таком случае можно скопировать директорию с последней версией и повысить её (1.0.1 -> 1.0.2), после чего внести необходимые изменения. Затем нужно пройтись по всем сервисам использующим спецификацию и обновить версию, поменяв её использование в коде.

Генерация сервера и клиента

Немного про генерацию со стороны OpenApi. Существуют разные генераторы, список которых можно посмотреть здесь. Из этого списка мы будем пользоваться spring генератором, описание которого можно посмотреть здесь. Хоть этот генератор и находится в категории servers, мы будем использовать его для генерации и серверной и клиентской части.

Для подключения спецификации и генерации по ней мы идём в build.gradle, где добавляем следующие зависимости:

[
        'org.springdoc:springdoc-openapi-ui:1.6.6',
        'io.swagger.core.v3:swagger-annotations:2.2.14',
        'org.openapitools:openapi-generator-gradle-plugin:7.0.1',
        'jakarta.validation:jakarta.validation-api',
].each{dep ->
    implemenation(dep) {
        exclude group: 'org.slf4j'
    }
}

Делаем поправку на актуальные версии, не забываем подключить spring-web и spring-openfeign.

Далее настраиваем генерацию:

1  sourseSets.main.java.srcDirs += "$buildDir/generated"
2  
3  def authorizationToken = System.properties["specification_repository_authorization_token"]
4  
5  tasks.register('generate some-service 1.0.1 server', GenerateTask) {
6      generatorName.set("spring")
7      remoteInputSpec.set("https://repository-host/.../raw/service/some-service/1.0.1/api.yaml")
8      auth.set("Authorization:Bearer $authorizationToken")
9      outputDir.set("$buildDir/generated")
10     ignoreFileOverride.set(".openapi-generator-ignore")
11     configOptions.set([
12             library:                              "spring-boot",
13             invokerPackage:                       "ru.our.package.specifications.some_service.1_0_1.server",
14             apiPackage:                           "ru.our.package.specifications.some_service.1_0_1.server.api",
15             modelPackage:                         "ru.our.package.specifications.some_service.1_0_1.server.model",
16             configPackage:                        "ru.our.package.specifications.some_service.1_0_1.server.configuration",
17             basePackage:                          "ru.our.package.specifications.some_service.1_0_1.server",
18             useOptional:                          "true",
19             openApiNullable:                      "false",
20             interfaceOnly:                        "false",
21             sourceFolder:                         "",
22             additionalModelTypeAnnotations:       "@lombok.Builder(toBuilder = true)\n@lombok.RequiredArgsConstructor\n@lombok.AllArgsConstructor\n@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown=true)",
23             generatedConstructorWithRequiredArgs: "false",
24             useTags:                              "true"
25     ])
26 }
27 
28 tasks.register('generate another-service 1.0.0 client', GenerateTask) {
29     generatorName.set("spring")
30     remoteInputSpec.set("https://repository-host/.../raw/service/another-service/1.0.0/api.yaml")
31     auth.set("Authorization:Bearer $authorizationToken")
32     outputDir.set("$buildDir/generated")
33     ignoreFileOverride.set(".openapi-generator-ignore")
34     configOptions.set([
35             library:                              "spring-cloud",
36             invokerPackage:                       "ru.our.package.specifications.another_service.1_0_0.client",
37             apiPackage:                           "ru.our.package.specifications.another_service.1_0_0.client.api",
38             modelPackage:                         "ru.our.package.specifications.another_service.1_0_0.client.model",
39             configPackage:                        "ru.our.package.specifications.another_service.1_0_0.client.configuration",
40             basePackage:                          "ru.our.package.specifications.another_service.1_0_0.client",
41             useOptional:                          "true",
42             openApiNullable:                      "false",
43             enumUnknownDefaultCase:               "true",
44             interfaceOnly:                        "false",
45             sourceFolder:                         "",
46             additionalModelTypeAnnotations:       "@lombok.Builder(toBuilder = true)\n@lombok.RequiredArgsConstructor\n@lombok.AllArgsConstructor",
47             generatedConstructorWithRequiredArgs: "false",
48             useTags:                              "true"
49     ])
50 }
51 
52 tasks.register('generate server and clients') {
53     dependsOn(
54             'generate some-service 1.0.1 server',
55             'generate another-service 1.0.0 client'
56     )
57 }
58 
59 compileJava.dependsOn 'generate server and clients'

Важные моменты:

Строка 1.
Включение сгенерированного кода в наши src.

Строки 3, 8, 31.
Тот самый токен с помощью которого мы сможем прочитать наши спецификации. Для ci/cd пайплайнов указывайте токен технического пользователя, для локальной сборки - поместите свой токен в файл настроек gradle.

Строки 5, 28, 52-57, 59.
Мы создаём gradle task на генерацию, и делаем шаг compileJava зависимым от неё.

Строки 6, 29.
Указываем каким генератором мы хотим воспользоваться. Как было сказано выше - используем spring.

Строки 7, 30.
Указываем где лежат наши спецификации. Важно указать путь именно к raw представлению наших файлов. При этом мы целимся только в api.yaml, а остальные файлы будут использоваться через ссылки в самих спецификациях.

Строки 11-25, 34-49.
Это уже настройки самого генератора.

Строки 12, 35.
Библиотека которую мы хотим использовать для генерации. spring-boot создаст нам сервер, spring-cloud создаст feign клиента.

Строки 13-17, 36-40.
Сгенерированные классы мы раскладываем по следующей структуре:

  • базовый пакет

  • пакет specifications

  • название сервиса

  • версия контракта сервиса

  • client/server

  • api/model/configuration

Строки 22, 46.
Вешаем аннотации на сгенерированные модели.

Строки 24, 48.
Очень важный параметр, который говорит о том, чтобы использовать теги из спецификации в качество имён генерируемых классов и методов.

Строки 10, 33.
Чтобы игнорировать созданные Application классы, необходимо добавить файл .openapi-generator-ignore и указать на него. Содержание файла:

**/OpenApiGeneratorApplication.java
**/OpenApiGeneratorApplicationTests.java

Запускаем задание на генерацию, и идём смотреть что у нас получилось.
В директории build/generated должны появиться указанные нами пакеты, среди которых нас интересуют следующие:

1 specification
2   some_service
3     1_0_1
4       server
5         api
6           SomeServiceApi
7           SomeServiceApiController
8   another_service
9     1_0_0
10      client
11        api
12          AnotherServiceApi
13          AnotherServiceApiClient

Важные момент:

Строки 3, 9.
Пакет с конкретной версией контракта. Если мы работаем более чем с одной версией в один момент времени - все модели и компоненты будут расположены в своих директориях и мы избежим коллизий при их использовании.

Строки 6 и 7.
Сгенерированный интерфейс нашего контракта и сгенерированный контроллер, реализующий интерфейс.

Строки 12 и 13.
Сгенерированный интерфейс нашего контракта и сгенерированный feign клиент, реализующий интерфейс.

Далее чтобы подключить серверную часть, нам необходимо создать свой контроллер, наследующий сгенерированный, и повесить аннотацию @Controller.После этого остаётся только написать логику за нашими эндпоинтами переопределив сгенерированные методы.

Чтобы подключить клиентскую часть, необходимо добавить в конфигурацию нашего приложения обнаружение feign клиентов:

@Import(FeignClientsConfiguration.class)
@EnableFeignClients(basePackages = {"ru.our.package.specifications"})

После этого остаётся использовать сгенерированный feign как обычный spring компонент.

Комментарии (4)


  1. olegchir
    13.11.2023 21:07
    +2

    Ух, ты объяснил почти каждую строчку, круто!

    Что здесь хотелось бы увидеть: абстракт на один абзац, строк на 6 хотя бы. С описанием, что дано и какая задача. Что было, что стало.

    Файлы лежат непонятно где и как, зависимости и конфиг в maven/gradle непонятно какие. Было бы круто сразу увидеть репозиторий на Гитхабе с минимальной демкой, по которому можно идти вместе со статьей и понимать написанное.

    Все ссылки на отдельные строки можно было бы сделать ссылками на конкретные метки в GitHub. Гитхаб так может.


  1. TerekhinSergey
    13.11.2023 21:07

    oneof инструкция корректно поддерживается? Заметил, что всякая дичь генерится для нее


    1. HumbleCommentor
      13.11.2023 21:07
      +1

      Я б расширил этот вопрос и на anyOf с allOf...


  1. sparhawk
    13.11.2023 21:07

    Далее чтобы подключить серверную часть, нам необходимо создать свой контроллер, наследующий сгенерированный, и повесить аннотацию @Controller

    Вот здесь хотелось бы пояснения (в идеале - вторую часть статьи). Дело в том, что если просто включить в свое приложение все сгенерированные таким образом классы и Spring-контекст, то подложить свой контроллер MySomeServiceApiController на указанный в OpenAPI url не получится - RequestMapping уже будет занят сгенерированным контроллером SomeServiceApiController.

    Чтобы такого не получилось, придется не включать сгенерированный Spring-контекст, либо...

    Либо использовать опцию delegatePattern=true. Тогда помимо SomeServiceApiи SomeServiceApiControllerбудет сгенирован делегат в виде интерфейса с дефолтными методамиSomeServiceApiDelegate. И как раз делегат можно создать уже свой.

    Работа с делегатами обладает следующими преимуществами:

    1. Java-код не содержит никакой логики работы с HTTP, весь HTTP-слой можно полностью взять из классов, сгенерированных по OpenAPI. Сохраняется IoC-структура приложения, ориентированного в первую очередь на OpenAPI

    2. Заглушки попадают в конечный сервис, что удобно для тестирования, если за OpenAPI отвечают не те же люди, что за реализацию сервиса - просто поменяв API можно сразу после пересборки увидеть появившиеся endpoint'ы.