
Почти любое мобильное приложение общается с бэкендом, а значит живёт по контракту — чаще всего в виде OpenAPI-схемы. Смотреть на неё удобнее всего в Swagger.

Пока проект маленький, изменения в API можно отслеживать вручную. Но по мере роста схемы и команды ручная проверка становится дорогой и начинает регулярно ломаться.
Автогенерация из OpenAPI решает проблему, но в многомодульном Android-проекте всплывают нюансы: где хранить код, как не тянуть лишнее, как вписать сгенерённый код в архитектуру.
Привет! Меня зовут Дима Максимов, я Android-разработчик в Дринкит. В этом цикле из 2 статей я расскажу о том, как настроить генерацию из Swagger в Kotlin-код, и о том, как обуздать автогенерацию в условиях многомодульного проекта.
Схема генерации. The best way
Сгенерировать код из Swagger просто в том случае, если:
у вас нет строгих ограничений по виду проекта;
сетевой код можно целиком сложить в одном месте.
С другой стороны – есть наш проект Дринкит. И тут мы сталкиваемся с несколькими сложностями одновременно:
1) Отдельные модули под каждую фичу. Согласно принципам Clean Architecture, каждый наш модуль имеет разделение на -api (domain), -impl (data) и presentation. В итоге проект имеет следующую структуру:
root ├── infra │ ├── lib_1 │ ├── lib_2 │ └── ... └── contexts ├── common │ ├── cart │ │ ├── domain-api │ │ └── domain-impl │ └── ... ├── cart │ ├── domain-api │ ├── domain-impl │ └── presentation ├── product │ ├── domain-api │ ├── domain-impl │ └── view └── ...
И в каждом модуле примерно такая структура:
cart ├── cart-api │ ├── build.gradle │ └── src/main/kotlin/ru/drinkit/common/cart │ ├── Cart.kt │ ├── CartItem.kt │ ├── ConsumeCartRepository.kt │ └── ... other cart related domain classes └── cart-impl ├── build.gradle └── src/main/kotlin/ru/drinkit/common/cart ├── ConsumeCartRepositoryImpl.kt ├── api │ └── CartApi.kt └── dto ├── CartDto.kt ├── CartLineDto.kt └── ... other cart related DTOs
То есть в конкретном модуле лежат DTO-модели и сетевой интерфейс только конкретного домена. Для корзины — про корзину, для карточки продукта — про карточку. Для соблюдения нашей архитектуры необходимо найти способ генерировать код из Swagger в модули -impl.
2) Вторая проблема про разделение ответственности даже в условиях одинаковых контекстов. Например, в блоке со структурой проекта можно заметить два одинаковых модуля cart: в common и в contexts. И это не ошибка!
Дело в скоупах использования. Например, классы и интерфейсы из contexts:common:cart могут использоваться разными частями приложения. Часто они «живут» в скоупе всего приложения.
При этом модуль contexts:feature отвечает за конкретную фичу и экран. В нём часто требуются запросы, которые нужны только для того, чтобы нарисовать UI и получить специфичные для экрана данные.

Легко представить ситуацию, когда разным частям приложения нужна возможность отправлять запросы в сеть про корзину: добавить или удалить продукт, посчитать что-нибудь. Если такая возможность нужна многим частям приложения, мы помещаем контракт в contexts:common:some-feature.
Но на экране корзины мы отображаем блок с рекомендациям продуктов. Такой запрос нужен только на экране корзины, а значит он будет лежать в domain-impl в contexts:feature. Знает про него только экран корзины.
Получается, нам нужен механизм фильтрации API-запросов и DTO-моделей из OpenAPI схемы для конкретного модуля. То есть, проектируя экран, мы должны знать, какие запросы нужны, и генерировать только их.
Обобщая требования:
Генерировать код в условиях многомодульности мы будем только в
-implмодулях.Перед тем, как генерировать, нам нужно отфильтровать только то, что модулю необходимо, и ничего более.
Выбор инструмента
Посмотрим, чем пользуются в сообществе. Первое, что находим — openapi-generator и swagger-codegen. В свою очередь, openapi-generator — это форк swagger-codegen. Да, на деле это одно и то же, но всё-таки мы возьмём openapi-generator — у него больше звёздочек.
Мне ещё рассказывали про мультиплатформенную библиотеку moko-network. У библиотеки всего 150 звёздочек, но интересно, что несколько человек упомянули её в ходе обсуждения. Прикладываю её сюда — возможно, вам пригодится.
Я быстро оценил возможности каждого, уровень поддержки, наличие документации и число ресурсов про troubleshooting на StackOverflow и Github. В итоге выбрал openapi-generator.
Библиотеку можно использовать через CLI или через Gradle Plugin. Последний — обёртка CLI, но параметры для запуска можно удобно задать через Gradle Task openApiGenerate:
// build.gradle openApiGenerate { generatorName = "kotlin" inputSpec = "openapi.json" modelPackage = "ru.drinkit.dto" apiPackage = "ru.drinkit.api" additionalProperties = [ "library": "jvm-retrofit2", "serializationLibrary": "kotlinx_serialization", "useCoroutines": "true", "dateLibrary": "kotlinx-datetime", ] }
У генератора много способов кастомизации. Все возможные параметры можно найти на странице с документацией.
Первая итерация. Генерация кода в одном месте
Начнём адаптацию с простого. Положим json-схему в корень проекта, а таску из блока выше определим в app-модуле. Запустим генератор через командную строку:
./gradlew openApiGenerate
По выполнении таски мы найдём в директории build/generate-resources… Целый Gradle-МОДУЛЬ!
Да, openapi-generator по умолчанию генерит целый модуль. Если вам нужно просто сгенерировать всё в одном модуле, который подключится к остальным — это отличное решение. Меняете outputDirectory, добавляете модуль в settings.gradle — и вуаля: модуль собирается в иерархии проекта.

Из дополнительного, что здесь генерируется по умолчанию:
Папка .openapi-generate с различной метаинформацией.
Документация на классы.
Тесты.
Вам всё это не нужно? Просто отключите генерацию дополнительных элементов:
openApiGenerate { // Отключает тесты на модели и API generateModelTests = false generateApiTests = false // Задаёт конфиг с файлами, которые не надо генерировать // Например, строка `!**/src/main/java/**/*` в файле указывает, // что должно генерироваться только содержимое `src/main/java/**` ignoreFileOverride = "openapi-generator-ignore" }
Но результат нас не устраивает. Нам-то нужно генерировать код в разных модулях, предварительно отфильтровав нужные модулю сетевые запросы.
Проще сначала реализовать фильтрацию с генерацией в одном месте. Потом расширим это решение на многомодульность.
Изучим сгенерированный код
А какой код генерируется способом по умолчанию из openapi-generator? По правде говоря, не очень читаемый и совсем неудобный для использования.
DTO-модели все на одно лицо — у всех один и тот же префикс CoffeeApiContacts:

Внутри же код хорош, пусть у него и есть недостатки, про которые расскажу дальше:
/** * * 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.drinkit.dto import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName import kotlinx.serialization.Contextual /** * * * @param id * @param isoCode * @param isoAlpha3 */ @Serializable data class CoffeeApiContractsMobileChainCurrencyDto ( @Contextual @SerialName(value = "id") val id: java.util.UUID, @SerialName(value = "isoCode") val isoCode: kotlin.Int, @SerialName(value = "isoAlpha3") val isoAlpha3: kotlin.String ) { }
Что хочется исправить в финальном варианте в DTO:
Убрать приписку «Do not edit this file manually». В целом, она полезная, но мы решили убрать — надо разобраться, откуда она настраивается.
Убрать бесполезные комментарии про поля data-класса.
Suppress для детекта можно сделать общим, используя однострочную запись
@file:Suppress("all").Некоторые типы по умолчанию мы хотим заменить. Например, поле
id— это типjava.until.UUID. Мы же в проекте практически всегда используем String для такого. Если использовать java-библиотекуjava.until.UUID, то в будущем при миграции на KMP (если такая произойдет) это сильно нас замедлит.Ну и, конечно, убрать приписки в названии классов. Вместо
CoffeeApiContractsMobileChainCurrencyDtoмодель должна называтьсяChainCurrencyDto.
Теперь перейдём к API-интерфейсу. У Retrofit интерфейса тоже всё не так красиво, как хотелось бы
interface MobileApi { /** * POST api/v1/CalculateOrder * Calculates order total cost and costs for each order line * * Responses: * - 200: Success * - 404: Some entities not found * - 429: Rate limit exceeded * * @param clientversion client version (default to "3.0.0") * @param clientid client id (default to "caa") * @param locale (optional) * @param coffeeApiContractsMobileOrdersCalculateOrderRequestDto (optional) * @return [CoffeeApiContractsMobileOrdersCalculateOrderResponseDto] */ @POST("api/v1/CalculateOrder") suspend fun apiV1CalculateOrderPost( @Header("clientversion") clientversion: kotlin.String = "3.0.0", @Header("clientid") clientid: kotlin.String = "caa", @Query("locale") locale: kotlin.String? = null, @Body coffeeApiContractsMobileOrdersCalculateOrderRequestDto: CoffeeApiContractsMobileOrdersCalculateOrderRequestDto? = null ): Response<CoffeeApiContractsMobileOrdersCalculateOrderResponseDto> // .. other API methods }
Что хочется изменить:
ВСЕ методы находятся в одном файле
MobileApi. Это особенность нашего бэкенда. Но менять его структуру дорого, поэтому придётся придумать хитрое решение на клиенте.Название REST-метода также выглядит неуместно. Метод
apiV1CalculateOrderPost()должен называтьсяcalculateOrder(). Это тоже неудобство, связанное с устройством бэкенда.Названия передающихся в параметры DTO — длинные и неудобные. Об этом в пятом пункте про DTO.
Некоторые параметры — лишние, поскольку часть из них добавляется в каждом сетевом запросе через механизм
okhttp3.Interceptor. Автоматически добавляютсяclientVersion,clientId,localeи ещё несколько.Документацию тоже уберем, для нас она не несет никакой практической пользы.
Получается, самое трудное — справиться с форматом OpenAPI.json, который отдаёт бэкенд. Рассмотрим его проблемы подробнее и узнаем, как с ними смириться справиться.
Проблемы с нашим OpenAPI
Посмотрим на openapi.json вблизи. Схема по стандарту OpenAPI состоит из 4 частей. Самые важные из них — paths и components:
{ "openapi": "3.0.1", "info": { "title": "Coffee.Api" }, "paths": [ ... ], "components": [ ... ] }
Изучив их, найдём, что нам мешает сгенерировать «правильный» код. Узнаем, как решить эти проблемы.
Методы в OpenAPI-схеме
В paths лежат REST-методы из контракта бэкенда. Исторически тут лежит всё для киоска, для Payment, для колл-центра и прочее. Нам нужно только то, что помечено /api/v*/ — это методы для клиентского приложения:
{ "paths": { "/api/v1/GetUnits": {}, "/api/v1/CalculateOrder": {}, "/api/v2/CalculateOrder": {}, ... } }
Внутри каждого метода — его детальное описание:
тип REST-метода с описанными входными значениями в
parameters;наличие Body-объекта для запроса в
requestBody;тег для группировки их в Swagger-интерфейcе;
возможные ответы в
responses.
"/api/v1/CalculateOrder": { "post": { "tags":["Mobile"], "summary": "Calculates order total cost and costs for each order line", "parameters": [ { "name": "Locale", ... } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Coffee.Api.Contracts.Mobile.Orders.CalculateOrderRequestDto" } } } }, "responses": { "200": { "description": "Success", ... } }, "security": [...] } }
Поле
tagнужно для разделения методов на группы в интерфейсе Swagger. Кроме того, в интерфейсе с таким тегом будет сгенерирован код endpoint'ов.
Как мы видим, название метода в Kotlin, которое выглядит как apiV1CalculateOrderPost, генерируется по принципу path+method:
/api/v1/CalculateOrder + post = apiV1CalculateOrderPost
А ещё в requestBody у моделей очень длинный неймспейс, который превращается в название класса:
/components/schemas/Coffee.Api.Contracts.Mobile.Orders.CalculateOrderRequestDto -> CoffeeApiContractsMobileOrdersCalculateOrderRequestDto
Модели в OpenAPI-схеме
Что насчёт моделей и что там не так? В целом, все точно так же:
"Coffee.Api.Contracts.Mobile.Menus.ProductDto": { "required": [ "id", "code", ... ], "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "prices": { "type": "array"... }, "code": { "type": "integer"... }, "sizeType": { "$ref": "#/components/schemas/Coffee.Api.Contracts.Mobile.Menus.ProductSizeTypeDto"... }, ... } }
Неймспейс бы сократить, да и некоторые типы параметров не соответствуют тому, что мы используем на клиенте. В примере выше — id типа uuid, хотя мы преобразуем это в String.
Не хватает скрипта, который оригинальный JSON исправит, учитывая требования, перечисленные в этом разделе. Но есть ли другие опции? В теории — да, но, возможно, они более дорогие:
Заставить бэкендеров переписать контракты, но в больших командах это может быть трудно или даже невозможно, если такая структура была заложена изначально. Повезёт, если у вас получится.
-
Создать свой генератор. Библиотека поддерживает множество генераторов (в нашем случае — kotlin), но мысль «написать cвоё» однажды придёт естественным образом.
Можно ли эту мысль реализовать? Да, но процесс будет таким сложным, что лезть совершенно не захочется. Если коротко, то:
- нужно завести проект;
- подключить зависимость на openapi-generator;
- реализовать генератор, разобравшись, как это сделать;
- собрать Jar;
- использовать его через CLI.
В общем, оценив «за» и «против» каждого из вариантов, я решил быстро написать Python-скрипт для предобработки JSON. И не ошибся!
Создаём Python-скрипт для предобработки JSON
Скрипты для GitHub Action и другие подобные вещи мы пишем на Python. И в отличие от остальных скриптов, этот — достаточно сложный. Причём и в теории, и на практике.
«Питоном» я не владею, но способен прочитать код и понять, адекватный ли он. Так что самое время заиспользовать ✨✨ LLM ✨✨
В первой итерации допустим, что мы дадим разработчику инструмент для генерации кода руками. Дальше он перемещает сгенерированный код в нужное место.
Скрипт будет генерировать код в отдельном месте, куда сложим и сам swagger json. Например, в папке projectDir/swagger-codegen.
Напомню, что требуется от скрипта:
Фильтрация JSON только по REST-методу.
Перечисление моделей, связанных только с REST-методом.
Упрощение имён методов и моделей.
Через несколько итераций с LLM и уточнения того, как JSON должен выглядеть – получаем скрипт предпроцессинга: https://gist.github.com/kartollikaa/a7cd2aae259fd3dad9aced838b174c53
Как запустить? Можно вызывать напрямую через терминал:
# Пример как запустить скрипт через терминал python3 filter_openapi_json.py openapi.json "/api/v1/GetRecentOrders"
Чтобы удобнее использовать скрипт и сделать сразу всё за одно нажатие, я решил добавить bash-файл для запуска:
В начале происходит валидация входных данных из параметра командной строки.
Он же запускает скрипт
filter_openapi_json.py, передав ему необходимыйendpoint.В папке
swagger-codegen/появляется файлfiltered_openapi.jsonс подготовленной openapi схемой.Этот файл мы передаём в команду
./gradlew openApiGenerate.
Сам bash-скрипт я привёл ниже. Его уже просто можно брать и запускать:
#!/bin/bash endpoint="${1}" if [ -z "$endpoint" ]; then echo "Usage: $0 <endpoint>" echo "For example $0 \\"/api/v1/GetRecentOrders\\"" exit 1 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" python3 "$SCRIPT_DIR/filter_openapi_json.py" "$SCRIPT_DIR/openapi.json" "$endpoint" $SCRIPT_DIR/.././gradlew openApiGenerate --project-dir $SCRIPT_DIR/.. --input=$SCRIPT_DIR/filtered_openapi.json --rerun-tasks rm "$SCRIPT_DIR/filtered_openapi.json"
Настройка генератора через Gradle-плагин
У openapi-generator есть удобный Gradle-плагин. Он нужен, чтобы сконфигурировать генератор под наши требования.
В скрипте выше и вызывается Gradle-таска openApiGenerate. Её нужно только правильно настроить.
Добавим в build.gradle конфигурацию генерации с помощью одноимённого блока openApiGenerate. Параметров много, их описания есть на странице документации. Я же расскажу про самые важные для нас.
openApiGenerate { generatorName = "kotlin" ignoreFileOverride = "${projectDir.path}/../swagger-codegen/.openapi-generator-ignore" outputDir = "${projectDir.path}/../swagger-codegen/generated/" templateDir = "${projectDir.path}/../swagger-codegen/templates" invokerPackage = "ru.drinkit" modelPackage = "ru.drinkit.dto" apiPackage = "ru.drinkit.api" generateModelTests = false generateApiTests = false generateApiDocumentation = false generateModelDocumentation = false typeMappings = [ "string+uuid": "kotlin.String", "string+date-time": "java.util.Date" ] additionalProperties = [ "library": "jvm-retrofit2", "serializationLibrary": "kotlinx_serialization", "useCoroutines": "true", "dateLibrary": "kotlinx-datetime", ] }
-
ignoreFileOverride– выделяет файлы, которые создавать не нужно: лишние директории и файлы, которые создаются для нового модуля./**/docs/ /**/gradle/wrapper/ /**/org/openapitools/client/infrastructure/ /**/.openapi-generator-ignore /**/build.gradle /**/gradlew /**/gradlew.bat /**/README.md /**/settings.gradle /**/proguard-rules.pro generatorName— используемый генератор. В нашем случае — Kotlin, поскольку мы генерируем Kotlin-код.typeMappings— преобразователь типов. Например, он отдаёт вам бэкенд ID в виде UUID, а вы договорились на клиентах, что это будет String. Ещё можно по-разному преобразовывать даты — главное определить сериализатор, если вы делаете что-то нестандартное.additionalProperties— для настройки используемых инструментов. Некоторые генераторы поддерживают дополнительные свойства. Например, Kotlin-генератору можно сообщить, какие библиотеки использовать для описания API, сериализации, времени, корутин и т.д.templateDirдля определения своих шаблонов генерации. О них ниже.
Шаблоны для генерации
В таске openApiGenerate есть параметр templateDir. Он для нас особенно важен.
Код генератора мы извне никак не отредактируем. Однако, мы можем изменить, как будет выглядеть сгенерированный класс. Для этого используются «шаблоны», по которым генерируются код. Написаны они на языке Mustache, а выглядят примерно так:
@file:Suppress("all") package {{apiPackage}} import retrofit2.http.* {{#imports}}{{#-first}}import {{modelPackage}}.*{{/-first}}{{/imports}} {{#operations}} public interface {{classname}} { {{#operation}} @{{httpMethod}}("{{path}}") public suspend fun {{#lambda.camelcase}}{{operationId}}{{/lambda.camelcase}}( {{#hasPathParams}} {{#pathParams}} @Path("{{baseName}}") {{paramName}}: String, {{/pathParams}} {{/hasPathParams}} {{#hasQueryParams}} {{#queryParams}} @Query("{{baseName}}") {{paramName}}: {{dataType}}{{^required}}? = null{{/required}}, {{/queryParams}} {{/hasQueryParams}} {{#hasBodyParam}} {{#bodyParam}}@Body {{#lambda.camelcase}}{{baseName}}{{/lambda.camelcase}}: {{baseType}}{{/bodyParam}}, {{/hasBodyParam}} ): {{{returnType}}}{{^returnType}}Unit{{/returnType}} {{/operation}} } {{/operations}}
Шаблоны для Kotlin лежат в официальном репозитории. Можно подробнее ознакомиться с файлами, по которым создаются... файлы (шаблоны?) и что-то поменять.
Например, можно убрать licenceInfo. Создадим в директории templateDir файл licenceInfo.mustache. В нём будет пусто, а при запуске генератора мы заметим пропажу этого блока:

Поскольку этот блок интегрируется в начало, добавим в шаблон строку:
@file:Suppress("all")
И теперь файлы не подвержены ошибкам со стороны детекта:

С помощью шаблонов можно сильно изменить вид ваших файлов. Например, отформатировать параметры с новых строк, убрать импорты, целые блоки кода, документацию или просто отчистить шаблон от ненужных библиотек.


Запускаем
На этом первая итерация — всё. Проверим работоспособность, вызвав в терминале:
./swagger-codegen/openapi_generate.sh "/api/v1/GetCustomerMenuView"
Получим множество сгенерированных файлов:

Внешний вид API-интерфейса:
package ru.drinkit.api import retrofit2.http.* import ru.drinkit.dto.* public interface MobileApi { @GET("api/v2/GetCustomerMenuView") public suspend fun apiV2GetCustomerMenuViewGet( @Query("countryId") countryId: kotlin.Int? = null, @Query("unitId") unitId: kotlin.String? = null, @Query("collapseMenu") collapseMenu: kotlin.Boolean? = null, ): GetMenuViewResponseV2 }
Внешний вид DTO-моделей:
package ru.drinkit.dto import ru.drinkit.dto.MenuViewDto import ru.drinkit.dto.ProductRecommendationBundleDto import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName import kotlinx.serialization.Contextual @Serializable data class GetMenuViewResponseV2 ( @SerialName(value = "menuView") val menuView: MenuViewDto, @SerialName(value = "productRecommendationBundle") val productRecommendationBundle: ProductRecommendationBundleDto? = null, )
Всё уже выглядит хорошо, но над множеством вещей ещё надо поработать:
Нейминг методов. Имя API метода выглядит как
apiV2GetCustomerMenuViewGet(). Нужно получитьgetCustomerMenuView().Нейминг интерфейса. Сейчас название API интерфейса —
MobileApi. Хочется, чтобы название отражало домен, для которого мы генерируем код. НапримерOrderApi.Декомпозиция API. Разнести методы по доменным интерфейсам (или хотя бы по
tags, чтобы не держать весь контракт в одном файле.Многомодульность. Научить генерацию работать в многомодульном проекте: генерить код «куда надо», переиспользовать общие модели и не дублировать их.
И другие сложности — о них всех пойдёт речь во второй части. А пока отдаём команде инструмент для временного использования. Приступаем к его улучшению и реализации поддержки многомодульности.
Заключение
В первой итерации мы научились брать сырой openapi.json, забирать из него только нужный эндпоинт со всеми связанными моделями и генерить из этого Kotlin код через openapi-generator с кастомными шаблонами.
Этого уже достаточно, чтобы пользоваться инструментом руками — но пока всё генерится в одно место и многое приходится делать вручную.
Во второй части научим кодогенерацию работать в нашем многомодульном проекте, попутно разбираясь с сложностями: коллизиями имён, версионированием эндпоинтов, неймингами и прочими сложностями.
А с первой частью на этом всё. Пишите ваши мысли в комментарии, закидывайте плюсик в карму и подписывайтесь на Telegram-канал Dodo Engineering, чтобы оставаться в курсе последних новостей нашей команды.
Также заглядывайте в мой Telegram-канал «Android в тесте и маленький капучино», где я пишу про мобильную разработку