Привет!
Представим ситуацию: идет тяжёлый спринт, вы выполнили кучу задач, написали тонну нового функционала, готовитесь к релизу и вдруг обнаруживайте, что часть фич перестала работать! Идёте разбираться и обнаруживайте, что оказывается бэкендер Вася в последний момент решил переименовать поля в 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 фалика - сгенерируют вам веб страницу со всеми методами вашего приложения. И позволят протестировать их прямо из браузера, никуда не уходя. К тому же, в большинстве, они встраиваются в бэк написанием всего пары строчек кода.
Перед тем как перейти к следующему блоку - подчеркну важность того, что бы начинать разработку когда спецификация уже готова. Ведь для большинства 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"}});
Это мой первый опыт написания статей на Хабре - буду рад критике и дополнениям в комментариях. На этом у меня все, спасибо что прочитали!
Автор: Aw1nger
