Как мы OpenAPI в приложениях используем (Spring Boot, Typescript)

7148a8c12691dfd7c00ed93ff0b6c7d3

Хочу рассказать, как мы реализуем на практике контакты по спецификации 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
        tergat/ts-openapi
        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
        target/public
    

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) — сервер и к интеграции между двумя сервисами. Становится совсем хорошо, когда клиент и сервер у вас написаны на разных языках, и для обоих языков есть генераторы.

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

© Habrahabr.ru