Основы разработки GraphQL API: Schemas, Resolvers, Subscriptions и Apollo
Этот материал представляет собой полное введение в основы разработки GraphQL API, охватывая schemas, resolvers, subscriptions и Apollo с практическими примерами.
In a Nutshell
GraphQL — это язык запросов для APIs и серверная среда выполнения с типизированной системой для определения структур данных.
Что такое GraphQL? Framework, программное обеспечение или идея?
GraphQL — это не framework и не готовое ПО, которое ты просто устанавливаешь и начинаешь использовать. Это язык запросов и спецификация, которая точно определяет, как клиенты запрашивают данные у сервера, как сервер обрабатывает эти запросы и какие ответы возвращаются.
Думай об этом так: GraphQL — это как SQL для баз данных, но для APIs. Он определяет синтаксис запросов и правила их выполнения. Ты должен реализовать GraphQL на некотором языке программирования или использовать уже готовые библиотеки и frameworks.
Apollo — пример популярной платформы и framework, которая реализует GraphQL. Она предоставляет Apollo Server для backend и Apollo Client для frontend. Другие реализации включают Relay, GraphQL Yoga или urql. Сам GraphQL остаётся независимым от языка программирования или framework.
Проще говоря: GraphQL — это идея и открытый стандарт для APIs, который позволяет клиентам запрашивать ровно те данные, которые им нужны.
Компактное определение
GraphQL — это язык запросов для APIs, позволяющий клиентам запрашивать именно те данные, которые им требуются, и получать всё в одном запросе.
Основные компоненты:
GraphQL Schema
- Types: определение структур данных
- Queries: операции получения данных
- Mutations: операции изменения данных
- Subscriptions: обновления в реальном времени
- Interfaces: переиспользуемые типы
- Unions: группирование типов
Resolvers
- Field Resolvers: разрешение данных для отдельных полей
- Type Resolvers: реализации типов
- Context: общий контекст для resolvers
- Data Loaders: избежание проблемы N+1
- Middleware: аутентификация и валидация
Apollo GraphQL
- Apollo Server: GraphQL сервер для Node.js
- Apollo Client: GraphQL клиент для Web/Mobile
- Apollo Federation: распределённый GraphQL
- Apollo Studio: мониторинг GraphQL
- Apollo Gateway: GraphQL шлюз
Ключевые моменты
- GraphQL: язык запросов для APIs с типизированной системой
- Schema: определения типов для структур данных
- Query: операция получения данных с конкретными полями
- Mutation: операции изменения данных
- Subscription: передача данных в реальном времени
- Resolver: функции для разрешения данных
- Apollo: платформа GraphQL для сервера и клиента
- Type System: строгая типизация данных
- IHK-relevant: современная разработка и архитектура API
Основные компоненты
-
Определение Schema: GraphQL Type System и SDL Schema — это сердце любого GraphQL API. Он описывает с помощью Schema Definition Language (SDL), какие структуры данных существуют и какие операции допускаются. Он определяет Object Types, Queries, Mutations, Subscriptions, Enums, Interfaces и Unions. Schema служит контрактом между клиентом и сервером.
-
Выполнение Query: парсинг и выполнение запросов Когда клиент отправляет query, она сначала парсится и валидируется относительно schema. Затем execution engine определяет, какие resolvers в каком порядке вызывать, чтобы вернуть запрошенные данные. При этом происходит обход дерева GraphQL запроса.
-
Resolver Functions: разрешение данных и бизнес-логика Каждое поле в GraphQL schema может иметь resolver. Resolvers — это функции, которые предоставляют фактические данные для поля. Они могут загружать данные из баз данных, внешних APIs или других источников и часто содержат бизнес-логику приложения.
-
Subscriptions: обновления в реальном времени на основе WebSocket Subscriptions позволяют серверу отправлять события в реальном времени клиентам. Обычно они основаны на WebSockets. Используются, когда клиентам нужно немедленно узнавать об изменениях, например о новых сообщениях или обновлениях статуса.
-
Apollo Server: реализация GraphQL сервера Apollo Server — это популярный Node.js framework, который предоставляет GraphQL API на основе schema и resolvers. Он предлагает функции вроде аутентификации, обработки ошибок, subscriptions, кеширования и интеграции плагинов.
-
Apollo Client: GraphQL клиент с кешированием Apollo Client — это библиотека для JavaScript/TypeScript приложений, позволяющая выполнять GraphQL запросы, кешировать данные и автоматически обновлять UI компоненты при изменении данных. Кеш снижает ненужные сетевые запросы.
-
Data Loading: оптимизация запросов данных При сложных GraphQL запросах может возникнуть так называемая проблема N+1, когда для каждого объекта выполняется отдельный запрос к БД. Data Loaders объединяют эти запросы и минимизируют количество обращений к базе.
-
Error Handling: обработка ошибок и валидация GraphQL частично возвращает ответ даже при ошибках в полях. Ошибки возвращаются в отдельном массиве
errors. Кроме того, в resolvers можно обрабатывать собственные валидации, ошибки аутентификации и авторизации.
Что такое Apollo и зачем его использовать?
Apollo — это комплексная платформа для GraphQL, разработанная компанией Apollo GraphQL Inc. Она состоит из нескольких частей, которые вместе охватывают весь GraphQL workflow: Apollo Server для backend, Apollo Client для frontend и Apollo Studio и Apollo Federation для мониторинга и распределённых архитектур.
Где достать Apollo?
- Apollo Server и Apollo Client доступны как пакеты с открытым исходным кодом через npm.
- Apollo Studio предлагает облачный интерфейс на https://www.apollographql.com/.
- Документацию и дополнительные инструменты найдёшь на официальном сайте Apollo.
Кто использует Apollo и для чего?
- Backend разработчики используют Apollo Server, чтобы быстро и типобезопасно создавать GraphQL APIs.
- Frontend разработчики используют Apollo Client, чтобы загружать и управлять данными из GraphQL APIs в React, Vue, Angular или других frameworks.
- DevOps и архитекторы используют Apollo Studio и Federation, чтобы отслеживать APIs, документировать их и распределять их по нескольким микросервисам.
Почему стоит использовать Apollo?
- Apollo основывается на официальном стандарте GraphQL и является одним из наиболее распространённых стеков.
- Apollo Server упрощает создание schemas, resolvers, аутентификации и subscriptions.
- Apollo Client значительно снижает усилия на frontend благодаря интеллектуальному кешированию и управлению состоянием.
- Apollo Studio помогает находить ошибки, измерять производительность и документировать schema.
- Apollo Federation позволяет создавать большие APIs из небольших, независимых сервисов.
Коротко: Apollo — это самая известная экосистема вокруг GraphQL и идеально подходит, если ты хочешь быстро создать готовый к production, хорошо документированный и масштабируемый API.
Примеры из практики
Ниже приведены примеры построения GraphQL API в реальных проектах. Сначала ты разработаешь полнофункциональный GraphQL-сервер с Apollo, Node.js, Express и Mongoose. Пример покажет, как определить схему, написать резолверы, добавить аутентификацию и реализовать подписки через WebSockets. Затем следует пример Apollo Client в React, который получает данные с сервера, кэширует их и автоматически обновляет.
1. GraphQL Server с Apollo и Node.js
// server.js
const { ApolloServer, gql, AuthenticationError, ForbiddenError } = require('apollo-server-express');
const { ApolloServerPluginDrainHttpServer } = require('apollo-server-core');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { default: mongoose } = require('mongoose');
const express = require('express');
const http = require('http');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const { PubSub } = require('graphql-subscriptions');
const { GraphQLUpload } = require('graphql-upload');
const { GraphQLJSON } = require('graphql-type-json');
const DataLoader = require('dataloader');
// Database Models
const userSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
firstName: String,
lastName: String,
avatar: String,
role: { type: String, enum: ['user', 'admin'], default: 'user' },
isActive: { type: Boolean, default: true },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
const postSchema = new mongoose.Schema({
title: { type: String, required: true },
content: { type: String, required: true },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
tags: [String],
status: { type: String, enum: ['draft', 'published', 'archived'], default: 'draft' },
featured: { type: Boolean, default: false },
likes: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
comments: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Comment' }],
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
const commentSchema = new mongoose.Schema({
content: { type: String, required: true },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
post: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', required: true },
parent: { type: mongoose.Schema.Types.ObjectId, ref: 'Comment' },
likes: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
const User = mongoose.model('User', userSchema);
const Post = mongoose.model('Post', postSchema);
const Comment = mongoose.model('Comment', commentSchema);
// GraphQL Schema Definition
const typeDefs = gql`
scalar Upload
scalar JSON
scalar DateTime
directive @auth(requires: String = "USER") on FIELD_DEFINITION
directive @admin on FIELD_DEFINITION
directive @rateLimit(limit: Int, duration: Int) on FIELD_DEFINITION
type User {
id: ID!
username: String!
email: String!
firstName: String
lastName: String
avatar: String
role: UserRole!
isActive: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
posts(limit: Int, offset: Int): [Post!]!
comments(limit: Int, offset: Int): [Comment!]!
likedPosts: [Post!]!
followers: [User!]!
following: [User!]!
postCount: Int!
commentCount: Int!
followerCount: Int!
followingCount: Int!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
tags: [String!]!
status: PostStatus!
featured: Boolean!
likes: [User!]!
comments(limit: Int, offset: Int): [Comment!]!
likeCount: Int!
commentCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
isLiked: Boolean
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
parent: Comment
replies: [Comment!]!
likes: [User!]!
likeCount: Int!
replyCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
isLiked: Boolean
}
enum UserRole {
USER
ADMIN
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
input UserInput {
username: String!
email: String!
password: String!
firstName: String
lastName: String
}
input UserUpdateInput {
username: String
email: String
firstName: String
lastName: String
avatar: String
}
input PostInput {
title: String!
content: String!
tags: [String!]
status: PostStatus
featured: Boolean
}
input PostUpdateInput {
title: String
content: String
tags: [String!]
status: PostStatus
featured: Boolean
}
input CommentInput {
content: String!
postId: ID!
parentId: ID
}
input CommentUpdateInput {
content: String
}
type AuthPayload {
token: String!
user: User!
}
type Query {
# User queries
me: User @auth
user(id: ID!): User
users(limit: Int = 10, offset: Int = 0, search: String): [User!]!
# Post queries
post(id: ID!): Post
posts(
limit: Int = 10
offset: Int = 0
status: PostStatus = PUBLISHED
authorId: ID
tags: [String]
search: String
featured: Boolean
): [Post!]!
trendingPosts(limit: Int = 5): [Post!]!
# Comment queries
comment(id: ID!): Comment
comments(postId: ID!, limit: Int = 10, offset: Int = 0): [Comment!]!
# Search queries
search(query: String!, type: String): SearchResult!
}
type Mutation {
# Authentication mutations
register(input: UserInput!): AuthPayload!
login(username: String!, password: String!): AuthPayload!
refreshToken: String! @auth
# User mutations
updateProfile(input: UserUpdateInput!): User! @auth
changePassword(currentPassword: String!, newPassword: String!): Boolean! @auth
followUser(userId: ID!): Boolean! @auth
unfollowUser(userId: ID!): Boolean! @auth
# Post mutations
createPost(input: PostInput!): Post! @auth @rateLimit(limit: 5, duration: 60)
updatePost(id: ID!, input: PostUpdateInput!): Post! @auth
deletePost(id: ID!): Boolean! @auth
likePost(postId: ID!): Post! @auth
unlikePost(postId: ID!): Post! @auth
# Comment mutations
createComment(input: CommentInput!): Comment! @auth
updateComment(id: ID!, input: CommentUpdateInput!): Comment! @auth
deleteComment(id: ID!): Boolean! @auth
likeComment(commentId: ID!): Comment! @auth
unlikeComment(commentId: ID!): Comment! @auth
# File upload mutations
uploadAvatar(file: Upload!): String! @auth
}
type Subscription {
# Post subscriptions
postCreated: Post!
postUpdated(postId: ID): Post!
postDeleted(postId: ID): ID!
# Comment subscriptions
commentCreated(postId: ID): Comment!
commentUpdated(commentId: ID): Comment!
commentDeleted(commentId: ID): ID!
# User subscriptions
userOnline(userId: ID): User!
userOffline(userId: ID): User!
}
union SearchResult = User | Post | Comment
`;
// Resolvers
const resolvers = {
// Custom scalar resolvers
Upload: GraphQLUpload,
JSON: GraphQLJSON,
DateTime: {
serialize: (value) => new Date(value).toISOString(),
parseValue: (value) => new Date(value),
parseLiteral: (ast) => new Date(ast.value)
},
// Query resolvers
Query: {
me: async (parent, args, context) => {
if (!context.user) {
throw new AuthenticationError('You must be logged in');
}
return context.user;
},
user: async (parent, { id }, context) => {
try {
const user = await User.findById(id)
.populate('followers following')
.lean();
if (!user || !user.isActive) {
throw new Error('User not found');
}
return user;
} catch (error) {
throw new Error(`Failed to fetch user: ${error.message}`);
}
},
users: async (parent, { limit = 10, offset = 0, search }, context) => {
try {
let query = { isActive: true };
if (search) {
query.$or = [
{ username: { $regex: search, $options: 'i' } },
{ email: { $regex: search, $options: 'i' } },
{ firstName: { $regex: search, $options: 'i' } },
{ lastName: { $regex: search, $options: 'i' } }
];
}
const users = await User.find(query)
.populate('followers following')
.sort({ createdAt: -1 })
.limit(limit)
.skip(offset)
.lean();
return users;
} catch (error) {
throw new Error(`Failed to fetch users: ${error.message}`);
}
},
post: async (parent, { id }, context) => {
try {
const post = await Post.findById(id)
.populate('author')
.populate('comments')
.lean();
if (!post) {
throw new Error('Post not found');
}
// Add isLiked field if user is authenticated
if (context.user) {
post.isLiked = post.likes.some(like => like.toString() === context.user.id);
}
return post;
} catch (error) {
throw new Error(`Failed to fetch post: ${error.message}`);
}
},
posts: async (parent, args, context) => {
try {
const {
limit = 10,
offset = 0,
status = 'PUBLISHED',
authorId,
tags,
search,
featured
} = args;
let query = { status };
if (authorId) {
query.author = authorId;
}
if (tags && tags.length > 0) {
query.tags = { $in: tags };
}
if (search) {
query.$or = [
{ title: { $regex: search, $options: 'i' } },
{ content: { $regex: search, $options: 'i' } }
];
}
if (featured !== undefined) {
query.featured = featured;
}
const posts = await Post.find(query)
.populate('author')
.sort({ createdAt: -1 })
.limit(limit)
.skip(offset)
.lean();
// Add isLiked field if user is authenticated
if (context.user) {
posts.forEach(post => {
post.isLiked = post.likes.some(like => like.toString() === context.user.id);
});
}
return posts;
} catch (error) {
throw new Error(`Failed to fetch posts: ${error.message}`);
}
},
trendingPosts: async (parent, { limit = 5 }, context) => {
try {
const posts = await Post.find({ status: 'PUBLISHED' })
.populate('author')
.sort({ likes: -1, createdAt: -1 })
.limit(limit)
.lean();
// Add isLiked field if user is authenticated
if (context.user) {
posts.forEach(post => {
post.isLiked = post.likes.some(like => like.toString() === context.user.id);
});
}
return posts;
} catch (error) {
throw new Error(`Failed to fetch trending posts: ${error.message}`);
}
},
comment: async (parent, { id }, context) => {
try {
const comment = await Comment.findById(id)
.populate('author')
.populate('post')
.populate('parent')
.lean();
if (!comment) {
throw new Error('Comment not found');
}
// Add isLiked field if user is authenticated
if (context.user) {
comment.isLiked = comment.likes.some(like => like.toString() === context.user.id);
}
return comment;
} catch (error) {
throw new Error(`Failed to fetch comment: ${error.message}`);
}
},
comments: async (parent, { postId, limit = 10, offset = 0 }, context) => {
try {
const comments = await Comment.find({ post: postId, parent: null })
.populate('author')
.populate('replies')
.sort({ createdAt: -1 })
.limit(limit)
.skip(offset)
.lean();
// Add isLiked field if user is authenticated
if (context.user) {
comments.forEach(comment => {
comment.isLiked = comment.likes.some(like => like.toString() === context.user.id);
});
}
return comments;
} catch (error) {
throw new Error(`Failed to fetch comments: ${error.message}`);
}
},
search: async (parent, { query, type }, context) => {
try {
const searchRegex = { $regex: query, $options: 'i' };
let results = [];
if (!type || type === 'USER') {
const users = await User.find({
$or: [
{ username: searchRegex },
{ email: searchRegex },
{ firstName: searchRegex },
{ lastName: searchRegex }
],
isActive: true
}).limit(5).lean();
results.push(...users);
}
if (!type || type === 'POST') {
const posts = await Post.find({
$or: [
{ title: searchRegex },
{ content: searchRegex },
{ tags: searchRegex }
],
status: 'PUBLISHED'
}).populate('author').limit(5).lean();
results.push(...posts);
}
if (!type || type === 'COMMENT') {
const comments = await Comment.find({
content: searchRegex
}).populate('author').populate('post').limit(5).lean();
results.push(...comments);
}
return results;
} catch (error) {
throw new Error(`Search failed: ${error.message}`);
}
}
},
// Mutation resolvers
Mutation: {
register: async (parent, { input }, context) => {
try {
const { username, email, password, firstName, lastName } = input;
// Check if user already exists
const existingUser = await User.findOne({
$or: [{ username }, { email }]
});
if (existingUser) {
throw new Error('User already exists');
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const user = new User({
username,
email,
password: hashedPassword,
firstName,
lastName
});
await user.save();
// Generate JWT token
const token = jwt.sign(
{ userId: user._id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
// Publish user created event
pubsub.publish('USER_CREATED', {
userCreated: user
});
return { token, user };
} catch (error) {
throw new Error(`Registration failed: ${error.message}`);
}
},
login: async (parent, { username, password }, context) => {
try {
// Find user
const user = await User.findOne({ username, isActive: true });
if (!user) {
throw new Error('Invalid credentials');
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
throw new Error('Invalid credentials');
}
// Generate JWT token
const token = jwt.sign(
{ userId: user._id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
return { token, user };
} catch (error) {
throw new Error(`Login failed: ${error.message}`);
}
},
refreshToken: async (parent, args, context) => {
try {
if (!context.user) {
throw new AuthenticationError('Invalid token');
}
// Generate new token
const token = jwt.sign(
{ userId: context.user._id, username: context.user.username },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
return token;
} catch (error) {
throw new Error(`Token refresh failed: ${error.message}`);
}
},
updateProfile: async (parent, { input }, context) => {
try {
const { username, email, firstName, lastName, avatar } = input;
// Check if username or email is already taken
if (username || email) {
const existingUser = await User.findOne({
_id: { $ne: context.user._id },
$or: [
...(username ? [{ username }] : []),
...(email ? [{ email }] : [])
]
});
if (existingUser) {
throw new Error('Username or email already exists');
}
}
// Update user
const updatedUser = await User.findByIdAndUpdate(
context.user._id,
{
...(username && { username }),
...(email && { email }),
...(firstName && { firstName }),
...(lastName && { lastName }),
...(avatar && { avatar }),
updatedAt: new Date()
},
{ new: true }
).lean();
return updatedUser;
} catch (error) {
throw new Error(`Profile update failed: ${error.message}`);
}
},
changePassword: async (parent, { currentPassword, newPassword }, context) => {
try {
// Get user with password
const user = await User.findById(context.user._id);
// Verify current password
const isValidPassword = await bcrypt.compare(currentPassword, user.password);
if (!isValidPassword) {
throw new Error('Current password is incorrect');
}
// Hash new password
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
// Update password
await User.findByIdAndUpdate(context.user._id, {
password: hashedNewPassword,
updatedAt: new Date()
});
return true;
} catch (error) {
throw new Error(`Password change failed: ${error.message}`);
}
},
createPost: async (parent, { input }, context) => {
try {
const post = new Post({
...input,
author: context.user._id
});
await post.save();
await post.populate('author');
// Publish post created event
pubsub.publish('POST_CREATED', {
postCreated: post
});
return post;
} catch (error) {
throw new Error(`Post creation failed: ${error.message}`);
}
},
updatePost: async (parent, { id, input }, context) => {
try {
const post = await Post.findById(id);
if (!post) {
throw new Error('Post not found');
}
// Check if user is the author or admin
if (post.author.toString() !== context.user._id && context.user.role !== 'ADMIN') {
throw new ForbiddenError('Not authorized to update this post');
}
const updatedPost = await Post.findByIdAndUpdate(
id,
{
...input,
updatedAt: new Date()
},
{ new: true }
).populate('author');
// Publish post updated event
pubsub.publish('POST_UPDATED', {
postUpdated: updatedPost,
postId: id
});
return updatedPost;
} catch (error) {
throw new Error(`Post update failed: ${error.message}`);
}
},
deletePost: async (parent, { id }, context) => {
try {
const post = await Post.findById(id);
if (!post) {
throw new Error('Post not found');
}
// Check if user is the author or admin
if (post.author.toString() !== context.user._id && context.user.role !== 'ADMIN') {
throw new ForbiddenError('Not authorized to delete this post');
}
await Post.findByIdAndDelete(id);
// Publish post deleted event
pubsub.publish('POST_DELETED', {
postDeleted: id,
postId: id
});
return true;
} catch (error) {
throw new Error(`Post deletion failed: ${error.message}`);
}
},
likePost: async (parent, { postId }, context) => {
try {
const post = await Post.findById(postId);
if (!post) {
throw new Error('Post not found');
}
// Check if already liked
if (post.likes.includes(context.user._id)) {
throw new Error('Post already liked');
}
// Add like
post.likes.push(context.user._id);
await post.save();
await post.populate('author');
return post;
} catch (error) {
throw new Error(`Post like failed: ${error.message}`);
}
},
unlikePost: async (parent, { postId }, context) => {
try {
const post = await Post.findById(postId);
if (!post) {
throw new Error('Post not found');
}
// Check if not liked
if (!post.likes.includes(context.user._id)) {
throw new Error('Post not liked');
}
// Remove like
post.likes = post.likes.filter(like => like.toString() !== context.user._id);
await post.save();
await post.populate('author');
return post;
} catch (error) {
throw new Error(`Post unlike failed: ${error.message}`);
}
},
createComment: async (parent, { input }, context) => {
try {
const { content, postId, parentId } = input;
// Verify post exists
const post = await Post.findById(postId);
if (!post) {
throw new Error('Post not found');
}
// Verify parent comment exists if provided
if (parentId) {
const parentComment = await Comment.findById(parentId);
if (!parentComment) {
throw new Error('Parent comment not found');
}
}
const comment = new Comment({
content,
author: context.user._id,
post: postId,
parent: parentId
});
await comment.save();
await comment.populate('author post parent');
// Publish comment created event
pubsub.publish('COMMENT_CREATED', {
commentCreated: comment,
postId
});
return comment;
} catch (error) {
throw new Error(`Comment creation failed: ${error.message}`);
}
},
updateComment: async (parent, { id, input }, context) => {
try {
const comment = await Comment.findById(id);
if (!comment) {
throw new Error('Comment not found');
}
// Check if user is the author or admin
if (comment.author.toString() !== context.user._id && context.user.role !== 'ADMIN') {
throw new ForbiddenError('Not authorized to update this comment');
}
const updatedComment = await Comment.findByIdAndUpdate(
id,
{
...input,
updatedAt: new Date()
},
{ new: true }
).populate('author post parent');
// Publish comment updated event
pubsub.publish('COMMENT_UPDATED', {
commentUpdated: updatedComment,
commentId: id
});
return updatedComment;
} catch (error) {
throw new Error(`Comment update failed: ${error.message}`);
}
},
deleteComment: async (parent, { id }, context) => {
try {
const comment = await Comment.findById(id);
if (!comment) {
throw new Error('Comment not found');
}
// Check if user is the author or admin
if (comment.author.toString() !== context.user._id && context.user.role !== 'ADMIN') {
throw new ForbiddenError('Not authorized to delete this comment');
}
await Comment.findByIdAndDelete(id);
// Publish comment deleted event
pubsub.publish('COMMENT_DELETED', {
commentDeleted: id,
commentId: id
});
return true;
} catch (error) {
throw new Error(`Comment deletion failed: ${error.message}`);
}
}
},
// Subscription resolvers
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
},
postUpdated: {
subscribe: (parent, { postId }) => {
if (postId) {
return pubsub.asyncIterator([`POST_UPDATED_${postId}`]);
}
return pubsub.asyncIterator(['POST_UPDATED']);
}
},
postDeleted: {
subscribe: (parent, { postId }) => {
if (postId) {
return pubsub.asyncIterator([`POST_DELETED_${postId}`]);
}
return pubsub.asyncIterator(['POST_DELETED']);
}
},
commentCreated: {
subscribe: (parent, { postId }) => {
if (postId) {
return pubsub.asyncIterator([`COMMENT_CREATED_${postId}`]);
}
return pubsub.asyncIterator(['COMMENT_CREATED']);
}
},
commentUpdated: {
subscribe: (parent, { commentId }) => {
if (commentId) {
return pubsub.asyncIterator([`COMMENT_UPDATED_${commentId}`]);
}
return pubsub.asyncIterator(['COMMENT_UPDATED']);
}
},
commentDeleted: {
subscribe: (parent, { commentId }) => {
if (commentId) {
return pubsub.asyncIterator([`COMMENT_DELETED_${commentId}`]);
}
return pubsub.asyncIterator(['COMMENT_DELETED']);
}
}
},
// Field resolvers
User: {
posts: async (parent, { limit = 10, offset = 0 }, context) => {
try {
return await Post.find({ author: parent._id })
.sort({ createdAt: -1 })
.limit(limit)
.skip(offset)
.lean();
} catch (error) {
throw new Error(`Failed to fetch user posts: ${error.message}`);
}
},
comments: async (parent, { limit = 10, offset = 0 }, context) => {
try {
return await Comment.find({ author: parent._id })
.sort({ createdAt: -1 })
.limit(limit)
.skip(offset)
.lean();
} catch (error) {
throw new Error(`Failed to fetch user comments: ${error.message}`);
}
},
likedPosts: async (parent, args, context) => {
try {
return await Post.find({ likes: parent._id })
.populate('author')
.sort({ createdAt: -1 })
.lean();
} catch (error) {
throw new Error(`Failed to fetch liked posts: ${error.message}`);
}
},
followers: async (parent, args, context) => {
try {
return await User.find({ following: parent._id })
.sort({ createdAt: -1 })
.lean();
} catch (error) {
throw new Error(`Failed to fetch followers: ${error.message}`);
}
},
following: async (parent, args, context)
### 2. GraphQL клиент с Apollo Client и React
```jsx
// ApolloClient.js
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { split } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { WebSocketLink } from '@apollo/client/link/ws';
// HTTP link
const httpLink = createHttpLink({
uri: process.env.REACT_APP_GRAPHQL_URI || 'http://localhost:4000/graphql'
});
// WebSocket link for subscriptions
const wsLink = new WebSocketLink({
uri: process.env.REACT_APP_GRAPHQL_WS_URI || 'ws://localhost:4000/graphql',
options: {
reconnect: true,
connectionParams: () => {
const token = localStorage.getItem('token');
return {
authorization: token ? `Bearer ${token}` : ''
};
}
}
});
// Auth link
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : ''
}
};
});
// Error handling link
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
console.error(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
extensions
);
// Handle authentication errors
if (extensions?.code === 'UNAUTHENTICATED') {
localStorage.removeItem('token');
window.location.href = '/login';
}
// Handle rate limiting
if (extensions?.code === 'RATE_LIMIT_EXCEEDED') {
console.warn('Rate limit exceeded. Please try again later.');
}
});
}
if (networkError) {
console.error(`[Network error]: ${networkError}`);
// Handle network errors
if (networkError.statusCode === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
}
});
// Split link for subscriptions
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
from([errorLink, authLink, httpLink])
);
// Apollo Client instance
export const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
merge(existing = [], incoming) {
return [...incoming];
}
},
users: {
merge(existing = [], incoming) {
return [...incoming];
}
}
}
},
User: {
fields: {
posts: {
merge(existing = [], incoming) {
return [...incoming];
}
},
followers: {
merge(existing = [], incoming) {
return [...incoming];
}
},
following: {
merge(existing = [], incoming) {
return [...incoming];
}
}
}
},
Post: {
fields: {
comments: {
merge(existing = [], incoming) {
return [...incoming];
}
},
likes: {
merge(existing = [], incoming) {
return [...incoming];
}
}
}
},
Comment: {
fields: {
replies: {
merge(existing = [], incoming) {
return [...incoming];
}
},
likes: {
merge(existing = [], incoming) {
return [...incoming];
}
}
}
}
}
}),
defaultOptions: {
watchQuery: {
errorPolicy: 'all',
notifyOnNetworkStatusChange: true
},
query: {
errorPolicy: 'all'
}
}
});
export default client;
// GraphQL Queries
import { gql } from '@apollo/client';
export const GET_ME = gql`
query GetMe {
me {
id
username
email
firstName
lastName
avatar
role
isActive
createdAt
updatedAt
postCount
commentCount
followerCount
followingCount
}
}
`;
export const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
username
email
firstName
lastName
avatar
role
isActive
createdAt
updatedAt
postCount
commentCount
followerCount
followingCount
followers {
id
username
firstName
lastName
avatar
}
following {
id
username
firstName
lastName
avatar
}
}
}
`;
export const GET_USERS = gql`
GetUsers($limit: Int, $offset: Int, $search: String) {
users(limit: $limit, offset: $offset, search: $search) {
id
username
email
firstName
lastName
avatar
role
isActive
createdAt
updatedAt
postCount
commentCount
followerCount
followingCount
}
}
`;
export const GET_POST = gql`
query GetPost($id: ID!) {
post(id: $id) {
id
title
content
status
featured
createdAt
updatedAt
likeCount
commentCount
isLiked
author {
id
username
firstName
lastName
avatar
}
tags
}
}
`;
export const GET_POSTS = gql`
query GetPosts(
$limit: Int
$offset: Int
$status: PostStatus
$authorId: ID
$tags: [String]
$search: String
$featured: Boolean
) {
posts(
limit: $limit
offset: $offset
status: $status
authorId: $authorId
tags: $tags
search: $search
featured: $featured
) {
id
title
content
status
featured
createdAt
updatedAt
likeCount
commentCount
isLiked
author {
id
username
firstName
lastName
avatar
}
tags
}
}
`;
export const GET_TRENDING_POSTS = gql`
query GetTrendingPosts($limit: Int) {
trendingPosts(limit: $limit) {
id
title
content
status
featured
createdAt
updatedAt
likeCount
commentCount
isLiked
author {
id
username
firstName
lastName
avatar
}
tags
}
}
`;
export const GET_COMMENTS = gql`
query GetComments($postId: ID!, $limit: Int, $offset: Int) {
comments(postId: $postId, limit: $limit, offset: $offset) {
id
content
createdAt
updatedAt
likeCount
replyCount
isLiked
author {
id
username
firstName
lastName
avatar
}
replies {
id
content
createdAt
updatedAt
likeCount
isLiked
author {
id
username
firstName
lastName
avatar
}
}
}
}
`;
export const SEARCH = gql`
query Search($query: String!, $type: String) {
search(query: $query, type: $type) {
... on User {
id
username
email
firstName
lastName
avatar
role
postCount
commentCount
followerCount
followingCount
}
... on Post {
id
title
content
status
featured
createdAt
likeCount
commentCount
author {
id
username
firstName
lastName
avatar
}
tags
}
... on Comment {
id
content
createdAt
likeCount
author {
id
username
firstName
lastName
avatar
}
post {
id
title
}
}
}
}
`;
// GraphQL Mutations
export const REGISTER = gql`
mutation Register($input: UserInput!) {
register(input: $input) {
token
user {
id
username
email
firstName
lastName
avatar
role
isActive
createdAt
updatedAt
}
}
}
`;
export const LOGIN = gql`
mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
token
user {
id
username
email
firstName
lastName
avatar
role
isActive
createdAt
updatedAt
}
}
}
`;
export const UPDATE_PROFILE = gql`
mutation UpdateProfile($input: UserUpdateInput!) {
updateProfile(input: $input) {
id
username
email
firstName
lastName
avatar
role
isActive
createdAt
updatedAt
}
}
`;
export const CHANGE_PASSWORD = gql`
mutation ChangePassword($currentPassword: String!, $newPassword: String!) {
changePassword(currentPassword: $currentPassword, newPassword: $newPassword)
}
`;
export const CREATE_POST = gql`
mutation CreatePost($input: PostInput!) {
createPost(input: $input) {
id
title
content
status
featured
createdAt
updatedAt
likeCount
commentCount
author {
id
username
firstName
lastName
avatar
}
tags
}
}
`;
export const UPDATE_POST = gql`
mutation UpdatePost($id: ID!, $input: PostUpdateInput!) {
updatePost(id: $id, input: $input) {
id
title
content
status
featured
createdAt
updatedAt
likeCount
commentCount
author {
id
username
firstName
lastName
avatar
}
tags
}
}
`;
export const DELETE_POST = gql`
mutation DeletePost($id: ID!) {
deletePost(id: $id)
}
`;
export const LIKE_POST = gql`
mutation LikePost($postId: ID!) {
likePost(postId: $postId) {
id
title
content
status
featured
createdAt
updatedAt
likeCount
commentCount
isLiked
author {
id
username
firstName
lastName
avatar
}
tags
}
}
`;
export const UNLIKE_POST = gql`
mutation UnlikePost($postId: ID!) {
unlikePost(postId: $postId) {
id
title
content
status
featured
createdAt
updatedAt
likeCount
commentCount
isLiked
author {
id
username
firstName
lastName
avatar
}
tags
}
}
`;
export const CREATE_COMMENT = gql`
mutation CreateComment($input: CommentInput!) {
createComment(input: $input) {
id
content
createdAt
updatedAt
likeCount
replyCount
author {
id
username
firstName
lastName
avatar
}
post {
id
title
}
parent {
id
content
author {
id
username
firstName
lastName
avatar
}
}
}
}
`;
export const UPDATE_COMMENT = gql`
mutation UpdateComment($id: ID!, $input: CommentUpdateInput!) {
updateComment(id: $id, input: $input) {
id
content
createdAt
updatedAt
likeCount
replyCount
author {
id
username
firstName
lastName
avatar
}
post {
id
title
}
}
}
`;
export const DELETE_COMMENT = gql`
mutation DeleteComment($id: ID!) {
deleteComment(id: $id)
}
`;
export const LIKE_COMMENT = gql`
mutation LikeComment($commentId: ID!) {
likeComment(commentId: $commentId) {
id
content
createdAt
updatedAt
likeCount
replyCount
isLiked
author {
id
username
firstName
lastName
avatar
}
post {
id
title
}
}
}
`;
export const UNLIKE_COMMENT = gql`
mutation UnlikeComment($commentId: ID!) {
unlikeComment(commentId: $commentId) {
id
content
createdAt
updatedAt
likeCount
replyCount
isLiked
author {
id
username
firstName
lastName
avatar
}
post {
id
title
}
}
}
`;
// GraphQL Subscriptions
export const POST_CREATED = gql`
subscription PostCreated {
postCreated {
id
title
content
status
featured
createdAt
updatedAt
likeCount
commentCount
author {
id
username
firstName
lastName
avatar
}
tags
}
}
`;
export const POST_UPDATED = gql`
subscription PostUpdated($postId: ID) {
postUpdated(postId: $postId) {
id
title
content
status
featured
createdAt
updatedAt
likeCount
commentCount
author {
id
username
firstName
lastName
avatar
}
tags
}
}
`;
export const POST_DELETED = gql`
subscription PostDeleted($postId: ID) {
postDeleted(postId: $postId)
}
`;
export const COMMENT_CREATED = gql`
subscription CommentCreated($postId: ID) {
commentCreated(postId: $postId) {
id
content
createdAt
updatedAt
likeCount
replyCount
author {
id
username
firstName
lastName
avatar
}
post {
id
title
}
parent {
id
content
author {
id
username
firstName
lastName
avatar
}
}
}
}
`;
export const COMMENT_UPDATED = gql`
subscription CommentUpdated($commentId: ID) {
commentUpdated(commentId: $commentId) {
id
content
createdAt
updatedAt
likeCount
replyCount
author {
id
username
firstName
lastName
avatar
}
post {
id
title
}
}
}
`;
export const COMMENT_DELETED = gql`
subscription CommentDeleted($commentId: ID) {
commentDeleted(commentId: $commentId)
}
`;
// React Components
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useSubscription } from '@apollo/client';
import { GET_POSTS, CREATE_POST, LIKE_POST, UNLIKE_POST, POST_CREATED } from './graphql';
// PostList Component
const PostList = ({ limit = 10, status = 'PUBLISHED' }) => {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const { data, loading: queryLoading, error: queryError, fetchMore } = useQuery(GET_POSTS, {
variables: { limit, offset: 0, status },
notifyOnNetworkStatusChange: true
});
const [createPost] = useMutation(CREATE_POST, {
onCompleted: (data) => {
setPosts(prev => [data.createPost, ...prev]);
},
onError: (error) => {
console.error('Create post error:', error);
}
});
const [likePost] = useMutation(LIKE_POST);
const [unlikePost] = useMutation(UNLIKE_POST);
// Subscription for new posts
const { data: subscriptionData } = useSubscription(POST_CREATED);
useEffect(() => {
if (data) {
setPosts(data.posts);
setLoading(false);
}
}, [data]);
useEffect(() => {
if (queryError) {
setError(queryError);
setLoading(false);
}
}, [queryError]);
useEffect(() => {
if (subscriptionData) {
setPosts(prev => [subscriptionData.postCreated, ...prev]);
}
}, [subscriptionData]);
const handleLikePost = async (postId, isLiked) => {
try {
if (isLiked) {
await unlikePost({ variables: { postId } });
} else {
await likePost({ variables: { postId } });
}
// Update local state
setPosts(prev => prev.map(post =>
post.id === postId
? { ...post, isLiked: !isLiked, likeCount: isLiked ? post.likeCount - 1 : post.likeCount + 1 }
: post
));
} catch (error) {
console.error('Like post error:', error);
}
};
const loadMore = () => {
if (!hasMore || queryLoading) return;
fetchMore({
variables: { offset: posts.length },
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
const newPosts = fetchMoreResult.posts;
setHasMore(newPosts.length >= limit);
return {
posts: [...prev.posts, ...newPosts]
};
}
});
};
if (loading && posts.length === 0) {
return <div>Loading posts...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div className="post-list">
{posts.map(post => (
<PostItem
key={post.id}
post={post}
onLike={handleLikePost}
/>
))}
{hasMore && (
<button onClick={loadMore} disabled={queryLoading}>
{queryLoading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
};
// PostItem Component
const PostItem = ({ post, onLike }) => {
const [expanded, setExpanded] = useState(false);
const handleLike = () => {
onLike(post.id, post.isLiked);
};
return (
<div className="post-item">
<div className="post-header">
<img
src={post.author.avatar || '/default-avatar.png'}
alt={post.author.username}
className="author-avatar"
/>
<div className="author-info">
<h4>{post.author.firstName} {post.author.lastName}</h4>
<p>@{post.author.username}</p>
</div>
<div className="post-meta">
<span className="post-date">
{new Date(post.createdAt).toLocaleDateString()}
</span>
{post.featured && <span className="featured-badge">Featured</span>}
</div>
</div>
<div className="post-content">
<h3>{post.title}</h3>
<p className={expanded ? 'expanded' : 'collapsed'}>
{post.content}
</p>
{post.content.length > 200 && (
<button
onClick={() => setExpanded(!expanded)}
className="expand-button"
>
{expanded ? 'Show Less' : 'Show More'}
</button>
)}
</div>
<div className="post-tags">
{post.tags.map(tag => (
<span key={tag} className="tag">
#{tag}
</span>
))}
</div>
<div className="post-actions">
<button
onClick={handleLike}
className={`like-button ${post.isLiked ? 'liked' : ''}`}
>
{post.isLiked ? '❤️' : '🤍'} {post.likeCount}
</button>
<button className="comment-button">
💬 {post.commentCount}
</button>
<button className="share-button">
🔗 Share
</button>
</div>
</div>
);
};
// CreatePost Component
const CreatePost = () => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [tags, setTags] = useState('');
const [status, setStatus] = useState('PUBLISHED');
const [loading, setLoading] = useState(false);
const [createPost] = useMutation(CREATE_POST);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await createPost({
variables: {
input: {
title,
content,
tags: tags.split(',').map(tag => tag.trim()).filter(Boolean),
status
}
}
});
// Reset form
setTitle('');
setContent('');
setTags('');
setStatus('PUBLISHED');
} catch (error) {
console.error('Create post error:', error);
} finally {
setLoading(false);
}
};
return (
<div className="create-post">
<h3>Create New Post</h3>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Content</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={5}
/>
</div>
<div className="form-group">
<label>Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="javascript, react, graphql"
/>
</div>
<div className="form-group">
<label>Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option>
<option value="ARCHIVED">Archived</option>
</select>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
</form>
</div>
);
};
export { PostList, PostItem, CreatePost };
Код выше демонстрирует полную настройку Apollo Client с поддержкой HTTP, WebSocket и обработкой ошибок. Ключевой момент в использовании split функции для маршрутизации запросов: подписки идут через WebSocket, остальные операции через HTTP.
Конфигурация кэша использует typePolicies для явного контроля слияния данных при получении новых значений. Это особенно важно при работе со списками, где нужно решить, заменить ли данные полностью или объединить с существующими записями.
Обработка авторизации реализована через authLink, который автоматически добавляет JWT токен в заголовок каждого запроса. При ошибке аутентификации код очищает токен и перенаправляет на страницу входа.
React компоненты используют хуки useQuery, useMutation и useSubscription для взаимодействия с сервером. Компонент PostList демонстрирует pagination, обновление кэша при создании поста и слушание новых постов через подписку.
Обратите внимание на обработку состояния загрузки и ошибок. Параметр notifyOnNetworkStatusChange позволяет отслеживать изменения статуса сети, что полезно при реализации индикаторов загрузки.
Дизайн GraphQL-схемы
Система типов
graph TD
A[Schema] --> B[Types]
A --> C[Queries]
A --> D[Mutations]
A --> E[Subscriptions]
B --> F[Scalar Types]
B --> G[Object Types]
B --> H[Interface Types]
B --> I[Union Types]
B --> J[Enum Types]
B --> K[Input Types]
C --> L[Data Fetching]
D --> M[Data Modification]
E --> N[Real-time Updates]
F --> O[String, Int, Float, Boolean, ID]
G --> P[User, Post, Comment]
H --> Q[Node, Entity]
I --> R[SearchResult]
J --> S[UserRole, PostStatus]
K --> T[UserInput, PostInput]
GraphQL и REST
Сравнение
| Аспект | GraphQL | REST |
|---|---|---|
| Получение данных | Именно необходимые данные | 固定определённые endpoint’ы |
| Количество запросов | Один запрос для всех данных | Часто требуется несколько запросов |
| Версионирование | Версионирование не требуется | Версионирование в URL |
| Типизация | Строгая типизация | Слабая типизация |
| Кеширование | Сложный механизм кеширования | Простое HTTP-кеширование |
| Обработка ошибок | Возможны частичные ошибки | HTTP-коды состояния |
Преимущества и недостатки
Преимущества GraphQL
- Эффективность: запрашиваем только нужные данные
- Гибкость: один запрос для сложных данных
- Типизация: строгая типизация предотвращает ошибки
- Эволюционируемость: схема расширяется постепенно
- Реал-тайм: Subscriptions для обновлений в реальном времени
Недостатки
- Сложность: более крутая кривая обучения чем REST
- Кеширование: сложнее чем HTTP-кеширование
- Загрузка файлов: требует расширения схемы
- Rate Limiting: требует специальной логики ограничения
- Мониторинг: нужны специализированные инструменты
Часто задаваемые вопросы: GraphQL-схема и ключевые компоненты
1. Что такое GraphQL?
2. GraphQL это фреймворк?
3. Что такое GraphQL-схема?
4. Что такое Schema Definition Language?
5. Что такое Query в GraphQL?
6. Что такое Mutation в GraphQL?
7. Что такое Subscription?
8. Что такое Resolver?
9. Что такое скалярные типы в GraphQL?
10. Что такое Object Type?
11. Что такое Enum в GraphQL?
12. Что такое Interface в GraphQL?
13. Что такое Union в GraphQL?
14. Что такое Input Type?
15. Что такое Apollo Server?
16. Что такое Apollo Client?
17. Что такое N+1-проблема в GraphQL?
18. Что такое Data Loader?
19. Что такое GraphQL Introspection?
20. Что такое GraphQL Playground?
21. Что такое Type Safety в GraphQL?
22. Что такое Schema Stitching?
23. Что такое Apollo Federation?
24. В чём разница между GraphQL и REST?
25. Когда использовать GraphQL?
Продолжение пути изучения API
Следующий материал в пути изучения API посвящён SOAP vs. REST vs. GraphQL — сравнению трёх основных стилей API с анализом сильных и слабых сторон, а также сценариев применения.
Основные источники
- https://graphql.org/
- https://www.apollographql.com/
- https://github.com/graphql/graphql-spec
- https://graphql-learn.com/


