На прошлой неделе я снова потратил полдня на то, чтобы понять, почему фронт падает после обновления бэка. Локально работало, а на стейдже ошибка. Оказалось, бэкендер переименовал поле в ответе, но не обновил документацию и не предупредил команду. Я узнал об этом только когда код упал на стейдже - вручную править ручку пришлось уже постфактум, разбираясь с ошибкой.
С этим надо было что-то делать.
Решение: генерируемый API-клиент
Я начал использовать генерируемый API-клиент. По сути, это набор ручек (функций для запросов) и типов к ним, которые генерируются на основе открытого API - yaml-файла сваггера.
Теперь на фронте я просто ввожу в терминал команду:
npm run openapi:pull
Она втягивает в проект актуальный yaml-файл, парсит его и выдает папку api/ в корне проекта. В api/generated лежат два главных файла: со всеми ручками и с их типами (описаниями структуры данных).
Как выглядит мой флоу обновления
Я скачиваю свежий spec (спецификацию - описание API) с бэкенда одной командой:
npm run openapi:pull
Под капотом она делает две вещи:
Скачивает
openapi.yamlс бэкенда черезcurlЗапускает генерацию на основе скачанного файла
Иногда мне нужно только перегенерировать клиент из уже лежащего в репозитории spec - без скачивания с бэкенда:
npm run openapi:generate
В качестве генератора я использую @hey-api/openapi-ts.
Мой конфиг openapi-ts.config.ts выглядит так:
import { defineConfig } from '@hey-api/openapi-ts'
export default defineConfig({
input: 'api/openapi.yaml',
output: 'api/generated/',
clean: true,
client: '@hey-api/client-fetch'
})
Важный момент: я коммичу в репозиторий и
api/openapi.yaml, и папкуapi/generated/. Но рукамиgenerated/никогда не правлю - только через генерацию.
Как устроена структура api/
Папка api/ у меня организована так:
Файл/папка |
Назначение |
|---|---|
|
Источник правды - OpenAPI-спецификация, скачанная с бэкенда |
|
Всё, что сгенерировано: |
|
Реэкспорт клиента из |
|
Реэкспорт функций эндпоинтов, например |
|
Реэкспорт всех типов |
|
Единая точка входа: клиент + все типы |
Что я использую в приложении
Функции для запросов я импортирую прямо из ~/api/generated. Например, в сторах, страницах или блоках:
import { whoIsThere, getBasketOfShame } from '~/api/generated'
Типы я импортирую из ~/api (через index.ts):
import type { User, BasketOfShameResponse } from '~/api'
Обёртку над запросами (baseURL, заголовки, обработка ошибок) я настраиваю отдельно, это не относится к генерации, поэтому в статье не рассматриваю.
Что делать, если бэкенда ещё нет?
Я храню моки (заглушки для тестирования) в папке mock/. Каждый мок - это обычный json-файл, соответствующий структуре ответа. Когда бэкенд готов, я просто удаляю моки и подключаю реальный клиент.
Как это повлияло на мою разработку
В коде и шаблонах теперь всегда используются только актуальные поля, приходящие с бэка. Мне не нужно держать в голове корректность именований - TypeScript сам подсказывает на этапе написания кода. Если я опечатался в имени поля или передал не тот тип, IDE сразу подсвечивает ошибку.
Исчезла головная боль с расхождениями в контракте (договорённости о том, как именно клиент и сервер обмениваются данными). Раньше огромное количество времени улетало на то, чтобы понять, кто виноват: фронтенд неправильно стучится или бэкенд неправильно отвечает. Теперь я всегда знаю, где лежат ручки, для чего нужна каждая из них, как ими пользоваться и какие типы данных участвуют в контракте. Всё это прямо в коде проекта - даже сваггер открывать не нужно.
Любой новый фронтендер, приходящий на проект и прочитавший короткое ридми, сразу знает, куда смотреть. На небольших проектах, где задача - вывести данные или отправить их назад, это особенно удобно: я практически полностью избавился от ручной API-типизации, которая требует постоянной актуализации, и от ручного написания каждой новой ручки.
И да, сместился фокус ответственности
Мы шутим, что теперь, если что-то сломалось, виноват бэкенд. Но на самом деле ответственность за работоспособность API действительно смещается на бэк.
С ручной реализацией, если запрос не работал, первопричину искали долго: либо фронтенд неверно запрашивает, либо бэкенд неверно отвечает.
Теперь всё проще. Если запрос не работает, есть два места, куда смотрим в первую очередь:
либо сваггер некорректно заполнен и не соответствует реальному контракту, который ожидает бэк;
либо - что чаще - просто бэковая поломка.
Но я не перекладываю работу
Моя цель - единая точка входа в контракт клиент-серверного взаимодействия. Флоу починки бага теперь максимально линеен:
Бэк чинит запрос
Бэк обновляет сваггер
Я одной командой втягиваю обновленный
yaml-файл и обновляю код по актуальному контракту
Подводные камни, с которыми я столкнулся
Всё было бы идеально, если бы не пара моментов.
oneOf / anyOf. Генератор не всегда красиво обрабатывает сложные схемы с пересечениями. Иногда приходится вручную дописывать гарды (проверки типов), чтобы TypeScript понял, что именно пришло.
Сваггер не совпадает с реальностью. Бывает, что в openapi.yaml написано одно, а бэк возвращает другое. В этом случае TypeScript не просто бесполезен - он активно мешает. Типы говорят, что пришло одно, а по факту приходит другое. Единственный способ успокоить компилятор - писать кучу проверок, гардов и as-кастов, чтобы код просто не краснел. А если поле вообще отсутствует, то вместо понятной ошибки ты получаешь undefined в рантайме, хотя TypeScript утверждает, что поле обязательное. В итоге ты тратишь время не на поиск реальной проблемы, а на то, чтобы TypeScript просто успокоился.
Трансформация данных на фронте. Сгенерированные DTO (объекты передачи данных) - это не доменные модели (внутренние объекты приложения с бизнес-логикой). Иногда я оборачиваю их в адаптеры (прослойки, преобразующие данные), чтобы добавить вычисляемые поля или переименовать для удобства внутри приложения.
Лучше коммитить spec. Я храню openapi.yaml в репозитории, чтобы сборка не падала, если бэкенд внезапно недоступен.
Что в итоге
Это работает. Я попробовал и возвращаться к ручному написанию ручек уже не хочу. Генерация API-клиента экономит время, убирает головную боль с типизацией и делает процесс разработки предсказуемым.
Если вы ещё не пробовали - начните с малого. Один небольшой проект. Мне хватило, чтобы понять, что возвращаться к ручному написанию ручек я уже не буду.
DmitryKazakov8
Добро пожаловать на Хабр.
Идея генерировать апи-клиент с типизацией из openapi стара, и ее недостатки достаточно изучены, чтобы сказать - это скорее нишевая схема, в большинстве проектов добавляет больше неразберихи и проблем, чем пользы.
Часть проблем вы уже нашли сами, с другой частью еще столкнетесь:
схема в реальности может расходиться с фактически присланными данными (по моему опыту - частая история), то есть гарантии мы не получаем
проблемы с версионированием - если скажем 10 разработчиков бэк+фронт работают над разными ручками в рамках своих задач, нужна сложная инфраструктура чтобы удобно разрабатывать. Это отдельное пространство для проблем, которые можно "героически решать", например публикуя с yaml или с готовым апи-клиентом пакеты во внутренние репозитории, генерируя в день сотни
"@api-spec": "123.3.1877-feature-123", синхронизируя со стендами и деплойными циклами, занимаясь сложными слияниями в dev. Ну либо перекидываться в рабочих чатах yaml файлами, а потом на dev стенде после слияния нескольких фич ловить что или фронт или бэк вмерджили что-то не выложенное на стенд или ушли в глубокий конфликт.фронту может быть нужен 1 параметр в конкретной ручке, а бэк присылает 100 (возможно - легаси, или для разных приложений-потребителей). Сгенерированный апи-клиент не очистит лишнее, ему все кажется важным и нужным.
в ряде кейсов теряется параллельность разработки бэка и фронта. В задаче согласовали переименование параметра в ручке - фронт не может у себя поменять, проверить, доработать типы и связанный код. Он должен дождаться пока бэк либо сделает задачу, либо сделает заглушки -> генерируется openapi -> генерируется апи-клиент -> можно начинать работу. Либо костылить и реплейсить вручную части сгенерированного апи-клиента.
большинство cli-утилит генерации заточены под единый огромный инстанс апи-клиента. Сложно будет сделать lazy loading, разбиение на чанки, модульность (чтобы апи лежало ближе к местам использования), не потеряв единый процесс фетчинга и не наплодив дубляж.
велик риск что в апи-клиент попадут неиспользуемые ручки, и в целом анализ по проекту "какие именно ручки и какие именно поля фронт использует" будет затруднен. Бандл неизбежно раздувается, на Хабр летят статьи "почему вкладки браузера тормозят и едят столько памяти".
логирование несовпавших ожиданий все равно требуется - то есть необходимо где-то хранить "что именно нужно фронту для работы", и делать дифф со сгенерированным апи-клиентом.
Ну, о недостатках можно говорить очень долго, суть - "сгенерированный апи-клиент по openapi может гарантировать, что он соответствует openapi, но абсолютно не гарантирует что он соответствует конкретному фронту-потребителю".
Конечно, есть кейсы, где это все подходит на каком-то из этапов развития проекта (особенно если количество фронтендеров === 1 и проект маленький), но он скорее тупиковый.
Как не сталкиваться с этими проблемами? Воткнуть генерацию в правильное место - не в то, чтобы создавать js-файл "что примерно отдает бэк", а в то, чтобы "производилась валидация ожиданий между тем, что нужно фронту и что реально отдает бэк". Написать руками DTO для ручек только с теми полями, которые нужны конкретному фронту-потребителю, а дальше уже подключать генерацию, как примерно описывал тут.
grmnche Автор
Рад быть тут :) Да, сложно не согласиться, что такой подход имеет как свои преимущества, некоторые из которых я описал, так и недостатки в виде сложности с масштабированием и потребности в четком версионировании. Для мелких проектов - я прямо-таки доволен. Спасибо за такой развернутый комментарий о подводных камнях, это ценно
DmitryKazakov8
Главное - успейте вовремя остановиться на текущем пути) Если поверх сгенерированного по спеке апи-клиента еще прикручивать автоматические валидаторы типа Zod, то одна из проблем формально решится - то, что бэк реально отдает, начнет валидироваться. Но добавятся новые - фронт будет логировать ошибки или падать при изменении неиспользуемых полей. По факту фронт становится "валидатором, что бэк правильно сгенерировал спеку" - это вообще путь не туда. Я потратил пару лет на этот путь, и продвинулся довольно далеко в энтерпрайзах - но проблемы будут множиться, инфраструктура становиться все сложнее, а по времени и ресурсам бизнесу это все куда дороже, чем даже вручную править.
Решение этого - не в проверке соответствия бэкенда им же написанной спеке. Просто предостерегаю от излишнего погружения в такую схему - лучше не делать дефолтной для проектов, есть подходы эффективнее.
grmnche Автор
Спасибо! Это отличный аргумент из практики) На что вы перешли в итоге после того, как пришли к тому, что генерируемый клиент - не тот путь, который вам нужен?
DmitryKazakov8
Не понимаю вопрос - в конце первого комментария я описал схему работы и дал ссылку на пример кода.