Учебник веб-разработки
Разделы учебника
На этой странице

VII. HTTP и API

Один контракт через HTTP, GraphQL, tRPC и gRPC

Оглавление · Сравнение API

Отделим транспорт от поведения: публикация имеет положительный целый 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.