Привет! Я Дмитрий, старший Angular-разработчик в команде Klecks — платформы для разметки данных AI-Центра Т-Банка.

Последние годы мы жили с NSwag: один api.ts на 13 000 строк, Docker-сборка бэкенда и ожидание по 15—17 минут каждый раз, когда менялся контракт. В рамках одной из задач мы решили перейти к модели, где OpenAPI-файлы лежат в репозитории, а клиент генерируется через ng-openapi-gen. Расскажу, какие проблемы мы пытались решить, что пробовали на стороне ASP.NET Core и какой workflow получился в итоге.

Почему старый подход перестал работать

По мере роста проекта фронтенд и бэкенд начинают все сильнее зависеть друг от друга. Контракты API меняются, появляются новые DTO, переименовываются поля и enum. В какой-то момент команда сталкивается с неприятной ситуацией: фронт узнает об изменениях слишком поздно. Бэкендер переименовал поле, изменил enum или URL — а проблема всплывает уже в QA или вообще в несвязанной задаче. Даже автоматически сгенерированный TypeScript-клиент не всегда спасает: его изменения сложно просматривать, а сам процесс генерации может превратиться в отдельный ритуал. 

Для решения проблемы многие команды используют OpenAPI. Если коротко, OpenAPI — открытый стандарт описания HTTP API. Он позволяет хранить контракт между фронтендом и бэкендом в виде JSON- или YAML-файла, где описаны эндпоинты, параметры, модели и ответы.

Появление стандарта породило множество инструментов для работы с ним: генераторы документации, проверки совместимости и codegen-клиенты. В .NET-мире одно из самых популярных решений — NSwag, который умеет генерировать TypeScript-клиент для фронтенда.

Последние годы наш workflow был неизменным: один гигантский api.ts, томная сборка в Docker и ожидание по 15–17 минут при смене контракта. В какой-то момент стало понятно, что проблема уже не в скорости генерации, а в самом процессе работы с API.

Зачем трогать то, что работает

NSwag у нас был давно и формально справлялся: скрипт собирал весь бэкенд в Docker, NSwag читал DLL (скомпилированный код) и генерировал монолитный api.ts на 13 000 строк. Но по мере роста платформы этот процесс перестал быть комфортным.

Случайные поломки фронта. Бэкендер переименовал enum, DTO (Data Transfer Object) или URL, и фронт падал в несвязанной задаче. Контракт менялся незаметно, потому что diff в C# и diff в сгенерированном api.ts на ревью читать тяжело.

Долгая генерация. Чтобы обновить клиент, нужно было:

  • собрать бэкенд;

  • поднять Docker-контейнер с образом dotnet-node-nswag;

  • прогнать NSwag по скомпилированной DLL.

На Colima это легко превращалось в 15—17 минут ожидания. Команда сначала собирала бэкенд, потом поднимала customer_nswag и operator_nswag.

Размытый процесс. Контракт иногда описывали в Jira, иногда — нет. Даже когда описывали, реализация на бэке могла отличаться. Уточнения происходили в личных сообщениях, а фронт узнавал о некоторых изменениях уже постфактум.

Монолитный клиент. Customer api.ts занимал больше 13 000 строк. Один файл, десятки классов {Controller}Client, общий ClientBase с обработкой blob-ответов. Ревью, merge conflicts и навигация по коду постепенно превращались в боль.

Когда команда перешла на TBD, в день в мастер могли попадать контракты под разные задачи. Сама сборка отнимала много времени, и мы сформулировали цель:

  • контракт должен быть отдельным артефактом;

  • его должно быть удобно просматривать в MR;

  • фронтенд должен иметь возможность ревьюить изменения без знания C#;

  • генерация клиента должна быть быстрой локальной операцией.

Что пробовали на бэкенде и почему отказались от build-time

Гипотеза была простая: ASP.NET Core умеет в OpenAPI, значит, можно генерировать .json на этапе билда и коммитить в git.

Мы изучили встроенную генерацию OpenAPI в ASP.NET Core и генерацию документов при сборке. На практике build-time-подход не зашел:

  • Генератор пытается поднимать приложение в специальном режиме, а у нас в startup (запуск приложения) много зависимостей: секреты, Mongo, Kafka, PostrgeSQL. Пришлось бы перестраивать инициализацию.

  • Время сборки заметно вырастало: сравнили build-time-генерацию с выбранным компромиссным решением.

  • Инструмент ненадежен: зависания на mock-server, открытые баги в dotnet и aspnetcore.

Build-time-генерацию мы отвергли: слишком хрупкая и тяжелая для нашего стартапа. Вместо нее выбрали компромисс: OpenAPI отдается по HTTP-роуту в рантайме, а в dev-режиме hosted-сервис OpenApiContractExporter при старте приложения скачивает JSON с этого endpoint и кладет в open-api/ рядом с проектом. 

На практике процесс выглядит так:

  1. Бэкендер запускает приложение локально.

  2. OpenAPI-файлы автоматически обновляются после старта сервиса.

  3. Изменения попадают в git вместе с кодом контроллеров.

Регистрация выглядит так:

services.AddKlecksOpenApi("web-api", relativePath: [
    "klecks-customer/web-api/",
    "klecks-customer/instruction/"
]);

services.AddOpenApiContractExporter(config => {
    config.OutputDirectory = customerSettings.OpenApi.OutputDirectory;
  
    config.Locations = [
        OpenApiExtensions.GetAbsoluteLocation(
          Ports.Customer, ApplicationPath, "customer-api"
        ),
        OpenApiExtensions.GetAbsoluteLocation(
          Ports.Customer, ApplicationPath, "web-api"
        )
    ];
});

У Customer получилось два документа:

  • customer-api.json (~5 300 строк) — внешнее API для интеграций;

  • web-api.json (~7 900 строк) — API пользовательского интерфейса.

Swagger UI при этом никуда не делся и по-прежнему используется для ручного тестирования. OpenAPI же стал отдельным контрактом и источником генерации клиентского кода.

Трансформеры: делаем схему пригодной для codegen

Стандартный вывод Microsoft.AspNetCore.OpenApi оказался не совсем удобным для генерации TypeScript-клиента, поэтому мы добавили несколько трансформеров.

OperationIdTransformer. Для каждой операции нужен уникальный operationId, иначе ng-openapi-gen не сможет корректно именовать функции. Мы генерируем идентификаторы в формате: {Controller}-{Action}-{HttpMethod}

Например: Order-GetOrderDetails-GET

EnumAsStringSchemaTransformer. По умолчанию enum сериализуются как числа.

В результате diff становится практически нечитаемым: непонятно, что именно изменилось. Поэтому мы переводим enum в строки и используем camelCase-значения.

ContentTypeTransformer. OpenAPI по умолчанию добавляет несколько вариантов JSON-контента:

  • application/json

  • text/json

  • /+json

Процесс генерации приводит к раздуванию клиентского кода, поэтому мы оставляем только application/json.

ParameterNameTransformer. Приводим названия параметров к привычному для TypeScript виду: OrderId → orderId

MultipartFormTransformer. Здесь пришлось добавить workaround для известной проблемы ASP.NET Core. Multipart-запросы описывались как application/x-www-form-urlencoded, поэтому дополнительно патчить схему пришлось уже перед генерацией клиента.

Фронтенд: ng-openapi-gen вместо NSwag

На фронтенде мы тоже уперлись в ограничения старого подхода. NSwag генерировал один огромный файл, внутри которого находились десятки классов {Controller}Client, модели и общий ClientBase.

Типичный клиент выглядел так:

@Injectable()
export class OrderClient extends ClientBase {
    getOrderDetails(orderId: string): Observable<OrderDetailsDto> {
        let url_ = this.baseUrl + "/klecks-customer/web-api/order/" + encodeURIComponent("" + orderId);
        // ... еще 20 строк на каждый метод
    }
}

Старый подход на nswag работал, но со временем возникали проблемы:

  • один файл на тысячи строк;

  • сложные diff;

  • постоянные merge conflicts;

  • неудобная навигация по коду;

  • тяжелые импорты.

Мы решили попробовать ng-openapi-gen. Вместо монолитного клиента он генерирует отдельные модели и функции. В результате вместо одного файла получилось около 300 небольших файлов. Каждая операция превратилась в чистую функцию:

export function orderGetOrderDetailsGet(
    http: HttpClient,
    rootUrl: string,
    params: OrderGetOrderDetailsGet$Params,
    context?: HttpContext
): Observable<StrictHttpResponse<OrderDetailsDto>> {
    const rb = new RequestBuilder(rootUrl, orderGetOrderDetailsGet.PATH, 'get');

    if (params) {
        rb.path('orderId', params.orderId, {});
    }

    return http.request(rb.build({ responseType: 'json', accept: 'application/json', context }))
        // ...
}

orderGetOrderDetailsGet.PATH = '/klecks-customer/web-api/order/{orderId}';

Сначала подход с ng-openapi-gen показался непривычным, но довольно быстро стало понятно, что он хорошо масштабируется.

Раньше сервисы напрямую зависели от классов, которые генерировал NSwag.

Было - Стало
Было - Стало

ClientService — кастомный Handlebars-шаблон поверх стандартного ng-openapi-gen. Главное отличие от дефолта — единая обработка ошибок через наш ApiException, совместимый с тем, что был в NSwag-клиенте.

Из коробки ng-openapi-gen генерирует набор функций и сервисов, но нам хотелось сохранить единый способ обработки ошибок, который уже существовал в NSwag-клиенте. Поэтому поверх стандартного генератора мы сделали собственный Handlebars-шаблон и добавили ClientService.

В итоге получилась единая точка вызова API: return this.clientService.invoke(fn, params)

Через единую точку вызова проходят:

  • создание запросов;

  • обработка ошибок;

  • преобразование ответов;

  • работа с нашим ApiException.

Для остального приложения миграция оказалась почти прозрачной. Генерация клиента теперь занимает секунды.

Теперь все свелось к одной команде:

npm run generate:all:api

# customer + operator, несколько секунд

Единственная проблема, которую мы не смогли решить средствами ASP.NET Core, связана с multipart-запросами. Из-за бага в OpenAPI-генерации в схемы попадали лишние свойства IFormFile: ContentType, FileName, Header, Length, Name и другие служебные поля.

В результате ng-openapi-gen создавал некорректные модели, поэтому перед запуском генератора мы добавили промежуточный шаг.

Скрипт generate-api-with-patch.js, при котором патч происходит автоматически и не влияет на сам контракт:

  1. Читает OpenAPI-файлы из src/backend/.../open-api.

  2. Исправляет проблемные multipart-схемы.

  3. Создает временную копию JSON.

  4. Запускает ng-openapi-gen.

Новый workflow с контрактами

После перехода на OpenAPI-файлы в git пришлось договориться о процессе работы. Сейчас он выглядит так.

Согласование контракта. На этапе проработки задачи бэкендер заводит отдельную подзадачу на контракт. Фронтенд и бэкенд заранее обсуждают:

  • новые поля;

  • enum;

  • breaking changes;

  • ожидаемые ответы.

Изменение API. После реализации бэкендер:

  • обновляет контроллеры;

  • запускает приложение;

  • получает обновленные OpenAPI-файлы;

  • коммитит JSON вместе с кодом.

Ревью фронтендом. В MR фронтендер назначается обязательным ревьюером через правило approvers.yml. Теперь он смотрит не diff в C# и не гигантский api.ts, а обычный diff в web-api.json. Если нужно, локально можно запустить npm run generate:customer:api и сразу проверить, как изменения повлияют на клиент.

Любые изменения контракта проходят тот же путь. Если API меняется уже в процессе разработки, создается отдельный MR. Фронтенд снова участвует в ревью и видит изменения до того, как они попадут в QA или production.

Со временем у нас появились несколько правил, которые оказались особенно важными:

  • исправление контракта — приоритетная задача;

  • для web-api стараемся сохранять обратную совместимость;

  • несогласованное переименование поля считается поломкой UI;

  • все breaking changes обсуждаются до мержа.

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

Что получилось: цифры и trade-offs.

NSwag

ng-openapi-gen + OpenAPI в git

Время генерации клиента

15—17 мин (Docker + сборка бэка)

Несколько секунд

Размер артефакта на примере одного приложения (customer)

1 файл, ~13 000 строк

~300 файлов, diff по операциям

Ревью контракта фронтом

diff в C# или гигантский api.ts

diff в web-api.json

Зависимость фронта от бэка при codegen

Нужна скомпилированная DLL

Достаточно JSON из репозитория

Плюсы, которые мы почувствовали:

  • фронт участвует в ревью контракта до мержа, а не узнает о поломке в QA-среде;

  • генерация клиента — часть фронтового workflow, без Docker;

  • OpenAPI-файл — живая документация, ее могут смотреть все участники процесса;

  • tree-shaking дружелюбнее: импортируешь только нужные функции из functions.ts.

Минусы и компромиссы, о которых стоит знать:

  • Multipart до сих пор требует патча — баг на стороне ASP.NET Core, не ng-openapi-gen.

  • Экспорт контракта в dev требует поднятого бэкенда — build-time мы отвергли сознательно. Но у этого компромисса есть обратная сторона: OpenAPI-файл не обновляется сам по факту правки в коде. Если бэкенд-разработчик отрефакторил DTO или переименовал поле, но не запустил приложение локально, в open-api/*.json ничего не изменится. Поломка всплывет позже: на ревью фронта, в CI или уже в несвязанной задаче.

  • Фронту нужно ревьюить MR бэкенда — это дополнительная нагрузка, но она окупается меньшим количеством «сюрпризов».

  • Миграция с {Controller}Client на ClientService.invoke(fn, params) — много однотипных правок по кодовой базе.

Заключение

Мы не искали лучший генератор ради генератора. У нас были другие цели:

  • сделать контракт прозрачным;

  • ускорить цикл взаимодействия между фронтендом и бэкендом;

  • перестать ловить случайные поломки после переименований на стороне API.

Переход от NSwag к OpenAPI-файлам в git и ng-openapi-gen позволил решить эти проблемы. Самый заметный эффект: генерация клиента перестала быть отдельным ритуалом с Docker и ожиданием по 15—17 минут. Но со временем стало понятно, что главный результат вообще не связан со скоростью. Мы изменили точку опоры.

Раньше фронтенд был привязан к реализации бэкенда: DLL, Docker, NSwag и внутренней структуре проекта. Теперь фронтенд работает с контрактом. Нам больше неважно, на чем написан бэкенд. Достаточно актуального OpenAPI-файла, и это открыло несколько интересных возможностей.

Контракт больше не зависит от структуры репозитория. Монорепозиторий или несколько репозиториев — не так важно. Достаточно получить web-api.json, выполнить npm run generate:customer:api — и можно работать дальше. 

В будущем контракт вообще может храниться:

  • в отдельном репозитории;

  • S3;

  • Artifactory;

  • CI-артефактах.

Контракт можно вынести в отдельный процесс. OpenAPI-файлы могут стать единой точкой согласования между командами. Это позволяет делать отдельные MR на изменения API, автоматически назначать ревьюеров, проверять breaking changes и уведомлять заинтересованные команды. То есть API становится полноценным продуктом, а не побочным результатом сборки.

Документация перестает зависеть от Swagger UI. Один и тот же контракт можно показывать через Swagger UI, Redoc, генераторы документации и собственные внутренние порталы.

Контракт один, а способ его представления может быть любым. Конечно, компромиссы никуда не исчезли. Мы все еще обновляем OpenAPI при изменении DTO, патчим multipart-схемы и ревьюим изменения API с обеих сторон. Но для нас это оказалась разумная цена за то, что контракт стал явным артефактом, а не побочным продуктом сборки. И пожалуй, именно это изменение оказалось важнее, чем замена одного генератора на другой.

Если у вас похожий стек (Angular + ASP.NET Core) и NSwag или swagger-codegen, как у вас устроен процесс согласования контрактов? Генерируете на билде, коммитите OpenAPI в репозиторий или уже вынесли контракты отдельно? Поделитесь опытом в комментариях.

Материалы для дополнительного чтения