javascript

Согласованность API по принципу единого источника истины

  • четверг, 26 февраля 2026 г. в 00:00:10
https://habr.com/ru/articles/1003398/

Привет, Хабр!

Представим ситуацию: идет тяжёлый спринт, вы выполнили кучу задач, написали тонну нового функционала, готовитесь к релизу и вдруг обнаруживайте, что часть фич перестала работать! Идёте разбираться и обнаруживайте, что оказывается бэкендер Вася в последний момент решил переименовать поля в json-е, а вам об этом не сказал!

Ситуация образная, но позволяет быстро обрисовать одну из болей во время разработки. В этой статье я бы хотел рассказать об одном из вариантов её решения в коде с помощью подхода Единого источника истины(Single source of truth).

Содержание

1. Согласованность API, что и зачем?

2. Варианты организации согласованного API

3. Реализация согласованного API на Hono RPC

Согласованность API, что и зачем?

Единый источник истины - это концепция, которая позволяет нам избавится от множеств версий одних и тех же данных, позволяет их согласовать, объявить контракт с которым можно сверится. Используя этот подход при разработке API мы:

  • всегда уверены в возвращаемых данных и их типе

  • имеем единую спецификацию по которой разрабатываем и тестируем API

  • можем использовать генераторы API клиентов

  • упрощаем решение всех конфликтов

Какие есть варианты организации источника единой истины при разработке API?

Open API

Начнём с самого простого и наиболее популярного - спецификация Open API. Данная спецификация позволяет описать всю структуру нашего API в одном едином .yaml файлике и начинать разработку опираясь на него.

Так например на фронтенде мы сможем использовать генераторы API клиентов, избавляясь от написания кучи шаблонного кода. Взамен получая готовый и строго типизированный клиент, для взаимодействия с API. Для тестировщиков это позволит автоматически генерировать коллекции для Postman-а и тестировать API вручную. Для бэкендеров - пособие по тому что, и в каком типе ждёт клиент.

А ещё есть замечательные Open API клиенты, которые на основе вашего .yaml фалика - сгенерируют вам веб страницу со всеми методами вашего приложения. И позволят протестировать их прямо из браузера, никуда не уходя. К тому же, в большинстве, они встраиваются в бэк написанием всего пары строчек кода.

Scalar Open API Client
Scalar Open API Client

Перед тем как перейти к следующему блоку - подчеркну важность того, что бы начинать разработку когда спецификация уже готова. Ведь для большинства Open API не новость, но появляется он после того как API работоспособен. И нередко спецификацию забывают обновить и просто допускают в ней ошибки.

GraphQL

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

RPC

Remote Produce Call - класс инструментов, который позволяет вызывать удалённые функции так, как будто они доступны нам локально. Это позволяет нам абстрагироваться от сетевых взаимодействий, не думать о том какой это метод и какие данные он принимает. Мы просто вызываем функцию, которая принимает чётко обозначенные данные и ровно так же гарантирует строго типизированный ответ.

В контексте единого источника истины, контрактом выступает сам код. Мы начинаем писать функцию, а IDE подскажет что нам вернётся и какие аргументы она принимает. А так же RPC позволяет нам выбрать удобную для нас технологическую реализацию. Начиная от gRPC - созданного для быстрого и надёжного межсервисного взаимодействия и заканчивая Hono RPC построенном на жёсткой типизации, на примере которого я бы хотел показать простейшую реализацию SSOT.

Реализация согласованного API на Hono RPC

Hono - лёгкий js фреймворк для написания бэка. Поддерживает Type Script, а так же из коробки предоставляет удобную модель RPC. Его очень удобно использовать в монорепе, поэтому развернём моно репозиторий на два пакета - client и server. Объявим workspace-ы в корневом package.json, а так же создадим папки client и server. В них так же отредактируем package.json, дабы объявить их отдельными пакетами. И добавим серверный пакет в зависимости клиентского package.json. Не забудем и отредактировать корневой tsconfig.json, а затем сделаем extends в конфиги пакетов.

Package.json

Корневой 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

Корневой 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, неразрывно связанный с беком и дающий полное представление о типах который мы получим в ответ. И если вдруг Вася решит что-то изменить в последний момент, то сборка упадет, а мы увидим в каком месте и почему!

 Пример использования и типизации ответов
Пример использования и типизации ответов
Добавление OpenAPI клиента

Установим:

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",
	}),
);
Handler для более удобного взаимодействия с клиентом
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"}});

Это мой первый опыт написания статей на Хабре - буду рад критике и дополнениям в комментариях. На этом у меня все, спасибо что прочитали!