Однажды я внедрил в своей команде подход разработки через 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 контракты и общие модели, сборка и публикация артефактов производится оттуда же. Так и вышло.



Вот примерно такая простая структура у меня получилась. Приводить пример структуры 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-репозиторий.

Итоги
Как я и хотел, я получил удобный (по крайней мере для себя и своих разработчиков) удобный способ хранения, разработки и распространения API контрактов. Он так же отлично ложится на подход API-first.
Уверен, в этом подходе есть вещи, который можно улучшить и я продолжаю исследовать этот вопрос.
PS: не буду вдаваться в подробности CLI команд openapi генератора, с документацией этого инструмента и тем, как я настраивал параметры для сборки контракта, можно ознакомиться по ссылкам:
https://openapi-generator.tech/docs/usage#batch
https://openapi-generator.tech/docs/generators/spring/
В планах сделать что-то похожее для асинхронных контрактов (Kafka, RabbitMQ, и тд).
Вопросы, критика и предложения в комментарии.
Спасибо за внимание.