Skip to content
IRC-CodingIRC-Coding
GraphQL API разработкаGraphQL SchemasResolversSubscriptionsApollo GraphQL

Основы GraphQL API: Schemas, Resolvers и Subscriptions

Изучите основы GraphQL API: Schemas, Resolvers, Subscriptions и Apollo. Query, Mutation, Type System с примерами.

S

schutzgeist

34 min read
Основы GraphQL API: Schemas, Resolvers и Subscriptions

Основы разработки 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

Основные компоненты

  1. Определение Schema: GraphQL Type System и SDL Schema — это сердце любого GraphQL API. Он описывает с помощью Schema Definition Language (SDL), какие структуры данных существуют и какие операции допускаются. Он определяет Object Types, Queries, Mutations, Subscriptions, Enums, Interfaces и Unions. Schema служит контрактом между клиентом и сервером.

  2. Выполнение Query: парсинг и выполнение запросов Когда клиент отправляет query, она сначала парсится и валидируется относительно schema. Затем execution engine определяет, какие resolvers в каком порядке вызывать, чтобы вернуть запрошенные данные. При этом происходит обход дерева GraphQL запроса.

  3. Resolver Functions: разрешение данных и бизнес-логика Каждое поле в GraphQL schema может иметь resolver. Resolvers — это функции, которые предоставляют фактические данные для поля. Они могут загружать данные из баз данных, внешних APIs или других источников и часто содержат бизнес-логику приложения.

  4. Subscriptions: обновления в реальном времени на основе WebSocket Subscriptions позволяют серверу отправлять события в реальном времени клиентам. Обычно они основаны на WebSockets. Используются, когда клиентам нужно немедленно узнавать об изменениях, например о новых сообщениях или обновлениях статуса.

  5. Apollo Server: реализация GraphQL сервера Apollo Server — это популярный Node.js framework, который предоставляет GraphQL API на основе schema и resolvers. Он предлагает функции вроде аутентификации, обработки ошибок, subscriptions, кеширования и интеграции плагинов.

  6. Apollo Client: GraphQL клиент с кешированием Apollo Client — это библиотека для JavaScript/TypeScript приложений, позволяющая выполнять GraphQL запросы, кешировать данные и автоматически обновлять UI компоненты при изменении данных. Кеш снижает ненужные сетевые запросы.

  7. Data Loading: оптимизация запросов данных При сложных GraphQL запросах может возникнуть так называемая проблема N+1, когда для каждого объекта выполняется отдельный запрос к БД. Data Loaders объединяют эти запросы и минимизируют количество обращений к базе.

  8. 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

Сравнение

АспектGraphQLREST
Получение данныхИменно необходимые данные固定определённые endpoint’ы
Количество запросовОдин запрос для всех данныхЧасто требуется несколько запросов
ВерсионированиеВерсионирование не требуетсяВерсионирование в URL
ТипизацияСтрогая типизацияСлабая типизация
КешированиеСложный механизм кешированияПростое HTTP-кеширование
Обработка ошибокВозможны частичные ошибкиHTTP-коды состояния

Преимущества и недостатки

Преимущества GraphQL

  • Эффективность: запрашиваем только нужные данные
  • Гибкость: один запрос для сложных данных
  • Типизация: строгая типизация предотвращает ошибки
  • Эволюционируемость: схема расширяется постепенно
  • Реал-тайм: Subscriptions для обновлений в реальном времени

Недостатки

  • Сложность: более крутая кривая обучения чем REST
  • Кеширование: сложнее чем HTTP-кеширование
  • Загрузка файлов: требует расширения схемы
  • Rate Limiting: требует специальной логики ограничения
  • Мониторинг: нужны специализированные инструменты

Часто задаваемые вопросы: GraphQL-схема и ключевые компоненты

1. Что такое GraphQL?

GraphQL это язык запросов для API и серверная среда выполнения. Он позволяет клиентам запрашивать ровно те данные, которые им нужны, и определяет строго типизированную схему.

2. GraphQL это фреймворк?

Нет, GraphQL это не готовый продукт и не фреймворк. Это спецификация и язык запросов. Фреймворки вроде Apollo Server реализуют GraphQL в конкретных языках программирования.

3. Что такое GraphQL-схема?

Схема это контракт между клиентом и сервером. Она описывает на Schema Definition Language какие типы, queries, mutations и subscriptions доступны.

4. Что такое Schema Definition Language?

SDL это декларативный синтаксис для определения GraphQL-схем. Он описывает типы, поля, связи и операции в понятном виде.

5. Что такое Query в GraphQL?

Query это операция чтения. Она получает данные с сервера, не изменяя их. Клиент может указать ровно те поля, которые ему нужны.

6. Что такое Mutation в GraphQL?

Mutation это операция записи. Она изменяет данные на сервере, например создание, обновление или удаление записей.

7. Что такое Subscription?

Subscription это операция реал-тайма. Сервер информирует клиента о событиях, обычно через WebSockets, как только данные меняются.

8. Что такое Resolver?

Resolver это функция, которая предоставляет данные для конкретного поля в схеме. Resolvers получают данные из баз данных или внешних API и могут содержать бизнес-логику.

9. Что такое скалярные типы в GraphQL?

Скалярные типы это элементарные типы данных вроде String, Int, Float, Boolean и ID. Они листья дерева запросов и не содержат вложенные поля.

10. Что такое Object Type?

Object Type это определённый пользователем тип в схеме, который содержит несколько полей. Он представляет сущность вроде User или Post и может быть связан с другими типами.

11. Что такое Enum в GraphQL?

Enum это тип с фиксированным набором допустимых значений. Он используется для типобезопасного определения полей вроде status, roles или categories.

12. Что такое Interface в GraphQL?

Interface определяет набор полей, которые должны быть реализованы несколькими типами. Он позволяет полиморфные запросы различных типов.

13. Что такое Union в GraphQL?

Union это тип, который объединяет несколько Object Types без требования общих полей. При запросе конкретный тип различается через Inline Fragments.

14. Что такое Input Type?

Input Type это специальный тип для входных данных в mutations. Он отличается от Object Types тем, что не имеет resolvers и используется только для входов.

15. Что такое Apollo Server?

Apollo Server это Node.js-фреймворк для GraphQL API. Он основан на схеме и resolvers и предоставляет features вроде аутентификации, кеширования и subscriptions.

16. Что такое Apollo Client?

Apollo Client это JavaScript-библиотека для доступа к GraphQL API. Она кеширует данные, управляет состоянием и автоматически обновляет UI-компоненты при изменении данных.

17. Что такое N+1-проблема в GraphQL?

N+1-проблема возникает когда для списка объектов выполняется отдельный запрос к БД для каждого. Data Loader группирует эти запросы для улучшения производительности.

18. Что такое Data Loader?

Data Loader это утилита, которая позволяет batch-загрузку данных. Она собирает запросы в течение короткого времени и выполняет их в одном запросе к БД.

19. Что такое GraphQL Introspection?

Introspection позволяет клиентам запрашивать схему во время выполнения. Инструменты вроде GraphQL Playground используют introspection для автодополнения и документации.

20. Что такое GraphQL Playground?

GraphQL Playground это интерактивная среда разработки для GraphQL-запросов. Она показывает схему, предоставляет автодополнение и позволяет тестировать queries и mutations.

21. Что такое Type Safety в GraphQL?

Type Safety означает что каждый запрос и результат проверяются против схемы. Это снижает runtime ошибки потому что поля, типы и параметры заранее известны.

22. Что такое Schema Stitching?

Schema Stitching это объединение нескольких GraphQL-схем в одну. Это позволяет сделать распределённые API доступными централизованно.

23. Что такое Apollo Federation?

Apollo Federation это архитектура где несколько microservices каждый предоставляет часть общей схемы. Gateway объединяет эти части в единую GraphQL-схему.

24. В чём разница между GraphQL и REST?

GraphQL использует один endpoint и позволяет точный выбор полей. REST использует несколько endpoints с фиксированными ресурсами. GraphQL предоставляет строгую типизацию, REST полагается на HTTP-методы и коды состояния.

25. Когда использовать GraphQL?

GraphQL хорошо подходит для сложных связей данных, мобильных клиентов с ограниченной пропускной способностью и приложений где клиентам нужны разные представления данных. Для простых API может быть достаточно REST.

Продолжение пути изучения API

Следующий материал в пути изучения API посвящён SOAP vs. REST vs. GraphQL — сравнению трёх основных стилей API с анализом сильных и слабых сторон, а также сценариев применения.

Основные источники

  1. https://graphql.org/
  2. https://www.apollographql.com/
  3. https://github.com/graphql/graphql-spec
  4. https://graphql-learn.com/
Назад к блогу
Share:

Похожие статьи