Согласованность API по принципу единого источника истины
- четверг, 26 февраля 2026 г. в 00:00:10
Привет, Хабр!
Представим ситуацию: идет тяжёлый спринт, вы выполнили кучу задач, написали тонну нового функционала, готовитесь к релизу и вдруг обнаруживайте, что часть фич перестала работать! Идёте разбираться и обнаруживайте, что оказывается бэкендер Вася в последний момент решил переименовать поля в json-е, а вам об этом не сказал!
Ситуация образная, но позволяет быстро обрисовать одну из болей во время разработки. В этой статье я бы хотел рассказать об одном из вариантов её решения в коде с помощью подхода Единого источника истины(Single source of truth).

1. Согласованность API, что и зачем?
2. Варианты организации согласованного API
3. Реализация согласованного API на Hono RPC
Единый источник истины - это концепция, которая позволяет нам избавится от множеств версий одних и тех же данных, позволяет их согласовать, объявить контракт с которым можно сверится. Используя этот подход при разработке API мы:
всегда уверены в возвращаемых данных и их типе
имеем единую спецификацию по которой разрабатываем и тестируем API
можем использовать генераторы API клиентов
упрощаем решение всех конфликтов
Начнём с самого простого и наиболее популярного - спецификация Open API. Данная спецификация позволяет описать всю структуру нашего API в одном едином .yaml файлике и начинать разработку опираясь на него.
Так например на фронтенде мы сможем использовать генераторы API клиентов, избавляясь от написания кучи шаблонного кода. Взамен получая готовый и строго типизированный клиент, для взаимодействия с API. Для тестировщиков это позволит автоматически генерировать коллекции для Postman-а и тестировать API вручную. Для бэкендеров - пособие по тому что, и в каком типе ждёт клиент.
А ещё есть замечательные Open API клиенты, которые на основе вашего .yaml фалика - сгенерируют вам веб страницу со всеми методами вашего приложения. И позволят протестировать их прямо из браузера, никуда не уходя. К тому же, в большинстве, они встраиваются в бэк написанием всего пары строчек кода.

Перед тем как перейти к следующему блоку - подчеркну важность того, что бы начинать разработку когда спецификация уже готова. Ведь для большинства Open API не новость, но появляется он после того как API работоспособен. И нередко спецификацию забывают обновить и просто допускают в ней ошибки.
Язык запросов, который предоставляет нам схему - строго типизированное описание всех данных который мы можем запросить и изменить, а так же связи между ними. Именно схема и будет являться нашим контрактом. По ней мы согласовываем все запросы, и просто не можем получить то, чего в ней нет
Remote Produce Call - класс инструментов, который позволяет вызывать удалённые функции так, как будто они доступны нам локально. Это позволяет нам абстрагироваться от сетевых взаимодействий, не думать о том какой это метод и какие данные он принимает. Мы просто вызываем функцию, которая принимает чётко обозначенные данные и ровно так же гарантирует строго типизированный ответ.
В контексте единого источника истины, контрактом выступает сам код. Мы начинаем писать функцию, а IDE подскажет что нам вернётся и какие аргументы она принимает. А так же RPC позволяет нам выбрать удобную для нас технологическую реализацию. Начиная от gRPC - созданного для быстрого и надёжного межсервисного взаимодействия и заканчивая Hono RPC построенном на жёсткой типизации, на примере которого я бы хотел показать простейшую реализацию SSOT.
Hono - лёгкий js фреймворк для написания бэка. Поддерживает Type Script, а так же из коробки предоставляет удобную модель RPC. Его очень удобно использовать в монорепе, поэтому развернём моно репозиторий на два пакета - client и server. Объявим workspace-ы в корневом package.json, а так же создадим папки client и server. В них так же отредактируем package.json, дабы объявить их отдельными пакетами. И добавим серверный пакет в зависимости клиентского package.json. Не забудем и отредактировать корневой tsconfig.json, а затем сделаем extends в конфиги пакетов.
Корневой package.json
{ "name": "ssot", "private": true, "workspaces": [ "client", "server" ], "peerDependencies": { "typescript": "^5" } }
Client package.json
{ "name": "@ssot/client", "module": "index.ts", "type": "module", "private": true, "dependencies": { "@ssot/server": "workspace:*" }, "peerDependencies": { "typescript": "^5" } }
Server package.json
{ "name": "@ssot/server", "module": "index.ts", "types": "index.ts", // не забываем экспортирвать типы "type": "module", "private": true, "peerDependencies": { "typescript": "^5" } }
Корневой tsconfig.json
{ "compilerOptions": { "lib": ["ESNext"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, "moduleResolution": "bundler", "verbatimModuleSyntax": true, "declaration": true, //важно "composite": true, //важно для корректного экспорта типо в пакет клиента "noEmit": false, //важно "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false, "paths": { "@ssot/server": ["./server/index.ts"], "@ssot/client": ["./client/index.ts"], }, }, }
Client tsconfig.json
{ "extends": "../tsconfig.json", "references": [{ "path": "../server" }], //для корректной обработки импорта типов }
Server tsconfig.json
{ "extends": "../tsconfig.json", }
Установим Hono - в корневой директории выполняем:
bun add hono @hono/zod-openapi --cwd server
bun add hono --cwd client
Для бэкенда я использую hono с поддержкой построения OpenAPI на основе Zod схем. Это позволит автоматически собирать схему и подключать к ней любой удобный OpenAPI клиент в пару строк.
Напишем простой серверный роут:
import { createRoute, z, type RouteHandler } from "@hono/zod-openapi"; //описваем схему роута export const messageRoute = createRoute({ method: "get", path: "/message", request: { query: z.object({ symbol: z.string().length(1) }), }, responses: { 200: { description: "Success", content: { "application/json": { schema: z.object({ message: z.literal("Hey, this is a strongly typed message."), symbol: z.string().length(1) }), }, }, }, } }) //на основе схемы собираем хендлер для обработки запроса const Message: RouteHandler<typeof messageRoute> = (c) => { const symbol = c.req.valid("query").symbol; return c.json({ message: "Hey, this is a strongly typed message.", symbol }); } export default Message;
И создадим приложение:
import { OpenAPIHono } from "@hono/zod-openapi"; import Message, { messageRoute } from "./message"; //создаем экземпляр приложения и добовляем префикс /api const app = new OpenAPIHono().basePath("/api") //объявлем роут const route = app.openapi(messageRoute, Message) export default app; export type HonoApp = typeof route;
Далее соберём клиент:
import type { HonoApp } from "@ssot/server"; import { hc } from "hono/client"; const hClient = hc<HonoApp>("http://localhost:3000").api;
На выходе получаем удобный клиент для работы с API, неразрывно связанный с беком и дающий полное представление о типах который мы получим в ответ. И если вдруг Вася решит что-то изменить в последний момент, то сборка упадет, а мы увидим в каком месте и почему!

Установим:
bun add @scalar/hono-api-reference --cwd server
и развернём Scalar OpenAPI Client:
// url по которому будет доступна OpenAPI схема app.doc("/doc", (c) => ({ openapi: "3.0.0", info: { version: "1.0.0", title: "My API", }, })); // OpenAPI client app.get( "/scalar", Scalar({ url: "/api/doc", theme: "bluePlanet", showDeveloperTools: "never", }), );
import { type ClientResponse, type InferResponseType } from "hono/client"; type RpcEndpoint = ( ...args: any[] ) => Promise<ClientResponse<any, number, "json">>; export const apiHandler = async <T extends RpcEndpoint>( query: T, ...params: Parameters<T> ): Promise<InferResponseType<T, 200>> => { const response = await query(params); if (!response.ok) { throw new Error((await response.json()).message); } return response.json(); }; // пример использования const response = await apiHandler(hClient.message.$get, {query: {symbol: "a"}});
Это мой первый опыт написания статей на Хабре - буду рад критике и дополнениям в комментариях. На этом у меня все, спасибо что прочитали!