Генерация контрактов OpenApi или прикладной API first
Подготовка репозитория
Создаём новый репозиторий и обзываем его «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 компонент.