Хочу рассказать, как мы реализуем на практике контакты по спецификации 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&lt;Airport&gt;</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&lt;Airport&gt;=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) — сервер и к интеграции между двумя сервисами. Становится совсем хорошо, когда клиент и сервер у вас написаны на разных языках, и для обоих языков есть генераторы.

Конечно, это не панацея, есть детали использования, области применения, баги в генераторах, которые являются недостатками этого подхода. Это стоит учитывать при выборе: «Описать контракты всех сервисов или нет?». В команде мы решили, что пользы от подхода больше, чем вреда.

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