Как мы OpenAPI в приложениях используем (Spring Boot, Typescript)
Хочу рассказать, как мы реализуем на практике контакты по спецификации 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, а в модулях она расширяется/изменяется отдельными свойствами):
org.openapitools
openapi-generator-maven-plugin
7.6.0
airports.yaml
spring
ru.alfastrah.example
ru.alfastrah.example.model
true
true
true
java8
none
true
false
none
false
false
true
Далее идет блок с executions, который описывает генерацию с помощью определенного генератора.
Например, для Spring-сервисов выглядит это так (с учетом configuration-блока выше, настроек остается немного):
openapi
generate
false
removeEnumValuePrefix=false
openapi.yaml
Для typescript такой:
openapi-ts
generate
false
typescript-axios
openapi.yaml
true
api
model
REFACTOR_ALLOF_INLINE_SCHEMAS=true
true
api
model
true
@{package family name}/${project.parent.artifactId}-${project.artifactId}
${project.version}
{repository to deploy}
А для html вот такой:
openapi-html
generate
false
html2
openapi.yaml
Pom на примере контракта с аэропортами будет такой:
org.openapitools
openapi-generator-maven-plugin
openapi
false
AirportResponse=Page<Airport>
SpringSortDirection=org.springframework.data.domain.Sort.Direction
Page=org.springframework.data.domain.Page
Pageable=org.springframework.data.domain.Pageable
Page<Airport>=org.springframework.data.domain.Page;
import ru...Airport
У генераторов много параметров, с помощью которых можно влиять на генерацию интерфейсов. Есть варианты повлиять на маппинг и импорт классов. Все вместе это дает возможность гибко подойти к процессу генерации интерфейсов.
Что получаем в результате?
Генератор на основе контракта при сборке создает такой 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 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 {
return (await this.airportApi.getAirports(pageable, airportFilter)).data;
}
/**
* Сохраняет новый аэропорт в системе
* @param airport данные по новому аэропорту
* @return информация о созданном аэропорту
*/
@Throbber()
async saveAirport(airport: Airport): Promise {
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) — сервер и к интеграции между двумя сервисами. Становится совсем хорошо, когда клиент и сервер у вас написаны на разных языках, и для обоих языков есть генераторы.
Конечно, это не панацея, есть детали использования, области применения, баги в генераторах, которые являются недостатками этого подхода. Это стоит учитывать при выборе: «Описать контракты всех сервисов или нет?». В команде мы решили, что пользы от подхода больше, чем вреда.