Однажды я внедрил в своей команде подход разработки через API-first. Все было классно, мы описывали API спецификации в файле, запускали генерацию, публиковали артефакты в репозиторий, но меня не покидало чувство, что работать с этим не так удобно как я себе это представлял, и я стал искать причины.
Прежде, чем продолжить, немного добавлю про свой стек: Java, Spring Framework и все-все-все из этой "истории".
Возвращаясь к первому приседания с API-first...
Старый подход
Раньше я использовал распространенный подход, в котором в проекте было 2 модуля - API контракты и само приложение. Примерно так:

Где-то В API контракте (crud-service-api в данном случае) лежал файл со спецификацией (openapi.yaml) и в Gradle (у вас может быть Maven) был подключен OpenAPI generator, который генерировал классы для приложения и jar, затем этот jar загружался в maven-репозиторий.
Отмечу, что для других клиентов мы не генерировали API, так как команды фронтов и мобильных приложений не были готовы к этому подходу, зато у нас было много межсервисных общений по REST. Ну и ничто не мешало распространить этот подход и на наших коллег (фронтов и мобильщиков).
И в чем тут же неудобство?
Со временем увеличивалось количество задач, а так же количество сервисов и потоков данных между ними. И тут я начал осознавать - неудобство в том, что в каждом сервисе отдельно нужно вносить изменения в API контракты, пушить изменения в гит, запускать CI, публиковать артефакты. А еще у нас были повторяющиеся модели, которые просто приходилось дублировать размазывать во всех контрактах (сервисах). Ну и напоследок, хоть и не часто, при внесении изменений в конфигурацию генератора, это необходимо было делать во всех проектах.
Возможно минусов этого подхода больше, пока писал статью, вспомнил только эти.
Таким образом я начал думать об ином подходе и пришел к тому, о котором расскажу вам далее.
Новый подход
Идея сводилась к простому: есть моно-репозиторий, в нем по папкам разложены API контракты и общие модели, сборка и публикация артефактов производится оттуда же. Так и вышло.


В папке common могут находиться любые общие и переиспользуемые модели и спецификации. В моем простом примере, это файл models.yaml с описанием Page и Pageable объектов
Содержимое файла models.yaml:
Page: type: object description: 'Description' properties: size: type: integer page: type: integer totalElements: type: integer totalPages: type: integer Pageable: type: object description: 'Description' properties: size: type: integer page: type: integer sort: type: array items: type: string

Вот примерно такая простая структура у меня получилась. Приводить пример структуры notification-service-api не стал, потому что она идентична структуре crud-service-api.
Давайте немного пробежимся по CI файлам.
1) Корневой .gitlab-ci.yml:
stages: - trigger .trigger: stage: trigger trigger: strategy: depend crud-service-api: extends: .trigger trigger: include: crud-service-api/.gitlab-ci.yml rules: - changes: - crud-service-api/* notification-service-api: extends: .trigger trigger: include: notification-service-api/.gitlab-ci.yml rules: - changes: - notification-service-api/*
Здесь описаны триггеры, которые будут запускать пайплайны только для тех сервисов, в которые были внесены изменения.
2) Дополнительный .api-first.gitlab-ci.yml:
stages: - validate - prepare - generate - publish variables: OPENAPI_GENERATOR: registry.gitlab.com/dmitrii-demchenko/infrastructure/custom-docker-images/openapi-generator-cli:1.0.0 OPENAPI_GENERATOR_CLI: java -jar /opt/openapi-generator-cli.jar # Stage: validate validate: stage: validate image: ${OPENAPI_GENERATOR} script: - cp ${OPENAPI_SPEC_PATH}/openapi.yaml openapi.yaml - ${OPENAPI_GENERATOR_CLI} validate -i openapi.yaml rules: - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" when: always - if: $CI_COMMIT_BRANCH == "main" when: always artifacts: paths: - openapi.yaml tags: - docker # Stage: prepare .prepare: stage: prepare before_script: - mkdir -p configs/shared - | cat > configs/shared/common.yaml <<EOF inputSpec: openapi.yaml EOF rules: - if: $CI_COMMIT_BRANCH == "main" when: on_success artifacts: paths: - openapi.yaml - configs expire_in: 1w needs: - job: validate tags: - docker prepare:spring-cloud-interface: extends: .prepare script: - | cat > configs/shared/spring-cloud-interface.yaml <<EOF ${SPRING_CLOUD_INTERFACE_INCLUDE_CONFIG} EOF - | cat > configs/spring-cloud-interface.yaml <<EOF '!include': 'shared/common.yaml' outputDir: generated/spring-cloud-interface generatorName: spring '!include': 'shared/spring-cloud-interface.yaml' additionalProperties: artifactId : ${JAR_ARTIFACT_ID} groupId : ${JAR_GROUP_ID} apiPackage : ${JAR_API_PACKAGE} modelPackage : ${JAR_MODEL_PACKAGE} library : spring-cloud dateLibrary : java8 useSpringBoot3 : true useTags : true interfaceOnly : true openApiNullable : false documentationProvider : none hideGenerationTimestamp : true additionalModelTypeAnnotations: '@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) @lombok.Getter @lombok.Setter @lombok.AllArgsConstructor @lombok.NoArgsConstructor' EOF # Stage: generate generate: stage: generate image: ${OPENAPI_GENERATOR} script: # - *script-clone-common - ${OPENAPI_GENERATOR_CLI} batch configs/*.yaml rules: - if: $CI_COMMIT_BRANCH == "main" when: on_success artifacts: paths: - generated expire_in: 1w needs: - job: prepare:spring-cloud-interface artifacts: true tags: - docker # Stage: publish .publish: stage: publish rules: - if: $CI_COMMIT_BRANCH == "main" when: manual allow_failure: true tags: - docker .publish:maven: extends: .publish image: maven:3-eclipse-temurin-21-alpine variables: MAVEN_OPTS: "-Dmaven.repo.local=${CI_PROJECT_DIR}/.m2/repository" MAVEN_CLI_OPTS: "-Dmaven.test.skip=true -s settings.xml" script: - echo ${MAVEN_SETTINGS_XML} > settings.xml - mvn deploy ${MAVEN_CLI_OPTS} -DaltDeploymentRepository=${MAVEN_SERVER_ID}::${MAVEN_SANDBOX_URL} cache: paths: - '.m2/repository' publish:spring-cloud-interface: extends: .publish:maven before_script: - cd generated/spring-cloud-interface needs: - job: generate artifacts: true
Этот файл можно назвать основным, так как в нем описаны основные джобы.
3) Контрактный (на примере crud-service-api/.gitlab-ci.yml):
include: - local: .api-first.gitlab-ci.yml variables: OPENAPI_SPEC_PATH: crud-service-api JAR_GROUP_ID: com.example JAR_ARTIFACT_ID: crud-service-api JAR_API_PACKAGE: com.example.crud.api JAR_MODEL_PACKAGE: com.example.crud.api.model SPRING_CLOUD_INTERFACE_INCLUDE_CONFIG: |- typeMappings: 'OrderPageDto': 'Page<OrderDto>' importMappings: 'Page': 'org.springframework.data.domain.Page' 'Pageable': 'org.springframework.data.domain.Pageable' 'Page<OrderDto>': 'org.springframework.data.domain.Page'
В этом же файле уже идет передача конечных значения переменным, предопределенным в файле выше.
Да, чуть не забыл...
Что за образ такой registry.gitlab.com/dmitrii-demchenko/infrastructure/custom-docker-images/openapi-generator-cli:1.0.0?
Для удобства сборки контрактов я сделал небольшой образ с openapi-generator-cli на борту.
Dockerfile:
FROM eclipse-temurin:21-jre-alpine ARG VERSION=7.10.0 ARG OPENAPI_URL=https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${VERSION}/openapi-generator-cli-${VERSION}.jar ADD ${OPENAPI_URL} /opt/openapi-generator-cli.jar
И еще у меня есть переменная MAVEN_SETTINGS_XML которой нигде не задано значение. Это постоянная переменная, которая добавлена в Gitlab CI/CI variables в настройках репозитория и содержит креды к maven-репозиторию. И выглядит она вот так:
<settings xmlns="http://maven.apache.org/SETTINGS/1.1.0"> <servers> <server> <id>maven-sandbox</id> <username>${env.MAVEN_SANDBOX_USERNAME}</username> <password>${env.MAVEN_SANDBOX_PASSWORD}</password> </server> </servers> </settings>
Запускаем первый пайплайн (на самом деле не первый, но первый удачный, который не стыдно показать вам):

И получаем загруженный артефакт в любой удобный для вас maven-репозиторий.

Кто и как работает с контрактами
Работать с репозиторием может кто угодно, системный аналитик или разработчик, тут все зависит от принятых процессов на проекте. В нашем случае поддержанием актуальных спецификаций занимаются сами разработчики. Все изменения в контракт вносятся либо после системной аналитики, либо параллельно. Аппрувят изменения в репозитории разработчики после проведения ревью.
Версионирование реализовано по простому, через указание версии в спецификации конкретного сервиса.
Была версия 1.0.0:
openapi: 3.0.3 info: title: CRUD service API description: CRUD service API version: 1.0.0
Стала 1.1.0:
openapi: 3.0.3 info: title: CRUD service API description: CRUD service API version: 1.1.0
Далее обычная сборка и деплой из main ветки.
Итоги
Как я и хотел, я получил удобный (по крайней мере для себя и своих разработчиков) способ хранения, разработки и распространения API контрактов. Он так же отлично ложится на подход API-first.
Уверен, в этом подходе есть вещи, которые можно улучшить и я продолжаю исследовать этот вопрос.
PS: не буду вдаваться в подробности CLI команд openapi генератора, с документацией этого инструмента и тем, как я настраивал параметры для сборки контракта, можно ознакомиться по ссылкам:
https://openapi-generator.tech/docs/usage#batch
https://openapi-generator.tech/docs/generators/spring/
В планах сделать что-то похожее для асинхронных контрактов (Kafka, RabbitMQ, и тд).
Вопросы, критика и предложения в комментарии.
Спасибо за внимание.
