Один контракт через HTTP, GraphQL, tRPC и gRPC
Отделим транспорт от поведения: публикация имеет положительный целый ID, непустой title и version. Чтение отсутствующей записи даёт NOT_FOUND. Запись разрешена редактору и требует совпадения версии, иначе CONFLICT. Транспортный тип не доказывает этих инвариантов. Лаборатория держит данные в памяти; перезапуск сбрасывает их и не подтверждает durable-идемпотентность.
Среда и общий сервис
В отдельном временном каталоге вне workspace выполните:
pnpm add --save-exact --ignore-scripts graphql@17.0.2 @grpc/grpc-js@1.14.5 @grpc/proto-loader@0.8.1 @trpc/server@11.19.0 zod@4.6.5
Сохраните следующие JS-блоки по порядку в api.mjs, proto — в lesson.proto
рядом. Запустите node api.mjs. HTTP и gRPC слушают случайные loopback-порты
и закрываются после проверки; GraphQL и tRPC вызываются внутри процесса.
Это проверка серверных контрактов, не браузерных клиентов четырёх протоколов.
import assert from "node:assert/strict";
import { createServer, request } from "node:http";
import { buildSchema, graphql, GraphQLError } from "graphql";
import grpc from "@grpc/grpc-js";
import loader from "@grpc/proto-loader";
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
const MAX_INT32 = 2 ** 31 - 1;
const idSchema = z.number().int().positive().max(MAX_INT32);
const changeSchema = z.object({
id: idSchema,
title: z.string().trim().min(1).max(120),
version: idSchema,
});
function fault(code) {
throw Object.assign(new Error(code), { code });
}
function service() {
let row = { id: 1, title: "Урок", version: 1 };
return {
read(id) {
if (!idSchema.safeParse(id).success) fault("BAD_REQUEST");
if (id !== row.id) fault("NOT_FOUND");
return { ...row };
},
update(input, role) {
if (role !== "editor") fault("FORBIDDEN");
const parsed = changeSchema.safeParse(input);
if (!parsed.success) fault("BAD_REQUEST");
const next = parsed.data;
this.read(next.id);
if (next.version !== row.version || row.version === MAX_INT32) fault("CONFLICT");
row = { id: row.id, title: next.title, version: row.version + 1 };
return { ...row };
},
};
}
ID и версия имеют общий диапазон 1..2147483647: это пересечение выбранных
GraphQL Int и Protobuf int32. При исчерпании версии обновление отклоняется.
Перед сериализацией gRPC проверяем вход: int32 может усечь большое число до
другого существующего ID. Серверная валидация после усечения этого уже не заметит.
HTTP собирает байты до декодирования UTF-8: граница chunk может проходить внутри
кириллической буквы. Лимит тела измеряется в байтах.
HTTP API
Используем GET /publications/1 и условный PUT. Сильный ETag соответствует
версии представления; If-Match защищает от потерянного обновления.
Роль в примере задаётся сервером при создании adapter. Заголовок с ролью
от клиента не был бы аутентификацией.
const status = { BAD_REQUEST: 400, NOT_FOUND: 404, FORBIDDEN: 403, CONFLICT: 412 };
async function httpAdapter(role) {
const model = service();
const server = createServer(async (req, res) => {
try {
const match = /^\/publications\/(\d+)$/.exec(req.url ?? "");
if (!match) fault("NOT_FOUND");
const id = Number(match[1]);
let result;
if (req.method === "GET") result = model.read(id);
else if (req.method === "PUT") {
if (role !== "editor") fault("FORBIDDEN");
if (!req.headers["if-match"]) {
res.writeHead(428);
res.end();
return;
}
const chunks = [];
let bytes = 0;
for await (const chunk of req) {
bytes += chunk.length;
if (bytes > 4096) fault("BAD_REQUEST");
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString("utf8");
let body;
try {
body = JSON.parse(raw);
} catch {
fault("BAD_REQUEST");
}
const version = Number(/^"v(\d+)"$/.exec(req.headers["if-match"])?.[1]);
if (body === null || typeof body !== "object" || Array.isArray(body)) fault("BAD_REQUEST");
result = model.update({ id, title: body.title, version }, role);
} else {
res.writeHead(405, { Allow: "GET, PUT" });
res.end();
return;
}
res.writeHead(200, {
"Content-Type": "application/json",
ETag: `"v${result.version}"`,
"Cache-Control": "no-store",
});
res.end(JSON.stringify(result));
} catch (error) {
res.writeHead(status[error.code] ?? 500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: error.code ?? "INTERNAL" }));
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${server.address().port}/publications/`;
async function call(id, input) {
const parsed = input ? changeSchema.safeParse(input) : idSchema.safeParse(id);
if (!parsed.success) fault("BAD_REQUEST");
const response = await fetch(
url + id,
input
? {
method: "PUT",
headers: { "Content-Type": "application/json", "If-Match": `"v${input.version}"` },
body: JSON.stringify({ title: input.title }),
}
: undefined,
);
const result = await response.json();
if (!response.ok) fault(result.code);
return result;
}
return {
url,
read: (id) => call(id),
update: (input) => call(input.id, input),
close: () => new Promise((resolve) => server.close(resolve)),
};
}
Для production дополнительно нужны проверка Content-Type, timeout/аборт, нормализованные ошибки, auth и лимиты соединений. Пример ограничивает тело, но не претендует на готовый web framework и не внедряется в Atmanki.
GraphQL: выбранные поля и ошибки
const schema = buildSchema(`
type Publication { id: Int!, title: String!, version: Int! }
type Query { publication(id: Int!): Publication! }
type Mutation { update(id: Int!, title: String!, version: Int!): Publication! }
`);
function graphqlAdapter(role) {
const model = service();
const wrap = (run) => (args) => {
try {
return run(args);
} catch (error) {
throw new GraphQLError(error.message, { extensions: { code: error.code } });
}
};
const rootValue = {
publication: wrap((args) => model.read(args.id)),
update: wrap((args) => model.update(args, role)),
};
async function call(source, variableValues) {
const result = await graphql({ schema, source, rootValue, variableValues });
if (result.errors) fault(result.errors[0].extensions.code ?? "BAD_REQUEST");
return Object.values(result.data)[0];
}
return {
read: (id) => call("query($id:Int!){publication(id:$id){id title version}}", { id }),
update: (input) =>
call(
"mutation($id:Int!,$title:String!,$version:Int!){update(id:$id,title:$title,version:$version){id title version}}",
input,
),
close: async () => {},
};
}
GraphQL error находится в errors, не в HTTP status этого внутрипроцессного вызова. Добавьте nullable поле и resolver с ошибкой: появление частичных data зависит от non-null границ. Для списка запросов нужны предел стоимости и batch-загрузка связей; выбор полей сам по себе не устраняет N+1.
tRPC: процедура и caller
const t = initTRPC.create();
function trpcAdapter(role) {
const model = service();
function run(fn) {
try {
return fn();
} catch (error) {
throw new TRPCError({ code: error.code, cause: error });
}
}
const router = t.router({
read: t.procedure.input(idSchema).query(({ input }) => run(() => model.read(input))),
update: t.procedure
.input(changeSchema)
.mutation(({ input }) => run(() => model.update(input, role))),
});
const caller = router.createCaller({});
return { read: caller.read, update: caller.update, close: async () => {} };
}
В JS-проверке виден runtime-контракт. Для TypeScript-клиента экспортируют
type AppRouter = typeof router, используют tRPC client и отдельно проверяют
компиляцию. Caller не выполняет HTTP и не проверяет маршрутизацию/headers.
Их реальные примеры уже есть в API проекта.
gRPC и Protobuf
syntax = "proto3";
package lesson;
message ReadRequest { int32 id = 1; }
message UpdateRequest { int32 id = 1; string title = 2; int32 version = 3; }
message Publication { int32 id = 1; string title = 2; int32 version = 3; }
service Publications {
rpc Read(ReadRequest) returns (Publication);
rpc Update(UpdateRequest) returns (Publication);
}
async function grpcAdapter(role) {
const model = service();
const definition = loader.loadSync(new URL("./lesson.proto", import.meta.url).pathname);
const { Publications } = grpc.loadPackageDefinition(definition).lesson;
const codes = {
BAD_REQUEST: grpc.status.INVALID_ARGUMENT,
NOT_FOUND: grpc.status.NOT_FOUND,
FORBIDDEN: grpc.status.PERMISSION_DENIED,
CONFLICT: grpc.status.ABORTED,
};
const server = new grpc.Server();
const wrap = (run) => (call, callback) => {
try {
callback(null, run(call.request));
} catch (error) {
callback({ code: codes[error.code] ?? grpc.status.INTERNAL, details: error.message });
}
};
server.addService(Publications.service, {
read: wrap((input) => model.read(input.id)),
update: wrap((input) => model.update(input, role)),
});
const port = await new Promise((resolve, reject) =>
server.bindAsync("127.0.0.1:0", grpc.ServerCredentials.createInsecure(), (error, value) =>
error ? reject(error) : resolve(value),
),
);
const client = new Publications(`127.0.0.1:${port}`, grpc.credentials.createInsecure());
const reverse = Object.fromEntries(Object.entries(codes).map(([key, value]) => [value, key]));
async function call(method, input) {
const parsed = method === "read" ? idSchema.safeParse(input.id) : changeSchema.safeParse(input);
if (!parsed.success) fault("BAD_REQUEST");
return new Promise((resolve, reject) =>
client[method](input, { deadline: new Date(Date.now() + 5000) }, (error, result) => {
if (error) reject(Object.assign(error, { code: reverse[error.code] ?? "TRANSPORT" }));
else resolve(result);
}),
);
}
return {
read: (id) => call("read", { id }),
update: (input) => call("update", input),
close: async () => {
client.close();
await new Promise((resolve) => server.tryShutdown(resolve));
},
};
}
Без TLS этот вариант допустим только для loopback-опыта. Unary имеет один ответ;
server/client/bidirectional streaming требуют отдельной модели backpressure,
отмены и завершения. Deadline ограничивает ожидание, не откатывает совершённый
эффект. Для браузера обычный gRPC-клиент не подходит: нужен совместимый gateway
или gRPC-Web. Номера удалённых Protobuf-полей резервируют; int32 отсутствует
как присутствие значения по умолчанию, для него при необходимости используют
optional. Формат сообщения не проверяет положительность ID.
Одна приёмка четырёх adapters
for (const factory of [httpAdapter, graphqlAdapter, trpcAdapter, grpcAdapter]) {
const api = await factory("editor");
try {
assert.deepEqual({ ...(await api.read(1)) }, { id: 1, title: "Урок", version: 1 });
for (const [id, code] of [
[0, "BAD_REQUEST"],
[1.5, "BAD_REQUEST"],
[2147483648, "BAD_REQUEST"],
[4294967297, "BAD_REQUEST"],
[2, "NOT_FOUND"],
]) {
await assert.rejects(
() => api.read(id),
(error) => error.code === code,
);
}
const input = { id: 1, title: "Новый урок", version: 1 };
assert.equal((await api.update(input)).version, 2);
await assert.rejects(
() => api.update(input),
(error) => error.code === "CONFLICT",
);
assert.equal((await api.read(1)).title, "Новый урок");
for (const invalid of [
{ id: 1, title: "", version: 2 },
{ id: 1, title: "Правка", version: 0 },
{ id: 4294967297, title: "Правка", version: 2 },
{ id: 1, title: "Правка", version: 4294967298 },
{ id: 1, title: "Правка", version: 2.5 },
]) {
await assert.rejects(
() => api.update(invalid),
(error) => error.code === "BAD_REQUEST",
);
}
assert.deepEqual({ ...(await api.read(1)) }, { id: 1, title: "Новый урок", version: 2 });
} finally {
await api.close();
}
const reader = await factory("reader");
try {
await assert.rejects(
() => reader.update({ id: 1, title: "Запрет", version: 1 }),
(error) => error.code === "FORBIDDEN",
);
} finally {
await reader.close();
}
console.log(factory.name, "passed");
}
Отдельно проверим HTTP на границе UTF-8. Сетевые chunks не обязаны совпадать с границами символов; отправляем две части с разрывом внутри «Я».
const utf8Api = await httpAdapter("editor");
try {
const body = Buffer.from(JSON.stringify({ title: "Я" }));
const split = body.indexOf(0xd0) + 1;
const result = await new Promise((resolve, reject) => {
const req = request(
utf8Api.url + "1",
{
method: "PUT",
headers: { "Content-Type": "application/json", "If-Match": '\"v1\"' },
},
async (res) => {
try {
const chunks = [];
for await (const chunk of res) chunks.push(chunk);
assert.equal(res.statusCode, 200);
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (error) {
reject(error);
}
},
);
req.on("error", reject);
req.write(body.subarray(0, split));
setTimeout(() => req.end(body.subarray(split)), 100);
});
assert.deepEqual(result, { id: 1, title: "Я", version: 2 });
} finally {
await utf8Api.close();
}
Повтор PUT после потерянного ответа даёт конфликт версии, но запись остаётся в желаемом состоянии. Это отличается от возврата сохранённого результата по Idempotency-Key. Для последнего нужна транзакционная лаборатория.
Углубление: добавьте list с одинаковым курсором во все adapters, проверьте неверный title и отсутствие version, сравните протокольные ошибки до вызова сервиса. Сохраните native GraphQL errors и gRPC status, а не только нормализованный код приёмки. Выпишите старый/новый клиент × старый/новый сервер для добавленного поля. Совпадение этой проверки не означает совпадение транспорта, кешируемости или стоимости эксплуатации.
Источники: GraphQL schema, gRPC Node, Protobuf, tRPC.