Apollo Server
Apollo Server - открытый соответствующий спецификации
GraphQL-сервер
, совместимый с любымGraphQL-клиентом
, включая Apollo Client. Это лучший способ создания готового к продакшну, самодокументируемогоGraphQL API
, который может использовать данные из любого источника.
Начало работы
Создание проекта
- Создаем директорию для проекта и заходим в нее:
mkdir graphql-server-example
cd !$
- Инициализируем Node.js-проект:
yarn init -y
# or
npm init -y
Установка зависимостей
Для Сервера требуется 2 зависимости:
apollo-server
- библиотека, позволяющая определять форму данных (data shape) и способы их полученияgraphql
- библиотека для создания GraphQL-схемы и выполнения запросов
yarn add apollo-server graphql
# or
npm i ...
Создаем файл index.js
:
touch index.js
Определение схемы
Каждый сервер GraphQL
(включая Apollo Server
) нуждается в схеме, определяющей структуру данных, которые могут запрашиваться клиентом. В следующем примере мы получаем коллекцию книг по названию и автору:
const { ApolloServer, gql } = require('apollo-server')
// схема - это коллекция типов (`typeDefs`),
// которые определяют "форму" выполняемых запросов
const typeDefs = gql`
# Пример комментария
# Тип "Book" определяет поля, которые можно получить для книги
type Book {
title: String
author: String
}
# Тип "Query" является особым: в нем указываются все запросы, которые
# могут выполняться клиентом, а также тип, возвращаемый запросом. В данном случае
# запрос "books" возвращает массив из 0 или более книг
type Query {
books: [Book]
}
`
Определение набора данных
После определения структуры мы можем определить данные. Сервер может получать данные из любого источника (база данных, REST API
, статический объект - хранилище или даже другой GraphQL-сервер).
Определяем данные в index.js
:
const books = [
{
title: 'The Awakening',
author: 'Kate Chopin'
},
{
title: 'City of Glass',
author: 'Paul Auster'
}
]
Обратите внимание, что оба объекта в массиве соответствуют типу Book
, определенному в схеме.
Определение резолвера
Резолверы сообщают Серверу, как получать данные того или иного типа. Создаем резолвер в index.js
:
// Резолверы определяют способ получения типов, определенных в схеме.
// Данный резолвер извлекает книги из соответствующего массива
const resolvers = {
Query: {
books: () => books,
}
}
Создание экземпляра Сервера
Схема, данные и резолвер должны быть переданы Серверу.
Создаем экземпляр Сервера в index.js
:
// В конструктор Сервера передается 2 аргумента:
// схема и набор резолверов
const server = new ApolloServer({ typeDefs, resolvers })
// Запускаем сервер
server.listen().then(({ url }) => {
console.log(`🚀 Сервер запущен по адресу: ${url}`)
})
Запуск сервера
Все готово к запуску сервера. Выполняем команду:
node index.js
Получаем сообщение:
🚀 Сервер запущен по адресу: http://localhost:4000/
Выполнение запроса
Для выполнения запросов можно использовать ApolloSandbox
.
Переходим по адресу http://localhost:4000
и нажимаем на кнопку Query your server
.
Интерфейс песочницы включает в себя следующее:
- панель операций (Operations) для создания и выполнения запросов (посередине)
- панель ответа (Response) для отображения результатов запросов (справа)
- вкладку для изучения, поиска и настроек схемы (слева)
URL
для подключения к другим серверам (наверху слева)
Запрос на получение книг может выглядеть так:
query GetBooks {
books {
title
author
}
}
Вставляем данную строку в панель операций и нажимаем синюю кнопку наверху справа. Результат запроса отображается в панели ответа.
Одной из ключевых особенностей GraphQL
является то, что клиент может запрашивать только те данные, которые ему нужны. Удалите author
из запроса и выполните его повторно. Вы увидите, что ответ теперь содержит только названия книг.
Основы работы со схемой
Сервер использует схему для описания формы доступных данных. Схема определяет иерархию типов с полями, которые наполняются (populate) данными из серверного хранилища. Она также определяет, какие запросы и мутации могут выполняться клиентом.
Язык для определения схемы
Спецификация GraphQL
определяет высокоуровневый язык определения схемы (schema definition language, SDL), который используется для определения схемы и ее хранения в виде строки.
Пример определения 2 объектных типов:
type Book {
title: String
author: Author
}
type Author {
name: String
books: [Book]
}
Схема определяет коллекцию типов и отношения между ними. В приведенном примере Book
имеет связанного с ней author
, а Author
- список book
.
Обратите внимание: структура схемы не зависит от реализации.
Определение полей
Как правило, типы имеют одно или более поле:
# Тип Book имеет 2 поля: `title` и `author`
type Book {
title: String # возвращает `String`
author: Author # возвращает `Author`
}
Каждое поле возвращает данные определенного типа. Возвращаемым типом может быть scalar
, object
, enum
, union
или interface
(⬇).
Списки
Поле может возвращать список, содержащий элементы определенного типа. Индикатором списка являются квадратные скобки ([]
):
type Author {
name: String
books: [Book] # Список `Books`
}
Поля с нулевым значением
По умолчанию поле может возвращать null
вместо определенного типа. Указание !
после типа делает поле ненулевым - это означает, что такое поле не может возвращать null
:
type Author {
name: String! # Не может возвращать `null`
books: [Book]
}
При попытке сервера вернуть null
для ненулевого поля будет выброшено исключение.
Нулевые значения и списки
В случае со списками символ !
может указываться в двух позициях:
type Author {
books: [Book!]!
# Данный список сам не может быть `null` и его элементы также не могут быть `null`
}
- использование
!
внутри квадратных скобок означает, что возвращаемый список не может содержать элементы, которые имеют значениеnull
- использование
!
после скобок означает, что сам список не может иметь значениеnull
- в любом случае возврат пустого массива будет валидным
Поддерживаемые типы
- Scalar
- Object
- это включает в себя 3 специальных типа корневых (root) операций:
Query
,Mutation
иSubscription
- это включает в себя 3 специальных типа корневых (root) операций:
- Input
- Enum
- Union
- Interface
Скалярные типы
Скалярные типы похожи на примитивы, они всегда разрешаются конкретными данными:
- Int
- Float
- String
- Boolean
- ID (сериализуется как
String
): уникальный идентификатор, который часто используется для повторного получения объекта или в качестве ключа для кеша. Несмотря на то, что он сериализуется в строку, он не обязательно имеет человекочитаемый формат
Объектные типы
Большая часть типов, определяемых в схеме, являются объектными. Такие типы содержат коллекцию полей, каждое из которых имеет собственный тип.
Два объектных типа могут включать друг друга в качестве полей:
type Book {
title: String
author: Author
}
type Author {
name: String
books: [Book]
}
Тип Query
Тип Query
- это специальный объектный тип, который определяет все конечные точки верхнего уровня, которые могут выполняться клиентом на сервере.
Каждое поле типа Query
определяет название и возвращаемый тип определенной конечной точки:
type Query {
books: [Book]
authors: [Author]
}
В REST API
книги и авторы, скорее всего, будут возвращаться разными конечными точками (например, /api/books
и /api/authors
). Гибкость GraphQL
позволяет получить эти ресурсы с помощью одного запроса.
Формирование запроса
Запрос должен соответствовать форме запрашиваемых объектных типов:
query GetBooksAndAuthors {
books {
title
}
authors {
name
}
}
В данном случае ответ сервера будет полностью соответствовать структуре запроса:
{
"data": {
"books": [
{
"title": "City of Glass"
},
...
],
"authors": [
{
"name": "Paul Auster"
},
...
]
}
}
Поскольку тип Book
имеет поле author
с типом Author
, запрос может выглядеть так:
query GetBooks {
books {
title
author {
name
}
}
}
Ответ сервера будет соответствовать структуре запроса:
{
"data": {
"books": [
{
"title": "City of Glass",
"author": {
"name": "Paul Auster"
}
},
...
]
}
}
Тип Mutation
Тип Mutation
определяет конечные точки для операций записи.
type Mutation {
addBook(title: String, author: String): Book
}
Данный тип Mutation
определяет единственную доступную мутацию addBook
. Эта мутация принимает 2 аргумента (title
и author
) и возвращает созданный объект Book
, соответствующий структуре, определенной в схеме.
Формирование мутации
Следующая мутация со здает новую книгу и запрашивает определенные поля созданного объекта:
mutation CreateBook {
addBook(title: "Fox in Socks", author: "Dr. Seuss") {
title
author {
name
}
}
}
Как и в случае с запросами, ответ сервера будет полностью соот ветствовать структуре мутации:
{
"data": {
"addBook": {
"title": "Fox in Socks",
"author": {
"name": "Dr. Seuss"
}
}
}
}
Одна операция мутации может содержать несколько верхнеуровневых полей типа Mutation
. Это означает, что одна мутация может приводить к выполнению нескольких операций записи. Во избежание гонки условий (race conditions) верхнеуровневые поля типа Mutation
разрешаются последовательно в том порядке, в котором они определены (другие поля разрешаются параллельно).
Типы для ввода данных
Типы для ввода данных (input types) - это специальные объектные типы, позволяющие передавать данные как аргументы полей:
input BlogPostContent {
title: String
body: String
}
Каждое поле такого типа может быть только скалярным типом, перечислением или другим типом для ввода:
input BlogPostContent {
title: String
body: String
media: [MediaDetails!]
}
input MediaDetails {
format: MediaFormat!
url: String!
}
enum MediaFormat {
IMAGE
VIDEO
}
После определения типа для ввода, любые поля объектных типов могут принимать этот тип в качестве аргумента:
type Mutation {
createBlogPost(content: BlogPostContent!): Post
updateBlogPost(id: ID!, content: BlogPostContent!): Post
}
Перечисления
Типы-перечисления похожи на скалярные типы, но все допустимые значения таких типов определяются в схеме в явном виде:
enum AllowedColor {
RED
GREEN
BLUE
}
Перечисления используются в ситуациях, когда пользователь должен выбрать одно значение из указанных.
Перечисления могут использоваться вместо скалярных типов, поскольку они сериализуются в строки:
type Query {
favoriteColor: AllowedColor # Значение, возвращаемое перечислением
avatar(borderColor: AllowedColor): String # Аргумент, передаваемый перечислению
}
Запрос может выглядеть следующим образом:
query GetAvatar {
avatar(borderColor: RED)
}
Описания (docstrings
)
SDL
поддерживает так называемые описания - markdown-подобные строки:
"Описание типа"
type MyObjectType {
"""
Описание поля
Поддерживается **многострочное** описание [API](http://example.com)!
"""
myField: String!
otherField(
"Описание аргумента"
arg: Int
)
}
Соглашение об именовании
Разработчики Apollo
рекомендуют придерживаться следующих правил именования сущностей:
- поля должны именоваться в стиле
camelCase
- типы - в стиле
PascalCase
- названия перечислений - в стиле
PascalCase
- значения перечислений - в стиле
ALL_CAPS
(подобно константам)
Проектирование схемы на основе запросов
Схема должна проектироваться на основе того, как данные используются, а не на основе того, как они хранятся.
Предположим, что мы разрабатываем приложение, которое отображает список приближающихся событий в нашей локации. Мы хотим, чтобы приложение показывало название, дату, локацию каждого события, а также погоду.
Мы хотим, чтобы наше приложение имело возможность выполнять такие запросы:
query EventList {
upcomingEvents {
name
date
location {
name
weather {
temperature
description
}
}
}
}
Поскольку нам известна структура данных, которые используются в приложении, мы можем спроектировать схему:
type Query {
upcomingEvents: [Event!]!
}
type Event {
name: String!
date: String!
location: Location
}
type Location {
name: String!
weather: WeatherInfo
}
type WeatherInfo {
temperature: Float
description: String
}
Каждый тип может заполняться данными из разных источников. Например, поля name
и date
типа Event
могут заполняться данными из нашей БД, а тип WeatherInfo
- из стороннего интерфейса.
Проектирование мутаций
В ответ мутации рекомендуется включать обновленные данные.
Пример мутации для обновления поля email
типа User
:
type Mutation {
# Данная мутация принимает параметры `id` и `email`
# и возвращает `User`
updateUserEmail(id: ID!, email: String!): User
}
type User {
id: ID!
name: String!
email: String!
}
Структура мутации, выполняемой клиентом:
mutation updateMyUser {
updateUserEmail(id: 1, email: "jane@mail.com"){
id
name
email
}
}
После того, как сервер выполнит мутацию и сохранит новый адрес электронной почты пользователя, он ответит клиенту следующим:
{
"data": {
"updateUserEmail": {
"id": "1",
"name": "Jane Doe",
"email": "jane@mail.com"
}
}
}
Формирование ответа мутации
Одна мутация может модифицировать несколько типов или несколько экземпляров одного типа.
Также мутации сильнее подвержены ошибкам, чем запросы.
Для решения этих проблем рекомендуется определять в схеме интерфейс MutationResponse
вместе с коллекцией объектных типов, реализующих этот интерфейс (по одному для каждой мутации).
Пример такого интерфейса:
interface MutationResponse {
code: String!
success: Boolean!
message: String!
}
Пример объекта, реализующего этот интерфейс:
type UpdateUserEmailMutationResponse implements MutationResponse {
code: String!
success: Boolean!
message: String!
user: User
}
Если возвращаемым типом мутации updateUserEmail
вместо User
будет UpdateUserEmailMutationResponse
, то ответ сервера будет следующим:
{
"data": {
"updateUser": {
"code": "200",
"success": true,
"message": "Email пользователя был успешно обновлен",
"user": {
"id": "1",
"name": "Jane Doe",
"email": "jane@mail.com"
}
}
}
}
code
- строка, представляющая статус передачи данных. Вы можете думать об этом, как о HTTP-статус-кодеsuccess
- индикатор успешного выполнения мутацииmessage
- строка в человекочитаемом формате, описывающая результат мутацииuser
- новый пользователь
Если мутация модифицирует несколько типов, ее реализация может включать отдельные поля для каждого типа:
type LikePostMutationResponse implements MutationResponse {
code: String!
success: Boolean!
message: String!
post: Post
user: User
}
Поскольку наша гипотетическая мутация likePost
модифицирует поля Post
и User
, объект ответа будет включать поля из обоих типов:
{
"data": {
"likePost": {
"code": "200",
"success": true,
"message": "Спасибо!",
"post": {
"id": "123",
"likes": 5040
},
"user": {
"likedPosts": ["123"]
}
}
}
}
Объединения и интерфейсы
Объединения и интерфейсы - это абстрактные типы, которые позволяют полю возвращать один из нескольких объектных типов.
Объединения
В случае с объединением мы определяем, какие объектные типы оно в себя включает:
union Media = Book | Movie
Поле может возвращать объединение или список объединений. В данном случае оно может возвращать любой объектный тип из объединения:
type Query {
allMedia: [Media] # Данный список может включать как объект `Book`, так и объект `Movie`
}
Обратите внимание: все типы в объединении должны быть объектами.
Пример
В следующем примере мы определяем объединение SearchResult
, которое может возвращать Book
или Author
:
union SearchResult = Book | Author
type Book {
title: String!
}
type Author {
name: String!
}
type Query {
search(contains: String): [SearchResult!]
}
SearchResult
включает Query.search
, что позволяет возвращать список, содержащий как Book
, так и Author
.
Получение объединения
Клиент не знает, какие объекты будут возвращены полем, содержащим объединение. Поэтому запрос может включать вложенные поля с несколькими возможными типами:
query GetSearchResults {
search(contains: "Shakespeare") {
# Запрос поля `__typename` рекомендуется включать всегда,
# это особенно важно при запросе поля, которое
# может возвращать один из нескольких типов
__typename
... on Book {
title
}
... on Author {
name
}
}
}
Данный запрос использует встроенные фрагменты (inline fragments) для получения заголовка (если типом результата является книга) или имени (если типом результата является автор).
Результат может выглядеть так:
{
"data": {
"search": [
{
"__typename": "Book",
"title": "The Complete Works of William Shakespeare"
},
{
"__typename": "Author",
"name": "William Shakespeare"
}
]
}
}
Разрешение объединения
Для полноценного разрешения объединения Серверу необходимо определить, какие типы оно возвращает. Для этого в карте резолверов (resolver map) определяется функция __resolveType
.
__resolveType
определяет тип объекта и возвращает название типа в виде строки. Она может использоваться для реализации такой логики, как:
- проверка наличия или отсутствия полей, которые являются уникальными для определенного типа, входящего в состав объединения
- использование
instanceof
, если JavaScript-объект связан с объектным типомGraphQL
const resolvers = {
SearchResult: {
__resolveType(obj, context, info){
// Только `Author` имеет поле `name`
if(obj.name){
return 'Author'
}
// Только `Book` имеет поле `title`
if(obj.title){
return 'Book'
}
return null // Выбрасывается `GraphQLError`
},
},
Query: {
search: () => { ... }
}
}
const server = new ApolloServer({
typeDefs,
resolvers,
})
server.listen().then(({ url }) => {
console.log(`🚀 Сервер запущен по адресу: ${url}`)
})
Обратите внимание: если функция __resolveType
возвращает значение, которое не является названием валидного типа, соответствующая операция заканчивается ошибкой.
Интерфейс
Интерфейс определяет набор полей, которые могут включать несколько объектных типов:
interface Book {
title: String!
author: Author!
}
Если объектный тип реализует (implements) интерфейс, он должен включать все его поля:
type Textbook implements Book {
title: String! # Должно быть включено
author: Author! # Должно быть включено
courses: [Course!]!
}
Поле может возвращать интерфейс или список интерфейсов. В этом случае поле может возвращать любой объектный тип, реализующий интерфейс:
type Query {
books: [Book!] # Может включать объекты `Textbook`
}
Пример
В следующем примере мы определяем интерфейс Book
и 2 объекта, реализующих этот интерфейс:
interface Book {
title: String!
author: Author!
}
type Textbook implements Book {
title: String!
author: Author!
courses: [Course!]!
}
type ColoringBook implements Book {
title: String!
author: Author!
colors: [String!]!
}
type Query {
books: [Book!]!
}
Query.books
возвращает список, который может включать как Textbook
, так и ColoringBook
.
Получение интерфейса
Если поле возвращает интерфейс, клиент может запрашивать любые вложенные поля, включенные в этот интерфейс:
query GetBooks {
books {
title
author
}
}
Клиент также может запрашивать поля, которые в интерфейсе отсутствуют:
query GetBooks {
books {
__typename
title
... on Textbook {
courses { # Имеется только в `Textbook`
name
}
}
... on ColoringBook {
colors # Имеется только в `ColoringBook`
}
}
}
Результат этого запроса может выглядеть так:
{
"data": {
"books": [
{
"__typename": "Textbook",
"title": "Wheelock's Latin",
"courses": [
{
"name": "Latin I"
}
]
},
{
"__typename": "ColoringBook",
"title": "Oops All Water",
"colors": [
"Blue"
]
}
]
}
}
Разрешение интерфейса
Пример использования функции __resolveType
для определенного выше интерфейса Book
:
const resolvers = {
Book: {
__resolveType(book, context, info){
// Только `Textbook` имеет поле `courses`
if(book.courses){
return 'Textbook'
}
// Только `ColoringBook` имеет поле `colors`
if(book.colors){
return 'ColoringBook'
}
return null // Выбрасывается `GraphQLError`
},
},
Query: {
schoolBooks: () => { ... }
}
}
Кастомные скалярные значения
Спецификация GraphQL
включает дефолтные скалярные типы Int
, Float
, String
, Boolean
и ID
. Несмотря на то, что этих типов в большинстве случаев достаточно, нам могут потребоваться другие типы данных (такие как Date
) или нам может потребоваться валидация существующего типа. Для этого мы можем определить кастомный скалярный тип.
Определение кастомного скалярного типа
Для определения кастомного скалярного типа следует добавить его в схему следующим образом:
scalar MyCustomScalar
После этого MyCustomScalar
может использоваться где угодно (например, как тип поля объекта, поля для ввода данных или в качестве аргумента).
Однако, Серверу нужно знать, к ак взаимодействовать с этим новым типом.
Определение логики кастомного скалярного типа
Определение логики кастомного скалярного типа включает в себя определение следующего:
- как скалярное значение представлено на сервере
- как это представление сериализуется в совместимый с
JSON
тип - как JSON-представление десериализуется
Данная логика определяется в экземпляре класса GraphQLScalarType
.
Пример: скалярный тип Date
В следующем примере мы исходим из предположения, что дата представлена на сервере в виде JavaScript-объекта Date
:
const { GraphQLScalarType, Kind } = require('graphql');
const dateScalar = new GraphQLScalarType({
name: 'Date',
description: 'Кастомный скалярный тип Date',
serialize(value) {
return value.getTime() // Конвертируем исходящий объект `Date` в целое число для `JSON`
},
parseValue(value) {
return new Date(value) // Конвертируем входящее целое число в `Date`
},
parseLiteral(ast) {
if (ast.kind === Kind.INT) {
return new Date(parseInt(ast.value, 10)) // Конвертируем строку `AST` сначала в целое число, затем в `Date`
}
return null // Невалидное значение (не целое число)
}
})
При инициализация используются следующие методы:
serialize
parseValue
parseLiteral
serialize
Метод serialize
преобразует серверное представление кастомного скалярного типа в совместимый с JSON
формат, чтобы Сервер мог включить его в ответ.
parseValue
Метод parseValue
преобразует значение скалярного типа в формате JSON
в его серверное представление перед добавлением в аргументы (args
), передаваемые резолверу.
Сервер вызывает этот метод при предоставлении скалярного типа в качестве переменной для аргумента (когда скалярный тип предоставляется в качестве аргумента в строку операции, вызывается метод parseLiteral
).
parseLiteral
Когда входящая строка запроса включает скалярный тип как значение аргумента, это значение является частью абстрактного синтаксического дерева (AST) документа запроса. Сервер вызывает метод parseLiteral
для преобразование AST в серверное представление скалярного типа.
Передача кастомного скаляра Серверу
После определения экземпляра GraphQLScalarType
он включается в карту, содержащую резолверы для других типов и полей, определенных в схеме:
const { ApolloServer, gql } = require('apollo-server')
const { GraphQLScalarType, Kind } = require('graphql')
const typeDefs = gql`
scalar Date
type Event {
id: ID!
date: Date!
}
type Query {
events: [Event!]
}
`
const dateScalar = new GraphQLScalarType({
// ...
})
const resolvers = {
Date: dateScalar
// другие резолверы
}
const server = new ApolloServer({
typeDefs,
resolvers
})
Пример: фильтрация чисел
В следующем примере мы определяем скаляр Odd
, который может содержать только нечетные целые числа:
const { ApolloServer, gql, UserInputError } = require('apollo-server')
const { GraphQLScalarType, Kind } = require('graphql')
// Основная схема
const typeDefs = gql`
scalar Odd
type Query {
# Выводит переданное нечетное целое число
echoOdd(odd: Odd!): Odd!
}
`
// Функция для проверки "нечетности" числа
function oddValue(value) {
if (typeof value === "number" && Number.isInteger(value) && value % 2 !== 0) {
return value;
}
throw new UserInputError("Переданное значение не является целым нечетным числом");
}
const resolvers = {
Odd: new GraphQLScalarType({
name: 'Odd',
description: 'Кастомный скалярный тип для целых нечетных чисел',
parseValue: oddValue,
serialize: oddValue,
parseLiteral(ast) {
if (ast.kind === Kind.INT) {
return oddValue(parseInt(ast.value, 10))
}
throw new UserInputError("Переданное значение не является целым нечетным числом")
},
}),
Query: {
echoOdd(_, {odd}) {
return odd
}
}
}
const server = new ApolloServer({ typeDefs, resolvers })
server.listen().then(({ url }) => {
console.log(`🚀 Сервер запуще н по адресу: ${url}`)
})
Импорт сторонних скаляров
Кастомные скаляры из сторонней библиотеки могут импортироваться и использоваться как любые другие типы.
Например, пакет graphql-type-json
определяет объект GraphQLJSON
, который является экземпляром GraphQLScalarType
. Он может использоваться для определения скаляра JSON
, принимающего любое значение, которое является валидным JSON
.
Устанавливаем эту библиотеку:
yarn add graphql-type-json
Получаем GraphQLJSON
и добавляем его в карту резолверов:
const { ApolloServer, gql } = require('apollo-server')
const GraphQLJSON = require('graphql-type-json')
const typeDefs = gql`
scalar JSON
type MyObject {
myField: JSON
}
type Query {
objects: [MyObject]
}
`
const resolvers = {
JSON: GraphQLJSON
// другие резолверы
}
const server = new ApolloServer({ typeDefs, resolvers })
server.listen().then(({ url }) => {
console.log(`🚀 Сервер запущен по адресу: ${url}`)
})
Директивы
Директива декорирует часть схемы или операции, добавляя в них дополнительную логику. Инструменты, такие как Apollo Server
, могут читать директивы и выполнять соответствующую логику.
Директивы объявляются следующим образом:
type ExampleType {
oldField: String @deprecated(reason: "Используется `newField`.")
newField: String
}
В приведенном примере используется одна из дефолтных директив - @deprecated
. Она показывает, что для директив характерно следующее:
- директивы могут принимать аргументы (в данном случае
reason
) - директивы указываются после декорируемых ими полей (
oldField
в данном случае)
Существует еще 2 дефолтные директивы:
@skip(if: Boolean)
- если имеет значениеtrue
, декорируемое поле или фрагмент в операции не разрешаются сервером@include(if: Boolean)
- если имеет значениеfalse
, декорируемое поле или фрагмент в операции не разрешаются сервером
Имеется возможность определения собственных директив.
Получение данных
Резолверы
Резолвер - это функция для заполнения данными определенного поля схемы.
Если мы не определяем резолвер для определенного поля, Сервер автоматически создает его для такого поля.
Определение резолвера
Предположим, что у нас имеется такая схема:
const resolvers = {
Query: {
numberSix() {
return 6
},
numberSeven() {
return 7
}
}
};
Как видно из примера:
- резолверы определяются в виде простого объекта (
resolvers
) - данный объект называется картой резолверов (resolver map) - карта резолверов включает поля верхнего уровня, соответствующие типам, определенным в схеме (например,
Query
) - каждый резолвер принадлежит типу соответствующего поля
Обработка аргументов
Предположим, что наша схема выглядит так:
type User {
id: ID!
name: String
}
type Query {
user(id: ID!): User
}
Мы хотим иметь возможность получать user
по id
.
Допустим, что у нас имеется такой массив:
const users = [
{
id: '1',
name: 'Elizabeth Bennet'
},
{
id: '2',
name: 'Fitzwilliam Darcy'
}
];
Резолвер для поля user
может выглядеть так:
const resolvers = {
Query: {
user(parent, args, context, info) {
return users.find(user => user.id === args.id);
}
}
}
Как видно из примера:
- резолвер принимает 4 опциональных аргумента:
parent, args, context, info
- аргумент
args
- это объект, содержащий все переданные аргументы
Обратите внимание: в примере мы не определяем резолверы для полей User
(id
и name
). Это возможно благодаря тому, что Сервер автоматически определяет для них дефолтные резолверы.
_Передача резолверов Серверу
После определения резолве ры передаются в конструктор ApolloServer
в качестве свойства resolvers
, наряду с определением схемы (свойство typeDefs
):
const { ApolloServer, gql } = require('apollo-server');
// Статичные данные
const books = [
{
title: 'The Awakening',
author: 'Kate Chopin',
},
{
title: 'City of Glass',
author: 'Paul Auster',
}
]
// Определение схемы
const typeDefs = gql`
type Book {
title: String
author: String
}
type Query {
books: [Book]
}
`
// Карта резолверов
const resolvers = {
Query: {
books() {
return books
}
}
}
// Передаем схему и резолверы в конструктор `ApolloServer`
const server = new ApolloServer({ typeDefs, resolvers })
// Запускаем сервер
server.listen().then(({ url }) => {
console.log(`🚀 Сервер запущен по адресу: ${url}`)
})
Цепочка резолверов
Когда Сервер разрешает поле, содержащее объектный тип, он затем разрешает одно или более поле этого объекта. Эти вложенные поля, в свою очередь, также могут содержать объектные типы. Так будет продолжаться до тех пор, пока Сервер не доберется до скалярного значения или перечисления. Количество уровней вложенности в данном случае зависит от схемы. Такая цепочка объектов называется цепочкой резолверов.
Предположим, что у нас имеется такая схема:
# У библиотеки есть филиал и книги
type Library {
branch: String!
books: [Book!]
}
# У книги есть название и автор
type Book {
title: String!
author: Author!
}
# У автора есть имя
type Author {
name: String!
}
# В ответ на запрос может возвращаться список библиотек
type Query {
libraries: [Library]
}
Вот один из примеров валидного запроса:
query GetBooksByLibrary {
libraries {
books {
author {
name
}
}
}
}
Эти резолверы разрешаются в указанном порядке. Возвращаемое резолвером значение передается следующему резолверу через аргумент parent
.
Полный пример:
const { ApolloServer, gql } = require('apollo-server')
const libraries = [
{
branch: 'downtown'
},
{
branch: 'riverside'
}
]
// Поле `branch` определяет, в какой библиотеке хранится книга
const books = [
{
title: 'The Awakening',
author: 'Kate Chopin',
branch: 'riverside'
},
{
title: 'City of Glass',
author: 'Paul Auster',
branch: 'downtown'
}
]
// Определение схемы
const typeDefs = gql`
type Library {
branch: String!
books: [Book!]
}
type Book {
title: String!
author: Author!
}
type Author {
name: String!
}
type Query {
libraries: [Library]
}
`
// Карта резолверов
const resolvers = {
Query: {
libraries() {
return libraries
}
},
Library: {
books(parent) {
// Фильтруем массив книг из указанного филиала
return books.filter(book => book.branch === parent.branch);
}
},
Book: {
// Родительский резолвер (`Library.books`) возвращает объект с именем автора
// в поле `author`. Возвращаем объект в формате `JSON`, содержащий имя,
// поскольку значением данного поля должен быть объект
author(parent) {
return {
name: parent.author
}
}
}
// Поскольку `Book.author` возвращает объект с полем `name`,
// для `Author.name` будет создан дефолтный резолвер.
// Нам не нужно определять его самостоятельно
}
// Передаем схему и резовлеры в конструктор `ApolloServer`
const server = new ApolloServer({ typeDefs, resolvers })
// Запускаем сервер
server.listen().then(({ url }) => {
console.log(`🚀 Сервер запущен по адресу: ${url}`)
})
Если после этого мы обновим запрос для получения названия книги:
query GetBooksByLibrary {
libraries {
books {
title
author {
name
}
}
}
}
Ветки, находящиеся на одном уровне, выполняются параллельно.
Аргументы резолверов
Как было отмечено выше, резолверы принимают 4 аргумента: parent, args, context, info
.
parent
- значение, возвращаемое резолвером и передаваемое дочернему резолверуargs
- объект, содержащий аргументы, переданные для поляcontext
- объект, который является общим для всех резолверов определенной операции. Может использоваться для хранения состояния, данных для аутентификации, индикатора загрузки и т.д.info
- объект, содержащий информацию о состоянии выполняемой операции, включая название поля, путь поля, начиная от корневого и т.д.
Аргумент context
Данный аргумент может использоваться для передачи данных, которые могут потребоваться определенному резолверу, например, информации об авторизации пользователя, соединении с базой данных, кастомной функции для получения данных и т.п.
Для передачи контекста в конструктор ApolloServer
передается функция инициализации context
. Эта функция вызывается при каждом запросе, поэтому контекст может определяться на основе запроса (например, на основе HTTP-заголовков):
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({
authScope: getScope(req.headers.authorization)
})
})
// Пример резолвера
(parent, args, context, info) => {
if(context.authScope !== ADMIN) throw new AuthenticationError('Пользователь не имеет статуса администратора');
// ...
}
Инициализация контекста может быть асинхронной:
context: async () => ({
db: await client.connect(),
})
// Резолвер
(parent, args, context, info) => {
return context.db.query('SELECT * FROM table_name');
}
Дефолтные резолверы
Как было отмечено выше, при отсутствии кастомного резолвера, для поля автоматически создается дефолтный резолвер.
Рассмотрим такую схему:
type Book {
title: String
}
type Author {
books: [Book]
}
Если резолвер поля books
возвращает массив объектов, каждый из которых содержит поле title
, тогда мы можем использовать дефолтный резолвер для поля title
. Дефолтный резолвер вернет parent.title
.
Источники данных
Источники данных (data sources) - это классы, которые Сервер может использовать для инкапсуляции получения данных из определенного источника, например, базы данных или REST API
.
Использовать источники данных необязательно, но настоятельно рекомендуется.
Реализации с открытым исходным кодом
Все реализации источников данных расширяют общий абстрактный класс DataSource
, входящий в состав пакета apollo-datasource
.
На сегодняшний день существуют следующие реализации источник ов данных:
RESTDataSource
- REST APIHTTPDataSource
- HTTP/REST API (свежая альтернативаRESTDataSource
, разработанная сообществом)SQLDataSource
- SQL БДMongoDataSource
- MongoDBCosmosDataSource
- Azure Cosmos БДFirestoreDataSource
- Cloud Firestore
При отсутствии нужного источника данных, его можно создать самостоятельно.
Добавление источника данных
Подклассы DataSource
передаются в конструктор ApolloServer
:
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => {
return {
moviesAPI: new MoviesAPI(),
personalizationAPI: new PersonalizationAPI()
}
}
})
- как мы видим, настройка
dataSources
- это функция, возвращающая объект с экземплярами подклассовDataSource
(в данном случаеMoviesAPI
иPersonalizationAPI
) - Сервер вызывает эту операцию для каждого входящей операции. Он автоматически присваивает возвращаемый объект полю
dataSources
объекта контекста, передаваемого резолверам - функция должна возвращать новый экземпляр каждого источника данных для каждой операции. Если несколько операций использует один источник, результаты нескольких операций можно комбинировать
После этого резолверы могут получать доступ к источникам данных через распределенный объект context
для получения данных:
const resolvers = {
Query: {
movie: async (_, { id }, { dataSources }) => {
return dataSources.moviesAPI.getMovie(id);
},
mostViewedMovies: async (_, __, { dataSources }) => {
return dataSources.moviesAPI.getMostViewedMovies();
},
favorites: async (_, __, { dataSources }) => {
return dataSources.personalizationAPI.getFavorites();
}
}
}
Кеширование
По умолчанию источники данных используют InMemoryLRUCache
для хранения результатов предыдущих запросов.
При инициализации Сервера в его конструктор можно передать другой объект кеша, реализующий интерфейс KeyValueCache
. Это позволяет хранить кеш в распределенных хранилищах типа Memcached
или Redis
.
Использование Memcached/Redis
в качестве хранилища для кеша
При запуске нескольких экземпляров сервера следует использовать распределенный кеш. Это позволяет одному экземпляру сервера использовать кешированные результаты другого экземпляра.
Сервер поддерживает использование Memcached
и Redis
в качестве хранилищ для кеша через пакеты apollo-server-cache-memcached
и apollo-server-cache-redis
, соответственно.
Memcached
const { MemcachedCache } = require('apollo-server-cache-memcached')
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new MemcachedCache(
['memcached-server-1', 'memcached-server-2', 'memcached-server-3'],
{ retries: 10, retry: 10000 } // Настройки
)
dataSources: () => ({
moviesAPI: new MoviesAPI(),
})
})
Redis
const { BaseRedisCache } = require('apollo-server-cache-redis')
const Redis = require('ioredis')
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new BaseRedisCache({
client: new Redis({
host: 'redis-server'
})
}),
dataSources: () => ({
moviesAPI: new MoviesAPI()
})
})
RESTDataSource
RESTDataSource
помогает получать данные из REST API
.
Устанавливаем пакет apollo-datasource-rest
:
yarn add apollo-datasource-rest
Пример
Пример по дкласса RESTDataSource
, определяющего 2 метода для получения данных - getMovie
и getMostViewedMovies
:
const { RESTDataSource } = require('apollo-datasource-rest')
class MoviesAPI extends RESTDataSource {
constructor() {
// Всегда вызываем `super()`
super()
// Устанавливаем базовый `URL` для `REST API`
this.baseURL = 'https://movies-api.example.com/'
}
async getMovie(id) {
// Отправляем GET-запрос request по указанному адресу
return this.get(`movies/${id}`)
}
async getMostViewedMovies(limit = 10) {
const data = await this.get('movies', {
// Параметры строки запроса
per_page: limit,
order_by: 'most_viewed'
})
return data.results
}
}
Методы HTTP
RESTDataSource
включает методы, соответствующие наиболее распространенным HTTP-методам: get, post, put, patch, delete
.
Пример каждого из них:
class MoviesAPI extends RESTDataSource {
constructor() {
super()
this.baseURL = 'https://movies-api.example.com/'
}
// GET
async getMovie(id) {
return this.get(
`movies/${id}` // путь
)
}
// POST
async postMovie(movie) {
return this.post(
`movies`, // путь
movie // тело запроса
)
}
// PUT
async newMovie(movie) {
return this.put(
`movies`, // путь
movie // тело запроса
)
}
// PATCH
async updateMovie(movie) {
return this.patch(
`movies`, // путь
{ id: movie.id, movie } // тело запроса
)
}
// DELETE
async deleteMovie(movie) {
return this.delete(
`movies/${movie.id}` // путь
)
}
}
Параметры методов
Все методы в качестве первого параметра принимают относительный путь для отправки запроса.
Второй параметр зависит от метода:
- для методов, включающих тело запроса (
post, put, patch
) - вторым параметром является тело запроса - для методов без тела запроса - второй параметр является объектом с параметрами строки запроса в виде пар ключ/значение
В качестве третьего параметра все методы принимают объект init
, позволяющий устанавливать дополнительные настройки (такие как заголовки и рефереры).
Перехват запросов
Метод willSendRequest
позволяет модифицировать исходящий запрос перед его отправкой. Этот метод можно использовать, например, для добавления заголовков или параметров строки запроса. В основном, он используется для авторизации и т.п.
Источники данных также имеют доступ к контексту операций, что может использоваться для хранения токенов или другой чувствительной информации.
Установка заголовка:
class PersonalizationAPI extends RESTDataSource {
willSendRequest(request) {
request.headers.set('Authorization', this.context.token)
}
}
Добавление параметров строки запроса:
class PersonalizationAPI extends RESTDataSource {
willSendRequest(request) {
request.params.set('api_key', this.context.token)
}
}
Использование TypeScript
import { RESTDataSource, RequestOptions } from 'apollo-datasource-rest'
class PersonalizationAPI extends RESTDataSource {
baseURL = 'https://personalization-api.example.com/'
willSendRequest(request: RequestOptions) {
request.headers.set('Authorization', this.context.token)
}
}
Динамическое разрешение URL
В некоторых случаях может потребоваться устанавливать URL
на основе окружения или значений контекста. Для этого можно перезаписать resolveURL
:
async resolveURL(request: RequestOptions) {
if (!this.baseURL) {
const addresses = await resolveSrv(request.path.split("/")[1] + ".service.consul")
this.baseURL = addresses[0]
}
return super.resolveURL(request)
}
Использование DataLoader
DataLoader
- это утилита для удаления дубликатов (дедупликации, deduplication) и пакетирования (батчинга, batching) объекта, загружаемого их хранилища данных. Она предоставляет мемоизированный кеш, позволяющий избежать повторных загрузок одного объекта в процессе выполнения запроса. Она также комбинирует загрузки в процессе одного тика (tick) цикла событий (event loop) в пакетный запрос, что позволяет получать несколько объектов за раз.
Основной задачей DataLoader
является батчинг, а не кеширование, поэтому данная утилита не очень полезна при получении данных из REST API
.
Обработка ошибок
При возникновении о шибки Сервер возвращает ответ, содержащий массив errors
. Каждая ошибка в этом массиве содержит свойство extensions
с дополнительной информацией, такой как code
и exception.stacktrace
(в режиме для разработки).
Пример ошибки, возникшей из-за опечатки в __typename
:
{
"errors":[
{
"message":"Cannot query field \"__typenam\" on type \"Query\".",
"locations":[
{
"line":1,
"column":2
}
],
"extensions":{
"code":"GRAPHQL_VALIDATION_FAILED",
"exception":{
"stacktrace":[
"GraphQLError: Cannot query field \"__typenam\" on type \"Query\".",
" at Object.Field (/my_project/node_modules/graphql/validation/rules/FieldsOnCorrectTypeRule.js:48:31)",
" ...и т.д.",
]
}
}
}
]
}
Сервер предоставляет подклассы класса ApolloError
из пакета apollo-server-errors
(такие как SyntaxError
и ValidationError
), которые могут использоваться для отладки.
Коды ошибок
GRAPHQL_PARSE_FAILED
(SyntaxError
) - строка операции содержит синтаксическую ошибкуGRAPHQL_VALIDATION_FAILED
(ValidationError
) - операция не соответствует схемеBAD_USER_INPUT
(UserInputError
) - операция включает невалидное значение для аргумента поляUNAUTHENTICATED
(AuthenticationError
) - сервер не смог выполнить аутентификацию в запрашиваемом источнике данныхFORBIDDEN
(ForbiddenError
) - сервер не имел полномочий на доступ к запрашиваемому источнику данныхPERSISTED_QUERY_NOT_FOUND
(PersistedQueryNotFoundError
) - клиент отправил хеш строки запроса для выполнения автоматически сохраняемого запроса, но запрос в таком кеше отсутствуетPERSISTED_QUERY_NOT_SUPPORTED
(PersistedQueryNotSupportedError
) - на сервере отключены автоматически сохраняемые запросыINTERNAL_SERVER_ERROR
(None
) - неизвестная ошибка
Вызов ошибки
В следующем примере резолвер выбрасывает UserInputError
, если id
пользователя меньше 1
:
const {
ApolloServer,
gql,
UserInputError
} = require('apollo-server')
const typeDefs = gql`
type Query {
userWithID(id: ID!): User
}
type User {
id: ID!
name: String!
}
`
const resolvers = {
Query: {
userWithID: (parent, args, context) => {
if (args.id < 1) {
throw new UserInputError('Неверное значение аргумента');
}
// ...
}
}
}
Кастомизация ошибки
В следующем примере в объект ошибки добавляется поле argumentName
с названием невалидного аргумента:
const {
ApolloServer,
gql,
UserInputError
} = require('apollo-server')
const typeDefs = gql`
type Query {
userWithID(id: ID!): User
}
type User {
id: ID!
name: String!
}
`
const resolvers = {
Query: {
userWithID: (parent, args, context) => {
if (args.id < 1) {
throw new UserInputError('Неверное значение аргу мента', {
argumentName: 'id'
})
}
// ...
}
}
}
Ответ сервера будет выглядеть так:
{
"errors": [
{
"message": "Неверное значение аргумента",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"userWithID"
],
"extensions": {
"argumentName": "id",
"code": "BAD_USER_INPUT",
"exception": {
"stacktrace": [
"UserInputError: Неверное значение аргумента",
" at userWithID (/my-project/index.js:25:13)",
" ...и т.д."
]
}
}
}
]
}
Кастомные ошибки
Мы можем создавать собственные ошибки либо путем определения подкласса ApolloError
, либо путем прямой инициализации объекта ApolloError
.
Пример определения подкласса:
import { ApolloError } from 'apollo-server-errors'
export class MyError extends ApolloError {
constructor(message: string) {
super(message, 'MY_ERROR_CODE')
Object.defineProperty(this, 'name', { value: 'MyError' })
}
}
throw new MyError('Сообщение об ошибке')
Прямая инициализация:
import { ApolloError } from 'apollo-server-errors'
throw new ApolloError('Сообщение об ошибке', 'MY_ERROR_CODE', myCustomExtensions)
Загрузка файлов
Для загрузки файлов используется библиотека graphql-upload
. Данный пакет предоставляет поддержку для multipart/form-data
.
Эту библиотеку нельзя использовать совместно с apollo-server
. Для этого нужна интеграция с каким-либо фреймворком, такая как apollo-server-express
.
const express = require('express')
const { ApolloServer, gql } = require('apollo-server-express')
const {
GraphQLUpload,
graphqlUploadExpress
} = require('graphql-upload')
const { finished } = require('stream/promises')
const typeDefs = gql`
# Реализация этого скалярного типа экспортируется из
# 'GraphQLUpload' из пакета 'graphql-upload'
# в карте резолверов ниже
scalar Upload
type File {
filename: String!
mimetype: String!
encoding: String!
}
type Query {
# В типе 'Query' должно присутствовать хотя бы одно поле. Данный пример
# показывает как получать загруженные файлы
otherFields: Boolean!
}
type Mutation {
# Поддерживаются разные способы загрузки
singleUpload(file: Upload!): File!
}
`
const resolvers = {
// Это связывает скаляр `Upload` с реализацией,
// предоставляемой `graphql-upload`
Upload: GraphQLUpload,
Mutation: {
singleUpload: async (parent, { file }) => {
const { createReadStream, filename, mimetype, encoding } = await file
// Вызов `createReadStream` возвращает поток для чтения.
// См. https://nodejs.org/api/stream.html#stream_readable_streams
const stream = createReadStream()
// Перезаписываем файл local-file-output.txt в текущей директории
// при каждой загрузке
const out = require('fs').createWriteStream('local-file-output.txt')
stream.pipe(out)
await finished(out)
return { filename, mimetype, encoding }
}
}
}
async function startServer() {
const server = new ApolloServer({
typeDefs,
resolvers
})
await server.start()
const app = express()
// Этот посредник должен быть добавлен перед вызовом `applyMiddleware`
app.use(graphqlUploadExpress())
server.applyMiddleware({ app })
await new Promise(r => app.listen({ port: 4000 }, r))
console.log(`🚀 Сервер запущен по адресу: http://localhost:4000${server.graphqlPath}`)
}
startServer()
Подписки
Подписки - это длящиеся операции чтения, которые могут обновлять свои результаты при возникновении определенного события на сервере.
Они, как правило, используют веб-сокеты вместо HTTP
.
Определение схемы
Тип Subscription
определяет поле верхнего уровня, на которое может подписаться клиент:
type Subscription {
postCreated: Post
}
Поле postCreated
будет обновляться при каждом создании нового Post
на сервере и отправлять Post
подписанным клиентам.
Клиенты могут подписаться на поле postCreated
следующим образом:
subscription PostFeed {
postCreated {
author
comment
}
}
Обратите внимание: каждая операция может быть подписана только на одно поле типа Subscription
.
Включение подписок
В следующих примерах вместо apollo-server
используется apollo-server-express
, поскольку первый не поддерживает подписки.
Для одновременного запуска Express-сервера и сервера для подписок мы создаем экземпляр http.Server
, который оборачивает оба сервера и становится новым "слушателем" (listener). Мы также создаем SubscriptionServer
, что требует определенных настроек.
- Устанавливаем
subscriptions-transport-ws
и@graphql-tools/schema
:
yarn add subscriptions-transport-ws @graphql-tools/schema
- Импортируем необходимые утилиты в файле, где инициализируется экземпляр
ApolloServer
:
import { createServer } from 'http'
import { execute, subscribe } from 'graphql'
import { SubscriptionServer } from 'subscriptions-transport-ws'
import { makeExecutableSchema } from '@graphql-tools/schema'
- Далее необходимо создать
http.Server
. Для этого мы передаем приложениеExpress
(app) в функциюcreateServer
, импортированную из модуляhttp
:
// `app` - значение, которое вернул вызов `express()`
const httpServer = createServer(app);
- Создаем экземпляр
GraphQLSchema
.
Вместо typeDefs
и resolvers
SubscriptionServer
принимает выполняемую GraphQLSchema
. Мы можем передать этот объект schema
в SubscriptionServer
и ApolloServer
. Таким образом, мы обеспечим использование одной и той же схемы в обоих местах.
const schema = makeExecutableSchema({ typeDefs, resolvers })
// ...
const server = new ApolloServer({
schema
})
- Создаем
SubscriptionServer
:
const subscriptionServer = SubscriptionServer.create({
// Созданная нами схема
schema,
// Это мы импортировали из `graphql`
execute,
subscribe
}, {
// `httpServer`, созданный на предыдущем шаге
server: httpServer,
// `server` - это экземпляр `ApolloServer`
path: server.graphqlPath
})
- Добавляем в конструктор
ApolloServer
плагин для закрытияSubscriptionServer
:
const server = new ApolloServer({
schema,
plugins: [{
async serverWillStart() {
return {
async drainServer() {
subscriptionServer.close()
}
}
}
}]
})
- Вызываем
listen
.
Вместо app.listen
мы вызываем httpServer.listen
с аналогичными аргументами. Это позволяет одновременно запустить HTTP
и веб-сокет-серверы.
Полный пример:
import { createServer } from "http"
import { execute, subscribe } from "graphql"
import { SubscriptionServer } from "subscriptions-transport-ws"
import { makeExecutableSchema } from "@graphql-tools/schema"
import express from "express"
import { ApolloServer } from "apollo-server-express"
import resolvers from "./resolvers"
import typeDefs from "./typeDefs"
;(async function () {
const app = express()
const httpServer = createServer(app)
const schema = makeExecutableSchema({
typeDefs,
resolvers
})
const subscriptionServer = SubscriptionServer.create(
{ schema, execute, subscribe },
{ server: httpServer, path: server.graphqlPath }
)
const server = new ApolloServer({
schema,
plugins: [{
async serverWillStart() {
return {
async drainServer() {
subscriptionServer.close()
}
}
}
}]
})
await server.start()
server.applyMiddleware({ app })
const PORT = 4000
httpServer.listen(PORT, () =>
console.log(`Сервер запущен по адресу: http://localhost:${PORT}/graphql`)
)
})()
Разрешение подписки
Резолверы для полей Subscription
отличаются от резолверов для полей других типов. Резолверы подписок - это объекты с функцией subscribe
:
const resolvers = {
Subscription: {
postCreated: {
// Подробнее об этом ниже
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
}
},
// другие резолверы
}
Функция subscribe
должна возвращать AsyncIterator
- стандартный интерфейс для перебора результатов асинхронных операций. В приведенном примере AsyncIterator
генерируется pubsub.asyncIterator
.
Класс PubSub
Обратите внимание: данный класс не рекомендуется использовать в продакшне, поскольку он представляет собой систему событий, хранящуюся в памяти, которая поддерживает только один экземпляр сервера. В продакшне должен использоваться другой подкласс абстрактного класса PubSubEngine
(см. в конце раздела).
Сервер использует модель "публикация-подписка" (pub-sub) для отслеживания событий, которые обновляют активные подписки. Библиотека graphql-subscription
предоставляет класс PubSub
, с которого можно начать разработку.
Устанавливаем данную библиотеку:
yarn add graphql-subscription
Экземпляр PubSub
позволяет как публиковать события определенного типа, так и регистрировать такие события. Создаем экземпляр PubSub
:
import { PubSub } from 'graphql-subscriptions'
const pubsub = new PubSub()
Публикация события
Для публикации события используется метод publish
:
pubsub.publish('POST_CREATED', {
postCreated: {
author: 'Али Баба',
comment: 'Сезам, откройся!'
}
})
- первый параметр - это название публикуемого события в виде строки
- второй параметр - полезная нагрузка, связанная с событием
Предположим, что у нас имеется такая мутация:
type Mutation {
createPost(author: String, comment: String): Post
}
Базовый резолвер для этой мутации может выглядеть так:
const resolvers = {
Mutation: {
createPost(parent, args, context) {
// Логика работы с БД живет в `postController`
return postController.createPost(args)
},
}
// другие резолверы
}
Перед сохранением поста в БД мы можем опубликовать соответствующее событие:
const resolvers = {
Mutation: {
createPost(parent, args, context) {
pubsub.publish('POST_CREATED', { postCreated: args })
return postController.createPost(args)
}
},
// другие резолверы
}
Регистрация событий
Объект AsyncIterator
"слушает" события определенного типа и добавляет их в очередь на обработку. AsyncIterator
создается путем вызова метода asyncIterator
экземпляра PubSub
:
pubsub.asyncIterator(['POST_CREATED'])
Данный метод принимает массив регистрируемых событий.
Каждая функция subscribe
резолвера для поля типа Subscription
должна возвращать объект AsyncIterator
. Это возвращает нас к коду, с которого мы начинали:
const resolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
}
},
// другие резолверы
}
После определения функции subscribe
Сервер будет использовать полезную нагрузку события POST_CREATED
для отправки обновленных значений в поле postCreated
.
Фильтрация событий
Функция withFilter
позволяет фильтровать обновления перед их отправкой клиенту.
Предположим,что сервер предоставляет подписку commendAdded
, которая уведомляет клиентов о добавлении нового комментария в определенный репозиторий. Клиент может выполнить подписку следующим образом:
subscription($repoName: String!){
commentAdded(repoFullName: $repoName) {
id
content
}
}
Что здесь не так? Сервер публикует событие COMMENT_ADDED
при добавлении комментария в любой репозиторий. Это означает, что резолвер commenAdded
выполняется для каждого комментария независимо от репозитория. Как результат, подписанные клиенты могут получать данные, которые им не нужны (или даже такие данные, к которым у них нет доступа).
Пример использования функции withFilter
:
import { withFilter } from 'graphql-subscriptions'
const resolvers = {
Subscription: {
commentAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator('COMMENT_ADDED'),
(payload, variables) => {
// Отправлять обновление только в случае, когда комментарий добавляется
// в правильный для этой операции репозиторий
return (payload.commentAdded.repository_name === variables.repoFullName)
}
)
}
},
// другие резолверы
}
withFilter
принимает 2 параметра:
- первый параметр - функция, используемая для
subscribe
при отсутствии фильтра - второй параметр - функция фильтрации, возвращающая
true
, если обновление должно быть отправлено определенному клиенту, илиfalse
в противном случае (Promise<boolean>
также является валидным). Данная функция, в свою очередь, также принимает 2 параметра:payload
- полезная нагрузка опубликованного событияvariables
- объект, содержащий переменные, переданные клиентом при инициализации подписки
Контекст операции
При инициализации контекста для запроса или мутации мы, как правило, извлекаем HTTP-заголовки и другие детали запроса из объекта req
, переданного в функцию context
.
Для подписок мы передаем функцию onConnect
в к онструктор SubscriptionServer
. Данная функция принимает connectionParams
в объекте в качестве первого аргумента, а также WebSocket
и ConnectionContext
в качестве второго и третьего аргументов, соответственно. Если onConnect
возвращает объект, он передается резолверам в качестве аргумента context
:
SubscriptionServer.create({
// другие настройки
async onConnect(connectionParams) {
if (connectionParams.authorization) {
const currentUser = await findUser(connectionParams.authorization)
return { currentUser }
}
throw new Error('Отсутствует токен аутентификации!')
}
})
Это важн о, поскольку такие метаданные, как токены аутентификации, отправляются по-разному в зависимости от используемого транспортного протокола.
onConnect
и onDisconnect
Мы можем определить функции, которые выполняются сервером подписок при каждом подключении - подписке (onConnect
) или отключении (onDisconnect
).
Определение onConnect
предоставляет следующие преимущества:
- мы можем отклонять определенное подключение, выбрасывая исключение или возвращая
false
. Это может быть полезным при аутентификации - если
onConnect
возвращает объект, он передается резолверам как контекст операции
Мы передаем эти функции в конструктор SubscriptionServer
:
SubscriptionServer.create({
schema,
execute,
subscribe,
onConnect(connectionParams, webSocket, context) {
console.log('Подключение установлено!')
},
onDisconnect(webSocket, context) {
console.log('Подключение прервано!')
}
})
Эти функции принимают следующие параметры:
connectionParams
- объект, содержащий параметры, включенные в запрос, такие как токенwebSocket
- подключаемый или отключаемыйWebSocket
context
- объект контекста для подключенияWebSocket
. Это не объектcontext
, связанный с определенной операцией
Пример: аутентификация с помощью onConnect
На клиенте SubscriptionClient
поддерживает добавление информации в connectionParams
, которая отправляется с первым сообщением. На сервере все подписки ожидают полной аутентификации соединения и возвращения колбеком onConnect
истинного значения.
Предположим, что наш SubscriptionClient
настроен следующим образом:
new SubscriptionClient(subscriptionUrl, {
// другие настройки
connectionParams: {
authorization: clientToken
}
})
Аргумент connectionParams
в колбеке onConnect
содержит информацию, переданную клиентом, и может использоваться для проверки полномочий пользователя.
Например, мы можем использовать токен authorization
, переданный клиентом, для поиска соответствующего пользователя и его передачи резолверам:
async function findUser(authToken) {
// ищем юзера по токену
}
SubscriptionServer.create({
schema,
execute,
subscribe,
async onConnect(connectionParams, webSocket) {
if (connectionParams.authorization) {
const currentUser = await findUser(connectionParams.authorization)
return { currentUser }
}
throw new Error('Отсутствует токен аутентификации!')
},
{ server, path }
})
Объект пользователя будет доступен в резолверах через context.currentUser
. При возникновении ошибки аутентификации промис будет отклонен, что предотвратит подключение клиента.
Библиотеки PubSub
для продакшна
В настоящее время сообществом разработано несколько библиотек PubSub
для таких популярных систем публикации событий, как:
- Redis
- Google PubSub
- MQTT enabled broker
- RabbitMQ
- Kafka
- Postgres
- Google Cloud Firestore
- Ably Realtime