Розробка GraphQL-резолверів для веб-застосунку

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка GraphQL-резолверів для веб-застосунку
Середня
~3-5 робочих днів
Часті питання

Наші компетенції:

Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розробка GraphQL-резолверів

GraphQL-резолвер—функція, що повертає дані для конкретного поля схеми. Якість резолверів визначає продуктивність і підтримуваність API: наївна реалізація генерує N+1 запити, неправильна—витоки даних між користувачами.

Структура резолвера

// schema.graphql
type Query {
  user(id: ID!): User
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
}

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
  comments: [Comment!]!
}
// resolvers.js
const resolvers = {
  Query: {
    user: async (parent, { id }, context) => {
      // context містить: user (з auth), dataloaders, db
      if (!context.user) throw new AuthenticationError('Not authenticated')
      return context.db.users.findById(id)
    },

    posts: async (parent, { limit, offset }, context) => {
      return context.db.posts.findAll({ limit, offset })
    }
  },

  User: {
    // Резолвер поля posts на типі User
    posts: async (parent, args, context) => {
      // parent—об'єкт User з батьківського резолвера
      // БЕЗ DataLoader це N+1!
      return context.loaders.postsByUserId.load(parent.id)
    }
  },

  Post: {
    author: async (parent, args, context) => {
      return context.loaders.userById.load(parent.author_id)
    },

    comments: async (parent, args, context) => {
      return context.loaders.commentsByPostId.load(parent.id)
    }
  }
}

Контекст і dependency injection

// server.js—формування контексту запиту
import { ApolloServer } from '@apollo/server'
import { DataloaderRegistry } from './dataloaders'

const server = new ApolloServer({ typeDefs, resolvers })

app.use('/graphql', expressMiddleware(server, {
  context: async ({ req }) => {
    // Аутентифікація
    const token = req.headers.authorization?.replace('Bearer ', '')
    const user = token ? await verifyToken(token) : null

    // DataLoader'и створюються per-request (важливо! запобігає кроскористувацькому кешу)
    const loaders = new DataloaderRegistry(db)

    return { user, db, loaders, req }
  }
}))

Авторизація в резолверах

// Функції допомічника авторизації
function requireAuth(context) {
  if (!context.user) {
    throw new GraphQLError('Not authenticated', {
      extensions: { code: 'UNAUTHENTICATED' }
    })
  }
}

function requireRole(context, role) {
  requireAuth(context)
  if (!context.user.roles.includes(role)) {
    throw new GraphQLError('Forbidden', {
      extensions: { code: 'FORBIDDEN' }
    })
  }
}

// Застосування в резолверах
const resolvers = {
  Mutation: {
    deletePost: async (parent, { id }, context) => {
      requireAuth(context)

      const post = await context.db.posts.findById(id)
      if (!post) throw new UserInputError('Post not found')

      // Перевірити власництво: тільки автор або адмін
      if (post.author_id !== context.user.id && !context.user.roles.includes('admin')) {
        throw new GraphQLError('Cannot delete others\' posts', {
          extensions: { code: 'FORBIDDEN' }
        })
      }

      await context.db.posts.delete(id)
      return { success: true }
    }
  }
}

Обробка помилок

import { GraphQLError } from 'graphql'
import { ApolloServerErrorCode } from '@apollo/server/errors'

const resolvers = {
  Mutation: {
    createPost: async (parent, { input }, context) => {
      requireAuth(context)

      // Валідація вхідних даних
      if (!input.title?.trim()) {
        throw new GraphQLError('Title is required', {
          extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT }
        })
      }

      if (input.title.length > 200) {
        throw new GraphQLError('Title too long (max 200)', {
          extensions: {
            code: ApolloServerErrorCode.BAD_USER_INPUT,
            field: 'title'
          }
        })
      }

      try {
        return await context.db.posts.create({
          ...input,
          author_id: context.user.id
        })
      } catch (err) {
        // Не витікайте деталі БД в продакшен
        console.error('DB error creating post:', err)
        throw new GraphQLError('Internal server error', {
          extensions: { code: 'INTERNAL_SERVER_ERROR' }
        })
      }
    }
  }
}

// Форматування помилок перед відправкою клієнту
const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formattedError, error) => {
    // Приховати технічні деталі в продакшені
    if (process.env.NODE_ENV === 'production') {
      if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
        return { message: 'Internal server error' }
      }
    }
    return formattedError
  }
})

Підписки (Subscriptions)

// schema.graphql
type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

// resolvers
import { PubSub } from 'graphql-subscriptions'
const pubsub = new PubSub()

const resolvers = {
  Mutation: {
    createPost: async (parent, { input }, context) => {
      const post = await context.db.posts.create(input)

      // Публікація подій
      pubsub.publish('POST_CREATED', { postCreated: post })

      return post
    }
  },

  Subscription: {
    postCreated: {
      subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
    },

    commentAdded: {
      subscribe: (parent, { postId }) => {
        return pubsub.asyncIterator([`COMMENT_ADDED_${postId}`])
      },
      resolve: (payload) => payload.commentAdded
    }
  }
}

Тестування резолверів

// post.resolver.test.js
describe('Post resolvers', () => {
  const mockDb = {
    posts: {
      findById: jest.fn(),
      delete: jest.fn()
    }
  }

  const mockContext = (overrides = {}) => ({
    user: { id: '1', roles: ['user'] },
    db: mockDb,
    loaders: { userById: { load: jest.fn() } },
    ...overrides
  })

  it('deletePost: owner can delete their post', async () => {
    mockDb.posts.findById.mockResolvedValue({ id: '1', author_id: '1' })
    mockDb.posts.delete.mockResolvedValue(true)

    const result = await resolvers.Mutation.deletePost(
      null, { id: '1' }, mockContext()
    )

    expect(result).toEqual({ success: true })
    expect(mockDb.posts.delete).toHaveBeenCalledWith('1')
  })

  it('deletePost: non-owner gets FORBIDDEN', async () => {
    mockDb.posts.findById.mockResolvedValue({ id: '1', author_id: '99' })

    await expect(
      resolvers.Mutation.deletePost(null, { id: '1' }, mockContext())
    ).rejects.toMatchObject({ extensions: { code: 'FORBIDDEN' } })
  })
})

Терміни

Розробка набору GraphQL-резолверів з авторизацією, DataLoader-інтеграцією та тестами—2–4 робочих дні залежно від кількості типів.