GraphQL-сервер состоит из схемы и резолверов. Схема на языке SDL описывает типы, точки входа Query и Mutation и аргументы, а резолверы — функции, которые возвращают значения полей из базы или других сервисов. Клиент отправляет POST /graphql с текстом операции и переменными и получает JSON той же формы. Чтобы сервер выдержал произвольные запросы, связи загружают пакетами через DataLoader, а глубину и стоимость запросов ограничивают до выполнения.
После материала вы сможете
- описать схему SDL с типами,
Query,Mutationи input-типом и поднять по ней сервер на GraphQL Yoga - написать резолверы с аргументами и контекстом и выполнить запрос и мутацию с переменными через curl и
fetch - найти N+1 по счётчику запросов к базе и устранить его через DataLoader
- вернуть ожидаемую ошибку с
extensions.code, скрыть неожиданную и ограничить глубину запроса
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)GraphQL Yoga 5.24.4graphql-js 16.14.2DataLoader 2.2.3@escape.tech/graphql-armor-max-depth 2.4.2node:test
Сверено с документацией: 11 октября 2026 г.
Содержание
Схема как контракт: SDL
В сравнении REST и GraphQL мы собрали карточку услуги одним запросом. Теперь построим этот сервер с нуля: каталог услуг студии с категориями и отзывами. Данные лежат в памяти, но каждый метод «базы» печатает SQL, который выполнила бы настоящая СУБД, — так видно, сколько запросов стоит каждая операция. Сервер — GraphQL Yoga, тесты — встроенный в Node.js node:test.
mkdir catalog-graphql && cd catalog-graphql
npm init -y
npm pkg set type=module scripts.start="node src/server.js" scripts.test="node --test"
npm pkg delete main keywords author license description
npm i graphql-yoga@5.24.4 graphql@16.14.2 dataloader@2.2.3 @escape.tech/graphql-armor-max-depth@2.4.2{
"name": "catalog-graphql",
"version": "1.0.0",
"scripts": {
"test": "node --test",
"start": "node src/server.js"
},
"type": "module",
"dependencies": {
"@escape.tech/graphql-armor-max-depth": "^2.4.2",
"dataloader": "^2.2.3",
"graphql": "^16.14.2",
"graphql-yoga": "^5.24.4"
}
}Схема — контракт между сервером и клиентами: какие есть типы, поля и операции. Её пишут на SDL (schema definition language) — языке, похожем на сами запросы. Объектный тип (type) перечисляет поля и их типы; Query — точка входа для чтения, Mutation — для изменений. Строки в кавычках перед полем — описания: они попадают в документацию и подсказки редактора.
export const typeDefs = /* GraphQL */ `
"Услуга из каталога"
type Service {
id: ID!
title: String!
category: Category!
reviews: [Review!]!
}
type Category {
id: ID!
name: String!
services: [Service!]!
}
type Review {
id: ID!
"Оценка от 1 до 5"
rating: Int!
text: String!
}
type Query {
"Список услуг; first — сколько вернуть (1–50)"
services(categoryId: ID, first: Int = 10): [Service!]!
"Одна услуга или null, если такой нет"
service(id: ID!): Service
categories: [Category!]!
}
input AddReviewInput {
serviceId: ID!
rating: Int!
text: String!
}
type Mutation {
addReview(input: AddReviewInput!): Review!
}
`;Int, Float, String, Boolean, ID
- Что значит
- Встроенные скаляры.
IDсериализуется как строка, но означает идентификатор, а не текст для человека - Пример из схемы
id: ID!
T!
- Что значит
- Non-null: значение обязательно
- Пример из схемы
title: String!
T без !
- Что значит
- Может быть
null - Пример из схемы
service(id: ID!): Service—null, если услуги нет
[T!]!
- Что значит
- Список не
nullи безnullвнутри - Пример из схемы
reviews: [Review!]!— пустой список, а неnull
arg: T = значение
- Что значит
- Аргумент со значением по умолчанию
- Пример из схемы
first: Int = 10
input
- Что значит
- Тип для входных данных: поля без аргументов, нельзя смешивать с выходными типами
- Пример из схемы
AddReviewInput
Обратите внимание на цикл: у категории есть услуги, у услуги — категория. Это удобно клиенту, но позволяет строить сколь угодно глубокие запросы; к этому вернёмся в разделе о безопасности.
Query, Mutation и переменные
Сервер собирают из схемы и резолверов. createSchema из Yoga связывает SDL с функциями, createYoga превращает схему в обработчик HTTP на адресе /graphql, а функция context создаёт для каждого запроса общий объект, который получат все резолверы: доступ к базе и набор загрузчиков. Сами резолверы и загрузчики разберём в следующих разделах.
import { createSchema, createYoga } from "graphql-yoga";
import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth";
import { typeDefs } from "./typeDefs.js";
import { resolvers as defaultResolvers } from "./resolvers.js";
import { createLoaders } from "./loaders.js";
export function buildYoga({ db, resolvers = defaultResolvers, maxDepth = 5 }) {
return createYoga({
schema: createSchema({ typeDefs, resolvers }),
// контекст создаётся заново на каждый запрос — вместе с загрузчиками
context: () => ({ db, loaders: createLoaders(db) }),
plugins: [maxDepthPlugin({ n: maxDepth })],
});
}import { createServer } from "node:http";
import { createDb } from "./db.js";
import { buildYoga } from "./yoga.js";
const port = Number(process.env.PORT ?? 4400);
const db = createDb({ onQuery: (sql, params) => console.log("[db]", sql, JSON.stringify(params)) });
const yoga = buildYoga({ db });
const server = createServer(yoga);
server.listen(port, () => {
console.log(`GraphQL: http://localhost:${port}${yoga.graphqlEndpoint}`);
});Запустите npm start и отправьте первый запрос. Сокращённая запись { … } — анонимная операция query:
$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d '{"query":"{ services(first: 3) { title category { name } } }"}'
{"data":{"services":[{"title":"Корпоративный сайт","category":{"name":"Веб-разработка"}},{"title":"Интернет-магазин","category":{"name":"Веб-разработка"}},{"title":"Приложение на Flutter","category":{"name":"Мобильные приложения"}}]}}$ npm start
> catalog-graphql@1.0.0 start
> node src/server.js
GraphQL: http://localhost:4400/graphql
[db] SELECT * FROM services WHERE ($1::text IS NULL OR category_id = $1) ORDER BY id LIMIT $2 [null,3]
[db] SELECT * FROM categories WHERE id = ANY($1) [["c1","c2"]]Значения из интерфейса — id категории, текст отзыва — нельзя вклеивать в строку запроса: graphql.org прямо запрещает строить запросы интерполяцией пользовательских данных. Для этого есть переменные: в операции объявляют $имя с типом, а значения передают отдельным объектом variables. Сервер проверит типы до выполнения. Операции стоит давать имя (ByCategory, AddReview) — по нему проще искать запрос в логах.
{
"query": "query ByCategory($cat: ID!, $n: Int) { services(categoryId: $cat, first: $n) { id title reviews { rating text } } }",
"variables": { "cat": "c3", "n": 5 }
}$cat обязателен (ID!), $n — нет{
"query": "mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id rating text } }",
"variables": { "input": { "serviceId": "s5", "rating": 5, "text": "Остатки в 1С и на сайте совпадают" } }
}$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d @requests/by-category.json
{"data":{"services":[{"id":"s4","title":"Telegram-бот","reviews":[{"rating":4,"text":"Бот принимает заявки"}]},{"id":"s5","title":"Обмен с 1С","reviews":[]}]}}
$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d @requests/add-review.json
{"data":{"addReview":{"id":"r3","rating":5,"text":"Остатки в 1С и на сайте совпадают"}}}
$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d @requests/bad-rating.json
{"errors":[{"message":"rating должен быть от 1 до 5","locations":[{"line":1,"column":47}],"path":["addReview"],"extensions":{"code":"BAD_USER_INPUT"}}],"data":null}[db] SELECT * FROM services WHERE ($1::text IS NULL OR category_id = $1) ORDER BY id LIMIT $2 ["c3",5]
[db] SELECT * FROM reviews WHERE service_id = ANY($1) ORDER BY id [["s4","s5"]]
[db] SELECT * FROM services WHERE id = $1 ["s5"]
[db] INSERT INTO reviews (service_id, rating, text) VALUES ($1, $2, $3) RETURNING * ["s5",5,"Остатки в 1С и на сайте совпадают"]Поля Query могут выполняться параллельно, а поля верхнего уровня Mutation — строго по очереди: так требует спецификация, потому что мутации меняют данные. Последовательность — не транзакция: если в одной операции две мутации и вторая упала, первая уже выполнена. Поэтому мутацию обычно делают одним осмысленным действием и возвращают изменённый объект, чтобы клиент сразу обновил экран.
Yoga принимает и GET — его удобно кэшировать, — но только для чтения. Мутация через GET получает 405 с заголовком Allow: POST, как требует спецификация GraphQL over HTTP:
$ curl -s "http://localhost:4400/graphql?query=%7Bcategories%7Bname%7D%7D"
{"data":{"categories":[{"name":"Веб-разработка"},{"name":"Мобильные приложения"},{"name":"Интеграции"}]}}
$ curl -si "http://localhost:4400/graphql?query=mutation%7BaddReview(input:%7BserviceId:%22s1%22,rating:5,text:%22x%22%7D)%7Bid%7D%7D"
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json; charset=utf-8
Allow: POST
{"errors":[{"message":"Can only perform a mutation operation from a POST request.","extensions":{"code":"BAD_REQUEST"}}]}Резолверы
Резолвер — функция, которая возвращает значение одного поля. graphql-js вызывает её с четырьмя аргументами: родительский объект (для полей Query и Mutation не используется), аргументы поля, контекст запроса и info — сведения о текущем поле и схеме для продвинутых случаев. Резолвер может вернуть значение, промис или выбросить ошибку.
import { GraphQLError } from "graphql";
function badInput(message) {
return new GraphQLError(message, { extensions: { code: "BAD_USER_INPUT" } });
}
export const resolvers = {
Query: {
services: (_parent, { categoryId, first }, { db }) => {
if (first < 1 || first > 50) throw badInput("first должен быть от 1 до 50");
return db.listServices({ categoryId, limit: first });
},
service: (_parent, { id }, { db }) => db.findService(id),
categories: (_parent, _args, { db }) => db.listCategories(),
},
Mutation: {
addReview: async (_parent, { input }, { db }) => {
const text = input.text.trim();
if (input.rating < 1 || input.rating > 5) throw badInput("rating должен быть от 1 до 5");
if (text.length === 0 || text.length > 1000) throw badInput("text: от 1 до 1000 символов");
const service = await db.findService(input.serviceId);
if (!service) {
throw new GraphQLError(`Услуга ${input.serviceId} не найдена`, {
extensions: { code: "NOT_FOUND" },
});
}
return db.insertReview({ serviceId: service.id, rating: input.rating, text });
},
},
// поля-связи: родительский объект приходит первым аргументом
Service: {
category: (service, _args, { loaders }) => loaders.categoryById.load(service.categoryId),
reviews: (service, _args, { loaders }) => loaders.reviewsByServiceId.load(service.id),
},
Category: {
services: (category, _args, { loaders }) => loaders.servicesByCategoryId.load(category.id),
},
};Корневые поля
Query.servicesполучает аргументыcategoryIdиfirst(если клиент не передалfirst, сработает значение по умолчанию 10) и идёт в базу черезcontext.db.Поля по умолчанию
Для
Service.titleилиReview.ratingрезолвера нет: Yoga, как и большинство библиотек, читает одноимённое свойство объекта. Писатьtitle: (s) => s.titleне нужно.Поля-связи
Service.categoryполучает услугу первым аргументом и по еёcategoryIdзагружает категорию. Так GraphQL проходит по графу: каждый уровень вложенности — новый вызов резолверов для каждого объекта.Мутация
addReviewсначала проверяет бизнес-правила (оценка от 1 до 5, длина текста, существование услуги), а потом пишет. Типы GraphQL гарантируют, чтоrating— целое число, но диапазон проверяет только ваш код.
Ошибки в GraphQL
Ответ GraphQL — JSON с ключами data и errors. Ошибки запроса — синтаксис, неизвестное поле, переменная не того типа — находятся до выполнения: ключа data нет. Ошибки выполнения выбрасывают резолверы: остальные поля выполняются, а в errors приходят сообщение, path до поля и extensions.
- Ожидаемую ошибку выбрасывайте как
GraphQLErrorс кодом вextensions.code—BAD_USER_INPUT,NOT_FOUND. Клиент ветвится по коду, а не по тексту сообщения. - «Не найдено» для одиночного объекта часто выражают через
null, а не ошибку:service(id: "s404")вернёт{ "service": null }. Для мутации, где отсутствие объекта — ошибка, —NOT_FOUND. - Если ошибка случилась в non-null поле,
nullподнимается к ближайшему родителю, который может бытьnull. В выводе вышеaddReview: Review!упал, и весьdataсталnull. - Любую другую ошибку — упавшую базу, опечатку в коде — Yoga маскирует: клиент видит
Unexpected error.с кодомINTERNAL_SERVER_ERROR, а подробности пишутся в лог сервера. Это видно в выводе тестов ниже.
$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:4400/graphql -H "content-type: application/json" -H "accept: application/json" -d @requests/typo.json
{"errors":[{"message":"Cannot query field \"servise\" on type \"Query\". Did you mean \"service\" or \"services\"?","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
HTTP 200
$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:4400/graphql -H "content-type: application/json" -H "accept: application/graphql-response+json" -d @requests/typo.json
{"errors":[{"message":"Cannot query field \"servise\" on type \"Query\". Did you mean \"service\" or \"services\"?","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
HTTP 400Клиенту важно правило: проверять ключ errors в теле, а не только HTTP-статус. Ответ с ошибкой выполнения приходит со статусом 200 — подробно о статусах и черновике спецификации GraphQL over HTTP рассказано в сравнении REST и GraphQL.
N+1 и DataLoader
Резолвер поля вызывается для каждого объекта отдельно. Если Service.category ходит в базу напрямую, список из пяти услуг даст один запрос за списком и пять — за категориями. Добавьте отзывы — ещё пять. Это проблема N+1: число запросов растёт вместе с длиной списка. «База» учебного проекта считает запросы — вот она целиком:
// «База данных» в памяти. Каждый метод — один запрос: onQuery получает
// SQL, который выполнила бы настоящая СУБД, — по этим вызовам считаем запросы.
export function createDb({ onQuery = () => {} } = {}) {
const categories = [
{ id: "c1", name: "Веб-разработка" },
{ id: "c2", name: "Мобильные приложения" },
{ id: "c3", name: "Интеграции" },
];
const services = [
{ id: "s1", title: "Корпоративный сайт", categoryId: "c1" },
{ id: "s2", title: "Интернет-магазин", categoryId: "c1" },
{ id: "s3", title: "Приложение на Flutter", categoryId: "c2" },
{ id: "s4", title: "Telegram-бот", categoryId: "c3" },
{ id: "s5", title: "Обмен с 1С", categoryId: "c3" },
];
const reviews = [
{ id: "r1", serviceId: "s1", rating: 5, text: "Сайт сдали в срок" },
{ id: "r2", serviceId: "s4", rating: 4, text: "Бот принимает заявки" },
];
async function query(sql, params, run) {
onQuery(sql, params);
return run();
}
return {
listCategories: () =>
query("SELECT * FROM categories ORDER BY id", [], () => [...categories]),
listServices: ({ categoryId, limit }) =>
query(
"SELECT * FROM services WHERE ($1::text IS NULL OR category_id = $1) ORDER BY id LIMIT $2",
[categoryId ?? null, limit],
() => services.filter((s) => !categoryId || s.categoryId === categoryId).slice(0, limit),
),
findService: (id) =>
query("SELECT * FROM services WHERE id = $1", [id], () => services.find((s) => s.id === id) ?? null),
findCategory: (id) =>
query("SELECT * FROM categories WHERE id = $1", [id], () => categories.find((c) => c.id === id) ?? null),
// пакетные запросы для DataLoader: один запрос на много ключей
categoriesByIds: (ids) =>
query("SELECT * FROM categories WHERE id = ANY($1)", [ids], () =>
categories.filter((c) => ids.includes(c.id)),
),
servicesByCategoryIds: (ids) =>
query("SELECT * FROM services WHERE category_id = ANY($1) ORDER BY id", [ids], () =>
services.filter((s) => ids.includes(s.categoryId)),
),
reviewsByServiceIds: (ids) =>
query("SELECT * FROM reviews WHERE service_id = ANY($1) ORDER BY id", [ids], () =>
reviews.filter((r) => ids.includes(r.serviceId)),
),
insertReview: ({ serviceId, rating, text }) =>
query("INSERT INTO reviews (service_id, rating, text) VALUES ($1, $2, $3) RETURNING *", [serviceId, rating, text], () => {
const review = { id: `r${reviews.length + 1}`, serviceId, rating, text };
reviews.push(review);
return review;
}),
};
}= ANY($1), каждый вызов сообщается в onQueryconst naive = {
...resolvers,
Service: {
category: (service, _args, { db }) => db.findCategory(service.categoryId),
reviews: async (service, _args, { db }) => db.reviewsByServiceIds([service.id]),
},
};DataLoader решает это пакетной загрузкой. Резолверы вызывают loader.load(id), а DataLoader собирает все вызовы, сделанные в одном такте выполнения, и один раз вызывает пакетную функцию со списком ключей. Внутри — один запрос WHERE id = ANY($1).
import DataLoader from "dataloader";
// Пакетная функция обязана вернуть массив той же длины и в том же порядке,
// что и keys: базе порядок не важен, поэтому раскладываем строки по ключам.
function byKey(keys, rows, key) {
const map = new Map(rows.map((row) => [row[key], row]));
return keys.map((k) => map.get(k) ?? new Error(`Нет записи ${k}`));
}
function groupByKey(keys, rows, key) {
const groups = new Map(keys.map((k) => [k, []]));
for (const row of rows) groups.get(row[key])?.push(row);
return keys.map((k) => groups.get(k));
}
// Новый набор загрузчиков на каждый HTTP-запрос: кэш DataLoader живёт
// только в пределах запроса и не смешивает данные разных пользователей.
export function createLoaders(db) {
return {
categoryById: new DataLoader(async (ids) => byKey(ids, await db.categoriesByIds(ids), "id")),
servicesByCategoryId: new DataLoader(async (ids) =>
groupByKey(ids, await db.servicesByCategoryIds(ids), "categoryId"),
),
reviewsByServiceId: new DataLoader(async (ids) =>
groupByKey(ids, await db.reviewsByServiceIds(ids), "serviceId"),
),
};
}Пакетная функция возвращает массив той же длины и в том же порядке, что keys
- Почему
- Иначе категория одной услуги достанется другой. База порядок не гарантирует — раскладываем строки по ключам через
Map
Для отсутствующего ключа — экземпляр Error, а не исключение
- Почему
- Ошибка достанется только тому
load, который просил этот ключ; остальные получат данные
Новые загрузчики на каждый запрос
- Почему
- У DataLoader есть кэш: повторный
loadтого же ключа не идёт в базу. Общий кэш на всех вернул бы устаревшие данные или чужие объекты
DataLoader — не замена Redis
- Почему
- Его кэш живёт в пределах запроса; общий кэш приложения — отдельная задача
Результат проверен тестами: тот же запрос «пять услуг с категориями и отзывами» с загрузчиками делает 3 запроса вместо 11 — список, один пакет категорий и один пакет отзывов. Число запросов больше не зависит от длины списка. Если пакетный запрос всё равно медленный, смотрите план запроса и индексы — это тема урока про EXPLAIN.
Безопасность: глубина и сложность запросов
Запрос составляет клиент, поэтому сервер должен отклонять слишком дорогие запросы до выполнения. Цикл «категория → услуги → категория» позволяет вложить поля сколько угодно раз. Документация Yoga для публичных API советует плагины GraphQL Armor; мы подключили лимит глубины maxDepthPlugin({ n: 5 }) — он работает на этапе валидации, до резолверов.
$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:4400/graphql -H "content-type: application/json" -d @requests/deep.json
{"errors":[{"message":"Syntax Error: Query depth limit of 5 exceeded, found 6."}]}
HTTP 200Обратите внимание: плагин отвечает статусом 200 и без data, а текст начинается с Syntax Error — так его формулирует версия 2.4.2. Клиенту это не мешает, если он проверяет errors. Ограничение глубины — только одна мера. Остальные из руководства graphql.org по безопасности:
Лимит размера списков
- Как сделать в Yoga
- Аргумент
firstс проверкой, как вQuery.services; для больших списков — курсорная пагинация
Лимит алиасов и числа токенов
- Как сделать в Yoga
- Плагины
@escape.tech/graphql-armor-max-aliasesи-max-tokens
Анализ стоимости
- Как сделать в Yoga
- Плагин
@escape.tech/graphql-armor-cost-limit: у каждого поля вес, запрос дороже порога отклоняется
Только известные операции
- Как сделать в Yoga
- Persisted operations — для API только своих клиентов: сервер выполняет операции по хэшу из списка
Права на уровне полей
- Как сделать в Yoga
- Проверка пользователя из контекста в резолверах или бизнес-логике
Клиент и тесты
Для запроса из фронтенда не обязательна библиотека: GraphQL — это POST с JSON. Минимальный клиент на fetch отправляет операцию и переменные, сообщает, что понимает application/graphql-response+json, и превращает errors в исключение. В браузере код тот же, если сервер разрешает CORS для вашего сайта; клиентские GraphQL-библиотеки добавляют к этому кэш и интеграцию с фреймворками.
// Минимальный клиент: тот же код работает в Node.js 18+ и в браузере
// (как <script type="module">, если сервер разрешает CORS для вашего сайта).
const ENDPOINT = "http://localhost:4400/graphql";
export async function gql(query, variables = {}) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/graphql-response+json, application/json",
},
body: JSON.stringify({ query, variables }),
});
const body = await res.json();
// ошибки GraphQL приходят в теле — проверяем errors, а не только статус
if (body.errors?.length) {
const [first] = body.errors;
throw new Error(`${first.extensions?.code ?? "GRAPHQL_ERROR"}: ${first.message}`);
}
return body.data;
}
const SERVICES = /* GraphQL */ `
query ServicesByCategory($categoryId: ID) {
services(categoryId: $categoryId) {
id
title
reviews { rating }
}
}
`;
const data = await gql(SERVICES, { categoryId: "c3" });
for (const s of data.services) {
console.log(`${s.id} ${s.title} — отзывов: ${s.reviews.length}`);
}
try {
await gql(`query Many($n: Int) { services(first: $n) { id } }`, { n: 100 });
} catch (err) {
console.log("Ошибка:", err.message);
}$ node src/client.js
s4 Telegram-бот — отзывов: 1
s5 Обмен с 1С — отзывов: 1
Ошибка: BAD_USER_INPUT: first должен быть от 1 до 50Тесты не поднимают сеть: yoga.fetch обрабатывает запрос в памяти, как описано в документации Yoga. Счётчик запросов к «базе» превращает N+1 в проверяемое утверждение, а тест с подменённым методом базы проверяет, что детали ошибки не уходят клиенту.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createDb } from "../src/db.js";
import { createLoaders } from "../src/loaders.js";
import { resolvers } from "../src/resolvers.js";
import { buildYoga } from "../src/yoga.js";
// Тестовый стенд: своя «БД» со счётчиком запросов и сервер без сети —
// yoga.fetch обрабатывает запрос в памяти, порт не открывается.
function setup(options = {}) {
const queries = [];
const db = createDb({ onQuery: (sql) => queries.push(sql) });
const yoga = buildYoga({ db, ...options });
async function gql(query, variables) {
queries.length = 0;
const res = await yoga.fetch("http://yoga/graphql", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query, variables }),
});
return { status: res.status, body: await res.json() };
}
return { db, gql, queries };
}
const LIST = /* GraphQL */ `
query ServiceList {
services {
title
category { name }
reviews { rating }
}
}
`;
test("запрос с переменной возвращает только запрошенные поля", async () => {
const { gql } = setup();
const { status, body } = await gql(
`query ByCategory($cat: ID!) { services(categoryId: $cat) { id title } }`,
{ cat: "c3" },
);
assert.equal(status, 200);
assert.deepEqual(body, {
data: {
services: [
{ id: "s4", title: "Telegram-бот" },
{ id: "s5", title: "Обмен с 1С" },
],
},
});
});
test("DataLoader: 5 услуг с категориями и отзывами — 3 запроса к БД", async () => {
const { gql, queries } = setup();
const { body } = await gql(LIST);
assert.equal(body.data.services.length, 5);
assert.equal(body.data.services[3].category.name, "Интеграции");
assert.deepEqual(queries, [
"SELECT * FROM services WHERE ($1::text IS NULL OR category_id = $1) ORDER BY id LIMIT $2",
"SELECT * FROM categories WHERE id = ANY($1)",
"SELECT * FROM reviews WHERE service_id = ANY($1) ORDER BY id",
]);
});
test("без DataLoader тот же запрос даёт N+1: 1 + 5 + 5 = 11 запросов", async () => {
const naive = {
...resolvers,
Service: {
category: (service, _args, { db }) => db.findCategory(service.categoryId),
reviews: async (service, _args, { db }) => db.reviewsByServiceIds([service.id]),
},
};
const { gql, queries } = setup({ resolvers: naive });
const { body } = await gql(LIST);
assert.equal(body.data.services.length, 5);
assert.equal(queries.length, 11);
assert.equal(queries.filter((q) => q.startsWith("SELECT * FROM categories WHERE id = $1")).length, 5);
});
test("пакетная функция сохраняет порядок ключей и возвращает ошибку для отсутствующего", async () => {
const db = createDb();
const { categoryById } = createLoaders(db);
const result = await categoryById.loadMany(["c3", "c1", "zz"]);
assert.equal(result[0].name, "Интеграции");
assert.equal(result[1].name, "Веб-разработка");
assert.ok(result[2] instanceof Error);
});
test("несуществующая услуга — null без ошибки", async () => {
const { gql } = setup();
const { body } = await gql(`{ service(id: "s404") { title } }`);
assert.deepEqual(body, { data: { service: null } });
});
test("ошибка в non-null поле обнуляет родителя, в errors есть path и code", async () => {
const { gql, queries } = setup();
const { status, body } = await gql(`{ services(first: 100) { title } }`);
assert.equal(status, 200);
assert.equal(body.data, null);
assert.deepEqual(body.errors[0].path, ["services"]);
assert.equal(body.errors[0].extensions.code, "BAD_USER_INPUT");
assert.equal(queries.length, 0);
});
test("мутация добавляет отзыв, следующий запрос его видит", async () => {
const { gql } = setup();
const READ = `{ service(id: "s2") { reviews { id } } }`;
const before = await gql(READ);
assert.deepEqual(before.body.data.service.reviews, []);
const add = await gql(
`mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id rating text } }`,
{ input: { serviceId: "s2", rating: 5, text: " Каталог удобный " } },
);
assert.deepEqual(add.body.data.addReview, { id: "r3", rating: 5, text: "Каталог удобный" });
const read = await gql(READ); // новый запрос — новые загрузчики, без устаревшего кэша
assert.deepEqual(read.body.data.service.reviews, [{ id: "r3" }]);
});
test("мутация проверяет ввод и отклоняет неизвестную услугу без записи", async () => {
const { gql, queries } = setup();
const MUTATION = `mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id } }`;
const bad = await gql(MUTATION, { input: { serviceId: "s1", rating: 9, text: "ok" } });
assert.equal(bad.body.errors[0].extensions.code, "BAD_USER_INPUT");
const missing = await gql(MUTATION, { input: { serviceId: "s404", rating: 5, text: "ok" } });
assert.equal(missing.body.errors[0].extensions.code, "NOT_FOUND");
assert.ok(!queries.some((q) => q.startsWith("INSERT")));
});
test("переменная не того типа — 400 до выполнения", async () => {
const { gql, queries } = setup();
const { status, body } = await gql(
`mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id } }`,
{ input: { serviceId: "s1", rating: "5", text: "ok" } },
);
assert.equal(status, 400);
assert.match(body.errors[0].message, /Int cannot represent non-integer value/);
assert.equal(queries.length, 0);
});
test("слишком глубокий запрос отклоняется до обращения к БД", async () => {
const { gql, queries } = setup();
const { body } = await gql(
`{ categories { services { category { services { category { name } } } } } }`,
);
assert.equal(body.data, undefined);
assert.match(body.errors[0].message, /depth limit of 5 exceeded, found 6/);
assert.equal(queries.length, 0);
});
test("неожиданная ошибка маскируется: детали не уходят клиенту", async () => {
const db = createDb();
db.listCategories = async () => {
throw new Error("connect ECONNREFUSED 10.0.0.5:5432");
};
const yoga = buildYoga({ db });
const res = await yoga.fetch("http://yoga/graphql", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query: "{ categories { name } }" }),
});
const body = await res.json();
assert.equal(body.errors[0].message, "Unexpected error.");
assert.doesNotMatch(JSON.stringify(body), /ECONNREFUSED/);
});$ npm test
> catalog-graphql@1.0.0 test
> node --test
✔ запрос с переменной возвращает только запрошенные поля (27.7426ms)
ERR Error: connect ECONNREFUSED 10.0.0.5:5432
at db.listCategories (file:///…/catalog-graphql/test/api.test.js:153:11)
…
path: [ 'categories' ],
locations: [ { line: 1, column: 3 } ],
extensions: [Object: null prototype] {}
}
✔ DataLoader: 5 услуг с категориями и отзывами — 3 запроса к БД (3.5657ms)
✔ без DataLoader тот же запрос даёт N+1: 1 + 5 + 5 = 11 запросов (3.0164ms)
✔ пакетная функция сохраняет порядок ключей и возвращает ошибку для отсутствующего (0.2793ms)
✔ несуществующая услуга — null без ошибки (2.3158ms)
✔ ошибка в non-null поле обнуляет родителя, в errors есть path и code (2.8934ms)
✔ мутация добавляет отзыв, следующий запрос его видит (3.8273ms)
✔ мутация проверяет ввод и отклоняет неизвестную услугу без записи (2.982ms)
✔ переменная не того типа — 400 до выполнения (2.0594ms)
✔ слишком глубокий запрос отклоняется до обращения к БД (2.4009ms)
✔ неожиданная ошибка маскируется: детали не уходят клиенту (2.1038ms)
ℹ tests 11
ℹ suites 0
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 323.6923Тесты мы проверили обратной заменой: если создавать загрузчики один раз на весь сервер, падает тест «следующий запрос видит отзыв» — кэш возвращает устаревший пустой список; если пакетная функция возвращает строки в порядке базы, падает тест порядка ключей; без плагина глубины падает тест лимита.
Загрузчики созданы один раз при старте
- Симптом
- Устаревшие данные, чужие объекты в ответе
- Исправление
- Создавать в
contextна каждый запрос
Пакетная функция вернула строки как есть
- Симптом
- Категории перепутаны, ошибка про длину массива
- Исправление
- Раскладывать по ключам через
Map
Значения вклеены в текст запроса
- Симптом
- Ошибки синтаксиса на кавычках, инъекции
- Исправление
- Переменные
$nameи объектvariables
Клиент смотрит только на статус
- Симптом
- Ошибки выполнения «не видны» при 200
- Исправление
- Проверять
errorsв теле
Ошибки — обычный Error
- Симптом
- Клиент получает
Unexpected error. - Исправление
GraphQLErrorсextensions.codeдля ожидаемых ошибок
Два экземпляра graphql в node_modules
- Симптом
- 500 и
Unexpected error.из плагинов - Исправление
npm ls graphql, одна версия на проект
Это учебный сервер: перед продакшеном ему нужны аутентификация и права на уровне полей, настоящая база с пулом соединений, пагинация всех списков, лимиты стоимости и частоты, журнал операций и мониторинг ответов с errors.
Как проверено5 · пройдено1 · частичносверено 11 октября 2026 г.
- запущено, работает
setup.sh + package.json (catalog-graphql: graphql-yoga 5.24.4, graphql 16.14.2, dataloader 2.2.3, @escape.tech/graphql-armor-max-depth 2.4.2)
команды выполнены в пустой папке на Node.js 24.18 / npm 11.16; package.json — реальный результат; npm ls — одна копия graphql
- запущено, работает
src/typeDefs.js, src/resolvers.js, src/loaders.js, src/db.js, src/yoga.js, src/server.js
сервер запущен (порт 4400), запросы curl: query, переменные, мутация, BAD_USER_INPUT, GET и 405 на GET-мутацию, валидация 200/400 по Accept, лимит глубины; вывод и журнал [db] скопированы из реального запуска
- запущено, работает
test/api.test.js (node:test + yoga.fetch, 11 тестов, включая N+1 11 запросов против 3 с DataLoader)
npm test: 11/11 pass в рабочем и в свежем проекте; мутационная проверка: глобальные загрузчики, порядок строк из БД, отключённый плагин глубины — каждый ломает ровно один тест
- запущено, работает
решение упражнения reviewCount (diff)
diff применён к свежему проекту (git apply --check), запрос services{title reviewCount reviews{rating}} — 2 запроса к БД, тесты 11/11; алиасы a/b — 2 запроса, вывод в решении реальный
- запущено, работает
несовместимость graphql 17.0.2 и graphql-armor-max-depth 2.4.2
отдельный проект с graphql@17.0.2: npm ls показывает вложенный graphql 16.14.2 у плагина, глубокий запрос даёт HTTP 500 Unexpected error.; NODE_ENV=development → extensions.originalError со стеком — проверено yoga.fetch
- частично
src/client.js (fetch-клиент)
запущен в Node.js 24.18 против сервера, вывод скопирован; в браузере не запускался (утверждение о браузере ограничено условием CORS)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.