Build project pre-commit

Contract First или страх и ненависть в королевстве CodeGen

SQA Days 34

Аннотация

Чтобы end-2-end тесты были максимально полезными, их нужно начинать писать параллельно с разработкой самой задачи. Но как это сделать прозрачно, ведь API может поменяться как во время разработки, так и в процессе доработок по другим задачам. Рассмотрим подход Contract First на базе OpenAPI как средство поддержания актуальности e2e тестов.

План

  1. Как быстро начать автоматизацию задачи и следить за изменениями в автоматизированном режиме?
  2. Что такое контракт? OpenAPI.
  3. Рассматриваем что умеет проект OpenAPI Generator
  4. Берем OpenAPI Generator и по контракту генерируем модели и клиента RestAssured.
  5. Убираем лишние файлы.
  6. Используем свои шаблоны генерации кода по OpenAPI.
  7. А что делать, если реализация еще недоступна? С помощью Postman и OpenAPI создаем Mock Server и делаем вызовы.
  8. Вместо выводов: как максимально быстро реагировать в тестах на изменения в коде?

Подготовка

Для выполнения мастер-класса нужно:

  1. Java 17: установка.
  2. Docker: установка.
  3. OpenAPI generator: установка.
  4. Postman: установка.

Код проекта: openapi-generation.

$ git clone git@github.com:Romanow/openapi-generation.git
$ ./gradlew clean build

Сервис servers развернут по адресу https://servers.romanow-alex.ru](https://servers.romanow-alex.ru) (на время доклада). Либо можно запустить локально:

$ docker compose up -d --wait

Импульс

Аннотация

Часто для решения одной бизнес задачи требуется коммуникация и совместная разработка нескольких команд. В микросервисной архитектуре задача упрощается, ведь сервисы имеют разную кодовую базу и взаимодействуют друг с другом по API. А значит требуется лишь договориться об общем API в начале разработки. Но так ли все просто и как автоматизировать этот процесс рассмотрим в нашем докладе.

План доклада

  1. Коммуникация между командами разработки при совместной работе над одной задачей.
  2. Что такое контракт? OpenAPI.
  3. Contract First vs. Code First. Плюсы и минусы подходов.
  4. Рассматриваем что умеет проект OpenAPI Generator.
  5. Генерируем код по контракту на клиенте и сервере, разбираем что получилось.
  6. Убираем лишние файлы.
  7. Используем свои шаблоны генерации кода по OpenAPI.
  8. Вместо выводов: как поддержать баланс между чувством прекрасного и сгенерированным кодом?

Доклад

Коммуникация между командами разработки при совместной работе над одной задачей

В мире микросервисов практически любая задача требует взаимодействия нескольких сервисов, а значит для ее выполнения требуется параллельно вести разработку в нескольких командах. Для этого нужна договоренность между командами, по какому API (Application Programming Interface) ону будут взаимодействовать. Ведь если Сервис А вызывает Сервис B, то команда, ответственная за Сервис A не может ждать, пока команда, ответственная за Сервис B возьмет задачу в работу и что-то напишет. Следовательно, нам надо заранее (до начала разработки) описать некоторый контракт, который будет реализовываться в Сервисе B, а Сервис А может начать его использовать на заглушках (например, с помощью WireMock или Postman Mock Server).

Command Communication

Как контролировать корректность данных, которые будут в заглушках, мы не будем рассматривать, скажу лишь, что стоит смотреть в сторону контрактных тестов (Использование Spring Cloud Contract как альтернатива для интеграционных тестов).

Как быстро начать автоматизацию задачи и следить за изменениями в автоматизированном режиме?

Когда вашу систему разрабатывают несколько команд, сложно следить за актуальностью моделей и API (кто-то что-то поменял и забыл вас известить об этом). Хотелось бы отлавливать ситуацию, что что-то поменялось, как можно раньше, например на этапе компиляции.

Так же, когда появляется новый сервис, для старта автоматизации приходится писать много boilerplate кода.

Что такое контракт? OpenAPI

Контракт – это соглашение о том, как будет выглядеть наше API и какие параметры оно будет принимать.

В рамках доклада будем рассматривать синхронную коммуникацию с помощью REST, следовательно, мы строим наше API на базе протокола HTTP. Самым распространенным способом описания контракта для REST сервисов является OpenAPI.

Спецификация OpenAPI определяет стандарт независимого от языка описания API, который позволяет людям и машинам понимать возможности службы без доступа к исходному коду, документации или путем перехвата сетевого трафика. По сути, OpenAPI — это описание методов API, для которых описываются заголовки, входные и выходные параметры.

Contract Communication

Contract First vs. Code First. Плюсы и минусы подхода

Итак, мы описали и согласовали контракт между командами, теперь можем приступать к реализации.

Но как поддерживать консистентность контракта с тем, что реально реализовано в коде? Можно при каждом релизе сравнивать код с контрактом, но все равно в каких-то мелочах они могут разойтись, а случае ошибки может быть сложно установить ее причину. Например: метод ищет сущность по ID (/api/v1/users/1), в ответ может вернуться 404 Not Found, если сущность не найдена. Но 404 Not Found может вернуться и в случае, если такого метода больше нет в коде. И для разбора в этой проблеме потребуется время.

Единственный надежный способ поддерживать в консистентном состоянии два источника правды – это получать одно из другого. Рассмотрим два подхода:

  • Code First – мы сначала пишем код, помечаем методы специальными аннотациями @Tag, @Opearation и т.п., а по этим аннотациям автоматически генерируется документация. В итоге получаем OpenAPI, полностью соответствующий коду. Но в этом подходе есть 3 проблемы:
    • в коде не все можно описать (доп. параметры, валидаторы и т.п.), следовательно, мы можем получить невалидный или неполный OpenAPI;
    • требуется время для написания кода, следовательно, другие команды вынуждены нас ждать;
    • есть проблемы при генерации методов со сходной сигнатурой (/api/v1/users и /api/v1/users?login={1}) – они сливаются в один метод.
  • Contract First – у нас есть согласованный со всеми сторонами контакт, по нему мы генерируем код. Сгенерированный код рабочий, но проблема в том, что он выглядит очень плохо и работать с ним зачастую неудобно. Плюс в команде есть Code Style, соглашения о названиях классов и т.п., а значит нам хочется, чтобы весь код в проекте удовлетворял этим критериям.

Рассматриваем что умеет проект OpenAPI Generator

Погрузимся глубже в Contract First и посмотрим что можно улучшить. Для генерации кода возьмем самое распространенное решение – проект OpenAPI Generator и посмотрим что оно умеет:

  • генерация клиентского и серверного кода для всем популярных языков и фреймворков (Java, Kotlin, Go, C#, JavaScript и т.п.);
  • валидация OpenAPI;
  • возможность кастомизации шаблонов.
# установка OpenAPI Generator
$ npm install @openapitools/openapi-generator-cli -g

Генерируем код по контракту на клиенте и сервере, разбираем что получилось

Рассматривать будем пример OpenAPI servers.yml, сгенерируем клиентский код с помощью openapi-generator:

$ openapi-generator generate \
    -g kotlin \
    --api-package ru.romanow.openapi.client.rest \
    --model-package ru.romanow.openapi.client.models \
    --additional-properties=dateLibrary=java8,serializationLibrary=jackson,enumPropertyNaming=UPPERCASE \
    -o client/build/generated \
    -i openapi/servers.yml

В результате получаем целый проект:

Generated client

Посмотрим на файл ServerResponse:

/**
 * Please note:
 * This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
 * Do not edit this file manually.
 */
@file:Suppress("ArrayInDataClass", "EnumEntryName", "RemoveRedundantQualifierName", "UnusedImport")
package ru.romanow.openapi.client.models

import ru.romanow.openapi.client.models.StateInfo
import com.fasterxml.jackson.annotation.JsonProperty

data class ServerResponse(
    @field:JsonProperty("id") val id: kotlin.Int,
    @field:JsonProperty("purpose") val purpose: ServerResponse.Purpose,
    @field:JsonProperty("latency") val latency: kotlin.Int,
    @field:JsonProperty("bandwidth") val bandwidth: kotlin.Int,
    @field:JsonProperty("state") val state: StateInfo
)

Код получился неплохой, но все равно много лишнего, например аннотация @JsonProperty или enum как inner class.

Чтобы не держать огромную команду запуска перенесем основные параметры в config.yml.

Лишние файлы

Полученный шаблон кода оформлен как проект, т.е. его можно собрать и использовать как модуль или опубликовать в репозиторий. Но наша задача сгенерировать код моделей, а все остальное не нужно

В сгенерированном коде мы получаем большое количество лишних файлов:

Опишем эти исключения в файле .openapi-generator-ignore.

$ openapi-generator generate \
  -g kotlin \
  --config openapi/client/config.yml \
  --ignore-file-override openapi/client/.openapi-generator-ignore \
  -o client/build/generated \
  -i openapi/servers.yml

На этот раз мы убрали все лишние файлы:

Generated client

Используем свои шаблоны генерации кода по OpenAPI

Структура шаблона

Мы избавились от лишних файлов, теперь займемся кастомизацией шаблона. В качестве движка шаблонизации используется mustache.

В OpenAPI generator можно переопределить часть шаблона генерации, при этом остальные файлы оставить без изменений.

Для начала надо выгрузить шаблон:

$ openapi-generator author template -g kotlin --library jvm-ktor -o openapi/templates

Заходим в openapi/client/templates и видим большое количество шаблонов:

OpenAPI codegen templates

Файлов много, но по сути есть 5 входных типов файлов, а остальные просто являются частью других шаблонов:

  • API – клиент или сервер;
  • APIDocs – markdown описание API;
  • Model – модели;
  • ModelDocs – markdown описание моделей;
  • SupportingFiles – дополнительные файлы.

Нам нужна кастомизация моделей, поэтому удаляем лишнее и оставляем только три файла:

Для примера рассмотрим шаблон model:


package 


import 





  • `` – подключение шаблона;
  • `` – обращение к значению переменной;
  • ` …. ` – обращение к переменной (так же используется для обхода списка).

Т.е. в этом примере подключается шаблон licenseInfo.mustache, а потом, если модель – enum, то подключаем шаблон enum_class.mustache, иначе data_class.mustache.

data class  (

    var : SetListArray<}}>}}? = null,

)

OpenAPI generator разбирает OpenAPI и собираем объект, который передает в шаблонизатор:

{
    "importPath": "ru.romanow.openapi.client.models.CreateServerRequest",
    "model": {
        "name": "CreateServerRequest",
        "classname": "CreateServerRequest",
        "isPrimitiveType": false,
        "vars": [
            {
                "openApiType": "string",
                "dataType": "kotlin.String",
                "name": "purpose",
                "baseType": "kotlin.String",
                "required": true,
                "deprecated": false,
                "isPrimitiveType": true,
                "isContainer": false,
                "isString": true,
                "isNumeric": false,
                "isInteger": false,
                "isShort": false,
                "isLong": false,
                "isUnboundedInteger": false,
                "isNumber": false,
                "isFloat": false,
                "isDouble": false,
                "isDecimal": false,
                "isByteArray": false,
                "isBinary": false,
                "isFile": false,
                "isBoolean": false,
                "isDate": false,
                "isDateTime": false,
                "isUuid": false,
                "isEmail": false,
                "isPassword": false,
                "isNull": false,
                "isVoid": false,
                "isFreeFormObject": false,
                "isAnyType": false,
                "isArray": false,
                "isMap": false,
                "isEnum": false,
                "isInnerEnum": false,
                "isEnumRef": false,
                "isReadOnly": false,
                "isWriteOnly": false,
                "isNullable": false,
                "vars": [],
                "requiredVars": [],
                "hasValidation": false,
                "isInherited": false,
                "nameInCamelCase": "Purpose",
                "nameInSnakeCase": "PURPOSE",
                "datatype": "kotlin.String",
                "hasItems": false,
                "isEnumOrRef": false
            }
        ]
    }
}

В config.yml указываем измененный шаблон и при запуске OpenAPI Generator передаем папку с шаблонами:

files:
    model.mustache:
        templateType: Model
        destinationFilename: .kt

Custom Template

$ openapi-generator generate \
  -g kotlin \
  --config openapi/client/config.yml \
  --ignore-file-override openapi/client/.openapi-generator-ignore \
  --template-dir openapi/client/templates \
  -o client/build/generated \
  -i openapi/servers.yml

Customized Classes Result

Если нам не требуется модифицировать шаблон (например ApiController в server), то мы просто его не меняем и он создается на основе базовых шаблонов.

Если требуется убрать сгенерированные файлы, то описываем их в .openapi-generator-ignore.

Вместо выводов: как поддержать баланс между чувством прекрасного и сгенерированным кодом?

  1. Если разработка идет в одной команде, то возможно Code First вам подойдет, т.к. для него не требуется никаких особенных настроек.
  2. Если все-таки вы работаете с Contract First, то нужно смотреть в сторону codegen: без этого реализация быстро разойдется с контрактом.
  3. OpenAPI generator позволяет кастомизировать шаблоны, следовательно, вы можете сгенерировать код, удовлетворяющий Code Style и вашему чувству прекрасного.

Вместо выводов: как максимально быстро реагировать в тестах на изменения в коде?

  1. Возможность генерировать модели и клиента по OpenAPI позволяет вам быстрее реагировать на изменения в реализации.
  2. OpenAPI generator сгенерирует за вас весь boilerplate, вам же останется лишь описать сценарии.
  3. OpenAPI generator позволяет кастомизировать шаблоны, следовательно, вы можете сгенерировать код, удовлетворяющий Code Style и вашему чувству прекрасного.