Хочу рассказать, как мы реализуем на практике контакты по спецификации OpenAPI, стараемся следовать подходу Contract First и в целом разрабатывать так, чтобы удобно было как разработчикам в команде, так и всем, кто использует наши сервисы. В статье описана генерация Java и typescript, а так же конфигурации maven.
Контракты OpenAPI — спецификация, которая позволяет описывать интерфейс взаимодействия с сервисом в виде REST. Или не REST, тут зависит от задачи и ее реализации.
Вдаваться в историю появления спецификации и ее развития не буду. Если кратко — эта спецификация позволяет описывать контракт взаимодействия с сервисом с помощью yaml‑синтаксиса. А с помощью OpenAPI generators можно генерировать из такого описания клиент‑серверные интерфейсы на различных языках. На данный момент последняя версия OpenAPI — 3.1.0 — является наиболее удобной и структурированной, позволяет описывать контракт с помощью JSON. Мы осознанно используем версию 3.0.3. Почему? Расскажу далее.
Как мы стараемся следовать подходу contract first*
*про подход: здесь, здесь и здесь
Когда в команду приходит задача, подразумевающая взаимодействие с сервисом по синхронному API: frontend-часть, другой сервис нашей команды, сторонняя система - в первую очередь мы беремся за описание контракта взаимодействия.
Такой подход мы признали успешным: после реализации контракта, его ревью и мержа в основную ветку всю дальнейшую работу над этой функциональностью можно параллелить на направления: реализовывать логику на бэке, рисовать фронт, сразу закладывая взаимодействие с бэком на основе контрактов, отдавать описание контракта на аудит по процессам компании, выдать другой команде в случае интеграции с другой системой.
Все контракты по OpenAPI спецификации всех наших сервисов мы храним в одном репозитории. Посчитали, что так будет удобнее. Ниже опишу плюсы и минусы такого варианта.
Как мы пишем контракты OpenAPI
После обдумывания, какие нужно реализовать эндпоинты (если контакт уже описан и требуется его расширение или изменение) или каков в целом контракт нового сервиса (если речь идет о создании нового сервиса) — мы описываем yml контракт.
Контракт может выглядеть так:
openapi: 3.0.3 info: title: Airport Service API description: 'API для работы со справочником аэропортов' version: 1.0.0 paths: /airports: get: tags: - Airport summary: Get a list of airports operationId: getAirports parameters: - name: pageable in: query description: Фильтр пагинации required: true schema: $ref: '../../common.yaml#/components/schemas/Pageable' - name: filter in: query description: Фильтр поиска аэропортов required: false schema: $ref: '#/components/schemas/AirportFilter' responses: 200: description: Successful response content: application/json: schema: $ref: '#/components/schemas/AirportResponse' 400: $ref: '../../common.yaml#/components/responses/ClientError' 500: $ref: '../../common.yaml#/components/responses/ServerError' post: tags: - Airport summary: Create a new airport operationId: creteAirport requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/Airport' responses: 201: description: Airport created successfully content: application/json: schema: $ref: '#/components/schemas/Airport' 400: $ref: '../../common.yaml#/components/responses/ClientError' 500: $ref: '../../common.yaml#/components/responses/ServerError' components: schemas: Airport: type: object properties: id: type: integer description: Идентификатор аэропорта example: 42 iata: type: string pattern: '^([a-zA-Z]{3}|)$' icao: type: string pattern: '^([a-zA-Z]{4}|)$' AirportFilter: description: Фильтр поиска аэропортов type: object properties: iata: type: string icao: type: string AirportResponse: description: Структура с данными по аэропортам allOf: - $ref: '../../common.yaml#/components/schemas/BasePage' - type: object properties: content: type: array items: $ref: '#/components/schemas/Airport' required: - content
На этом примере можно увидеть, что в контракте есть основные блоки:
info — информация о контракте/сервисе;
paths — описание эндпоинтов;
components — модели данных (модель запроса, модель ответа).
Так же часто встречается $ref — это ссылка на модель. Ссылаться можно как на модели внутри контракта ($ref: '#/components/schemas/Airport'), так и на модели в соседних файлах ($ref: '../../common.yaml#/components/schemas/BasePage'). Возможность ссылаться на другие файлы позволяет переиспользовать модели в разных контрактах.
Разбирать все конструкции не буду, их много, и с этим успешно справляется официальная документация. Возможностей спецификации достаточно, чтобы описать большинство кейсов, используемых в контрактах (говорю по опыту наших 23-х контрактов).
Всегда можно обратиться к документации, чтобы понять как описать то или иное.
Использование OpenAPI генератора
Речь идет про OpenAPI generators. Существует обширный список генераторов, которые позволяют из yml контракта сгенерировать интерфейсы клиента и сервера для различных языков. Мы используем только 3 из них:
java — для последующего использования в Spring Boot приложениях;
typescript‑axios — для использования во VueJS/React приложениях;
html2 — для генерации визуального представления в виде html.
Так как сборка проектов у нас на maven, приведу примеры на нем (используем мульти-модульную структуру maven, конфигурация плагинов описана в корневом pom, а в модулях она расширяется/изменяется отдельными свойствами):
<plugin> <groupId>org.openapitools</groupId> <artifactId>openapi-generator-maven-plugin</artifactId> <version>7.6.0</version> <configuration> <!-- Название файла с контрактом --> <inputSpec>airports.yaml</inputSpec> <!-- Название генератора, по умолчанию поставили spring, так как большинство контрактов мы используем в java-сервисах --> <generatorName>spring</generatorName> <!-- Пакет, куда генератор сложит API-интерфейсы --> <apiPackage>ru.alfastrah.example</apiPackage> <!-- Пакет, куда генератор сложит модели, используемые в API-интерфейсах --> <modelPackage>ru.alfastrah.example.model</modelPackage> <configOptions> <useSpringBoot3>true</useSpringBoot3> <useTags>true</useTags> <interfaceOnly>true</interfaceOnly> <dateLibrary>java8</dateLibrary> <documentationProvider>none</documentationProvider> <skipDefaultInterface>true</skipDefaultInterface> <openApiNullable>false</openApiNullable> <requestMappingMode>none</requestMappingMode> <serializableModel>false</serializableModel> <useResponseEntity>false</useResponseEntity> <containerDefaultToNull>true</containerDefaultToNull> </configOptions> </configuration> <executions> <!-- тут некоторые общие executions --> </executions> </plugin>
Далее идет блок с executions, который описывает генерацию с помощью определенного генератора.
Например, для Spring-сервисов выглядит это так (с учетом configuration-блока выше, настроек остается немного):
<execution> <id>openapi</id> <goals> <goal>generate</goal> </goals> <configuration> <!-- В конечном исполнителе не пропускаем генерацию --> <skip>false</skip> <!-- Это мы добавили, чтобы значения enum были такими, какими мы их ожидаем --> <additionalProperties>removeEnumValuePrefix=false</additionalProperties> <!-- Название файла с контрактом --> <inputSpec>openapi.yaml</inputSpec> </configuration> </execution>
Для typescript такой:
<execution> <id>openapi-ts</id> <goals> <goal>generate</goal> </goals> <configuration> <skip>false</skip> <generatorName>typescript-axios</generatorName> <inputSpec>openapi.yaml</inputSpec> <output>tergat/ts-openapi</output> <generateSupportingFiles>true</generateSupportingFiles> <apiPackage>api</apiPackage> <modelPackage>model</modelPackage> <inlineSchemaOptions>REFACTOR_ALLOF_INLINE_SCHEMAS=true</inlineSchemaOptions> <configOptions> <withSeparateModelsAndApi>true</withSeparateModelsAndApi> <apiPackage>api</apiPackage> <modelPackage>model</modelPackage> <supportsES6>true</supportsES6> <npmName>@{package family name}/${project.parent.artifactId}-${project.artifactId}</npmName> <npmVersion>${project.version}</npmVersion> <npmRepository>{repository to deploy}</npmRepository> </configOptions> </configuration> </execution>
А для html вот такой:
<execution> <id>openapi-html</id> <goals> <goal>generate</goal> </goals> <configuration> <skip>false</skip> <generatorName>html2</generatorName> <inputSpec>openapi.yaml</inputSpec> <output>target/public</output> </configuration> </execution>
Pom на примере контракта с аэропортами будет такой:
<plugin> <groupId>org.openapitools</groupId> <artifactId>openapi-generator-maven-plugin</artifactId> <executions> <execution> <id>openapi</id> <configuration> <skip>false</skip> <typeMappings> <typeMapping>AirportResponse=Page<Airport></typeMapping> <typeMapping>SpringSortDirection=org.springframework.data.domain.Sort.Direction</typeMapping> </typeMappings> <importMappings> <importMapping>Page=org.springframework.data.domain.Page</importMapping> <importMapping>Pageable=org.springframework.data.domain.Pageable</importMapping> <importMapping>Page<Airport>=org.springframework.data.domain.Page; import ru...Airport</importMapping> </importMappings> </configuration> </execution> </executions> </plugin>
У генераторов много параметров, с помощью которых можно влиять на генерацию интерфейсов. Есть варианты повлиять на маппинг и импорт классов. Все вместе это дает возможность гибко подойти к процессу генерации интерфейсов.
Что получаем в результате?
Генератор на основе контракта при сборке создает такой java-интерфейс (spring generator):
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen") @Validated public interface AirportApi { @RequestMapping( method = RequestMethod.POST, value = "/airports", produces = { "application/json" }, consumes = { "application/json" } ) @ResponseStatus(HttpStatus.CREATED) Airport creteAirport( @Valid @RequestBody Airport airport ); @RequestMapping( method = RequestMethod.GET, value = "/airports", produces = { "application/json" } ) @ResponseStatus(HttpStatus.OK) Page<Airport> getAirports( @NotNull @Valid Pageable pageable, @Valid AirportFilter filter ); }
И вот в такой typescript-класс (typescript-axios generator):
export class AirportApi extends BaseAPI { public creteAirport(airport: Airport, options?: RawAxiosRequestConfig) { return AirportApiFp(this.configuration).creteAirport(airport, options).then((request) => request(this.axios, this.basePath)); } public getAirports(pageable: Pageable, filter?: AirportFilter, options?: RawAxiosRequestConfig) { return AirportApiFp(this.configuration).getAirports(pageable, filter, options).then((request) => request(this.axios, this.basePath)); } }
На стороне сервера (java-service) мы просто создаем контроллер, который реализует интерфейс, а на стороне клиента - например, так:
/** * Это не автогенерируемый класс, это создано вручную */ @Singleton @OnlyInstantiableByContainer export class SomeAirportService { /** Сервис по работе с аэропортами */ @Inject private airportApi: AirportApi; /** * Возвращает информацию об аэропортах * @param pageable пагинация * @param airportFilter фильтр * @return информация об аэропортах */ @Throbber() async getAirports(pageable: Pageable, airportFilter: AirportFilter): Promise<AirportResponse> { return (await this.airportApi.getAirports(pageable, airportFilter)).data; } /** * Сохраняет новый аэропорт в системе * @param airport данные по новому аэропорту * @return информация о созданном аэропорту */ @Throbber() async saveAirport(airport: Airport): Promise<Airport> { return (await this.airportApi.creteAirport(airport)).data; } }
И все. Это здорово облегчает разработку сервисов:
в интерфейсе сразу описаны все возможные эндпоинты;
созданы классы-модели, которые соответствуют описанию в контракте, и их можно просто подключить из созданной библиотеки, а не создавать новые в месте использования;
код взаимодействия с сервисом не дублируется;
Все это уменьшает количество ошибок при непосредственной реализации взаимодействия двух сервисов.
Плюсы хранения контрактов всех сервисов в одном репозитории
То, что мы храним все контракты, используемые командой, в одном репозитории, позволяет нам просматривать и управлять всем многообразием интерфейсов, применять изменения и улучшения на группу контрактов, быстро исправлять однотипные баги.
Мы используем Gitlab Pages для публикации внутри Gitlab контрактов команды. Пользователь Gitlab может посмотреть наши контракты. В чем польза:
позволяет быстро проанализировать сложность и оценить разработку;
хорошая точка входа для нового сотрудника, который начинает смотреть архитектурные схемы систем и хочет более детально понимать как взаимодействовать с тем или иным сервисом;
является частью процесса аудита сервисов: достаточно дать ссылку на описание работы сервиса и его контракт и ответственный за аудит сотрудник сможет самостоятельно проанализировать, что можно проверить на безопасность.
Храня все контракты в одном репозитории, вы можете выносить общие части в yml-файлы и переиспользовать их в нескольких местах. Мы таким образом вынесли описания клиентских и серверных ошибок:
openapi: 3.0.3 info: title: Common API description: 'Общие части контрактов' version: 1.1.0 paths: components: schemas: Pageable: description: Pageable запрос, маппится в org.springframework.data.domain.Pageable type: object properties: page: description: Номер страницы type: integer size: description: Размер type: integer sort: description: Сортировка type: string required: - page - size BasePage: type: object description: | Базовая схема для Page ответа, соответствует классу org.springframework.data.domain.Page, не используется самостоятельно, компонент должен использовать этот объект через allOf и ref, а также иметь в properties 'content' с той схемой, для которой реализуется пагинация, также следует настроить импорты в openapi generator (см pom.xml проекта) properties: empty: type: boolean first: type: boolean last: type: boolean number: type: integer numberOfElements: type: integer pageable: type: object size: type: integer sort: type: object totalElements: type: integer totalPages: type: integer required: - empty - first - last - number - numberOfElements - pageable - size - sort - totalElements - totalPages ApiError: type: object description: Описание ошибки обработки запроса required: - message - code properties: message: description: Сообщение об ошибке type: string code: description: Код ошибки type: integer format: int64 responses: ClientError: description: Клиентская ошибка content: application/json: schema: $ref: '#/components/schemas/ApiError' ServerError: description: Ошибка сервера content: application/json: schema: $ref: '#/components/schemas/ApiError'
А минусы?
Минусы хранения в одном репозитории (по крайней мере при сборке с помощью maven) тоже есть. При изменении одного контракта и фиксировании новой версии в git — меняются версии всех maven‑модулей в репозитории.
Возникает ситуация: в nexus-регистри лежит 10 новых версий интерфейса сервиса, сервис использует старую версию, при этом разницы между старой и новой версией интерфейсов фактически нет. Такую ситуацию мы обходим использованием для всех сервисов единого parent-pom, в котором указаны актуальные версии зависимостей на интерфейсы openapi-контрактов.
А почему не используем версию 3.1.0?
Хотя OAS (OpenAPI specification) версии 3.1.0 вышла в релиз в начале 2021 года, ее поддержка (на уровне beta) в openapi-generator появилась лишь в версии 7.0.1 в 2023 году.
До недавнего времени мы использовали OpenAPI-generator версии 6.6.0. Для дальнейшего развития контрактов, перехода на OAS 3.1.0 мы начали использовать последнюю доступную версию генератора 7.6.0. Сразу словили такой баг: нарушена работа инициализации required-коллекций в моделях.
А вот при использовании версии OAS 3.1.0 у нас ломаются ссылки на общие компоненты ($ref), которые заданы в корневом common.yaml:
Failed to get the schema name: ../../common.yaml#/components/responses/ServerError Failed to get the schema name: ../../common.yaml#/components/responses/ClientError
Как это починить — я не понял, правила работы ссылок $ref изменились, о чем говорится в документации. Но как настроить так, чтобы работало как раньше — для меня (пока) загадка. Если есть идеи — буду рад обсудить в комментариях.
Выводы
Описывание контрактов помогает быстрее писать клиент‑серверные взаимодействия. Это относится к связке клиент‑фронтенд (например, SPA) — сервер и к интеграции между двумя сервисами. Становится совсем хорошо, когда клиент и сервер у вас написаны на разных языках, и для обоих языков есть генераторы.
Конечно, это не панацея, есть детали использования, области применения, баги в генераторах, которые являются недостатками этого подхода. Это стоит учитывать при выборе: «Описать контракты всех сервисов или нет?». В команде мы решили, что пользы от подхода больше, чем вреда.
