GraphQL

Если вы ещё не знакомы с TypeScript — не беда. Продолжайте читать дальше: основные понятия и большую часть синтаксиса освоить будет несложно.

Предполагается, что у вас установлены и корректно работают yarn и MongoDB, а также есть хотя бы базовые знания о JavaScript.

Это руководство состоит всего из четырёх частей, каждая из которых находится в отдельной папке в репозитории.

⭐️Часть 1 — Базовая настройка

Готовый репозиторий для первой части находится здесь:

На этом этапе мы клонируем стартовый набор TypeScript и следуем руководству по базовой настройке из официальной документации Apollo.

Добавляем nodemon глобально, чтобы иметь возможность перезапускать сервер после каждого изменения:

yarn global add nodemon

Клонируем этот стартовый репозиторий TypeScript для запуска установки проекта:

git clone https://github.com/tomanagle/typescript-starter.git apollo-graphql-server

Устанавливаем необходимые зависимости:

cd apollo-graphql-server
yarn install
yarn add apollo-server graphql

В этой части мы следуем руководству непосредственно с официальной документации Apollo GraphQL, но с минимальными изменениями.

Создаём схему GraphQL:

touch src/schema.ts

Добавляем к схеме следующий код:

// src/schema.ts
import { gql } from 'apollo-server'

// Схема - это коллекция определений типов (обозначаемых "typeDefs"),
// которые вместе определяют "форму" запросов, выполняемых применительно к
// нашим данным.
const typeDefs = gql`
    # Комментарии в строках GraphQL (такие, как этот) начинаются со значка (#).

    # Этот тип "Card" определяет запрашиваемые поля для каждой карты в источнике данных.
    type Card {
        title: String
        author: String
        body: String
    }

    # Тип "Query" особенный: он перечисляет все доступные запросы, которые
    # могут выполнить клиенты, вместе с возвращаемым типом для каждого. В 
    # нашем случае запрос "cards" возвращает массив нуля или большего          количества Карт (определённый выше).
    type Query {
        cards: [Card]
    }
`

export default typeDefs

Эта схема сообщает GraphQL, что нам нужен один объект, который мы называем ‘Card’. Когда мы запрашиваем карты, мы ожидаем вернуть массив с объектами, соответствующими форме, которую мы определили в ‘Card’.

Дальше объединяем всё вместе в файле src/app.ts, точке входа в наш сервер.

// src/app.ts
import { ApolloServer } from 'apollo-server'
import typeDefs from './schema'

// Хардкодим данные на сервер в конечной точке GraphQL
const cards = [
    {
        title: 'Card one title',
        author: 'J.K. Rowling',
        body: 'This is the first card!',
    },
    {
        title: 'Card two title',
        author: 'Michael Crichton',
        body: 'This is the second card!',
    },
]

// Резолверы задают технику получения типов, определённых в
// схеме. Наш резолвер извлекает книги из массива "books".
const resolvers = {
    Query: {
        cards: () => cards,
    },
}

// Конструктору ApolloServer требуются два параметра: определение
// схемы и набор резолверов.
const server = new ApolloServer({ typeDefs, resolvers })

// Метод `listen` запускает веб-сервер.
server.listen().then(({ url }: { url: string }) => {
    console.log(`🚀  Server ready at ${url}`)
})

Теперь запускаем сервер и создаём запрос на наши карты:

yarn run dev

Переходим в http://localhost:4000/ и прописываем запрос. В нём должно быть название, автор и текстовая часть. Жмём на play и видим хардкод данных в файле index.ts. В следующей части настроим базу данных так, чтобы запрос мог возвращать реальные данные.

⭐️Часть 2 — Настройка базы данных

Готовый репозиторий для второй части находится здесь.

На этом этапе мы установим Mongoose и модель карты для чтения и записи данных в БД.

Добавляем mongoose как зависимость:

yarn add mongoose
yarn add @types/node -D

Создаём соединение с MongoDB с помощью Mongoose для чтения и записи данных в БД:

mkdir src/database
touch src/database/connect.ts

// src/database/connect.ts
import mongoose from 'mongoose';

async function connect({ db }: { db: string }) {
    try {
        await mongoose.connect(db, { useNewUrlParser: true, useUnifiedTopology: true })
            .then(() => console.log(`🗄️ Successfully connected to ${db} 🗄️`));
    } catch (error) {
        console.log(`🔥 An error ocurred when trying to connect with ${db} 🔥`)
        throw error;
    }
}

export default connect;

Добавляем подключение к app.ts для инициализации этого соединения:

// src/app.ts
import { ApolloServer } from 'apollo-server'
import typeDefs from './schema'
import connect from './database/connect'
// Хардкодим данные на сервер в конечной точке GraphQL
const cards = [
    {
        title: 'Card one title',
        author: 'J.K. Rowling',
        body: 'This is the first card!',
    },
    {
        title: 'Card two title',
        author: 'Michael Crichton',
        body: 'This is the second card!',
    },
]

// Резолверы задают технику получения типов, определённых в
// схеме. Наш резолвер извлекает книги из массива "books".
const resolvers = {
    Query: {
        cards: () => cards,
    },
}

// Конструктору ApolloServer требуются два параметра: определение
// схемы и набор резолверов.
const server = new ApolloServer({ typeDefs, resolvers })

// Перемещаем это в конфигурационный файл
const DATABASE_NAME = 'test-database'

// Метод `listen` запускает веб-сервер.
server.listen().then(async ({ url }: { url: string }) => {
    console.log(`🚀  Server ready at ${url}`)
    // Подключаемся к нашей БД
    await connect({ db: `mongodb://localhost:27017/${DATABASE_NAME}` })
})

Создаём нашу модель и файлы контроллера:

mkdir src/models
mkdir src/controllers
touch src/models/card.model.ts
touch src/controllers/card.controller.ts

🔥 Подсказка: явключил в название файла слова model и controller, чтобы потом было проще ориентироваться. Ну, а что? Очень удобно: у каждой модели, контроллера и резолвера (распознавателя) название связано с тем объектом, над которым идёт работа в редакторе. 

Добавляем к модели код:

// src/models/card/model.ts
import mongoose, { Document } from 'mongoose'

export interface Card extends Document {
    title: string
    author: string
    body: string
}

const Schema = new mongoose.Schema(
    {
        title: String,
        author: String,
        body: String,
    },
    // Добавляет к модели createdAt и updatedAt
    { timestamps: true }
)

export default mongoose.model<Card>('Card', Schema)

Хотя это и валидная модель Mongoose, будем использовать TypeScript: так мы задействуем её по полной.

Добавляем пакет Mongoose DefinentlyTyped:

yarn add @types/mongoose -D

Теперь мы можем импортировать Document и создать интерфейс, который расширяет его:

// src/models/card/model.ts
import mongoose, { Document } from 'mongoose'

export interface Card extends Document {
    title: string
    author: string
    body: string
}

const Schema = new mongoose.Schema({
    title: String,
    author: String,
    body: String,
})

export default mongoose.model<Card>('Card', Schema)

Добавляем функцию к контроллеру карт для создания карты с заданным входным значением:

// src/models/card/model.ts
import mongoose, { Document } from 'mongoose'

export interface Card extends Document {
    title: string
    author: string
    body: string
}

const Schema = new mongoose.Schema(
    {
        title: String,
        author: String,
        body: String,
    },
    // Добавляет к модели createdAt и updatedAt
    { timestamps: true }
)

export default mongoose.model<Card>('Card', Schema)

Наконец, добавляем функцию, возвращающую все карты в базе данных. Функция будет принимать произвольный параметр, лимитирующий количество результатов на запрос.

// src/controllers/card.controller.ts
import CardModel, { Card } from '../models/card.model'

export interface CreateCardInput {
    title: string
    author: string
    body: string
}

// Создаём новую карту из заданного входа
export function createCard({
    title,
    author,
    body,
}: CreateCardInput): Promise<Card | Error> {
    return CardModel.create({
        title,
        author,
        body,
    })
        .then((data: Card) => data)
        .catch((error: Error) => {
            throw error
        })
}

export interface GetAllCardsInput {
    limit?: number
}

export function getAllCards({ limit }: GetAllCardsInput) {
    return CardModel.find({})
        .limit(limit ? limit : 0)
        .then((data: Card[]) => data)
        .catch((error: Error) => {
            throw error
        })
}

⭐ Часть 3 — Сводим всё воедино с помощью резолвера

Готовый репозиторий для третьей части находится здесь

На этом этапе мы изучим реальные отличия REST API от GraphQL API. Вместо создания конечной точки для нашего API, займёмся резолвером.

Прежде чем добавлять новый код, немного почистим каталог проекта.

Удаляем массив с хардкодом карт из app.ts:

// src/app.ts
import { ApolloServer } from 'apollo-server'
import typeDefs from './schema'
import connect from './database/connect'

// Резолверы задают технику получения типов, определённых в
// схеме. Наш резолвер извлекает книги из массива "books".
const resolvers = {
    Query: {
        cards: () => cards,
    },
}

// Конструктору ApolloServer требуются два параметра: определение
// схемы и набор резолверов.
const server = new ApolloServer({ typeDefs, resolvers })

// Перемещаем это в конфигурационный файл
const DATABASE_NAME = 'test-database'

// Метод `listen` запускает веб-сервер.
server.listen().then(async ({ url }: { url: string }) => {
    console.log(`🚀  Server ready at ${url}`)
    // Подключаемся к нашей БД
    await connect({ db: `mongodb://localhost:27017/${DATABASE_NAME}` })
})

Перемещаем наш резолвер из app.ts в новый файл resolver.ts:

 touch src/resolvers.ts 

// src/resolvers/ts

const resolvers = {
    Query: {
        cards: () => cards,
    },
}

export default resolvers

Импортируем новый файл с резолвером в app.ts:

// src/app.ts
import { ApolloServer } from 'apollo-server'
import typeDefs from './schema'
import connect from './database/connect'
import resolvers from './resolvers'

// Конструктору ApolloServer требуются два параметра: определение
// схемы и набор резолверов.
const server = new ApolloServer({ typeDefs, resolvers })

// Перемещаем это в конфигурационный файл
const DATABASE_NAME = 'test-database'

// Метод `listen` запускает веб-сервер.
server.listen().then(async ({ url }: { url: string }) => {
    console.log(`🚀  Server ready at ${url}`)
    // Подключаемся к нашей БД
    await connect({ db: `mongodb://localhost:27017/${DATABASE_NAME}` })
})

В файле с резолвером вылезает ошибка: он не знает, как распознавать запрос ‘Cards’. Укажем резолверу, что это можно сделать, используя функцию getAllCards в контроллере. Импортируем функцию и её интерфейс из контроллера:

// src/resolvers/ts
import { getAllCards, GetAllCardsInput } from './controllers/card.controller'
const resolvers = {
    Query: {
        cards: () => cards,
    },
}

export default resolvers

Соединяем нашу функцию ‘getAllcards’ с резолвером:

// src/resolvers/ts
import { getAllCards, GetAllCardsInput } from './controllers/card.controller'
const resolvers = {
    Query: {
        cards: (_: null, { input }: { input: GetAllCardsInput }) =>
            getAllCards({ ...input }),
    },
}

export default resolvers

Добавляем к нашим резолверам объект Mutations, а внутри добавляем функцию ‘CreateCard’. Она будет вызывать и возвращать функцию ‘createCard’ в контроллере карт.

// src/resolvers/ts
import {
    getAllCards,
    GetAllCardsInput,
    createCard,
    CreateCardInput,
} from './controllers/card.controller'
const resolvers = {
    Query: {
        cards: (_: null, { input }: { input: GetAllCardsInput }) =>
            getAllCards({ ...input }),
    },
    Mutation: {
        CreateCard: (_: null, { input }: { input: CreateCardInput }) =>
            createCard({ ...input }),
    },
}

export default resolvers

Дальше в консоли должна вылезти ошибка «Мутации», определенная в резолверах, а не в схеме. И это хорошо. Значит, схема пытается донести до нас, что она знает о наших резолверах и, если в схеме чего-то нет, мы не сможем добраться до неё через GraphQL API.

Решение очевидно: добавить к схеме мутацию.

🔥 Подсказка: при создании мутаций следуйте правилам:

  1. Имя мутации должно начинаться словами ‘Create’ или ‘Update’.
  2. После префикса ‘Create’ или ‘Update’ добавляем тип объекта.
  3. Создаём тип входных данных, который принимает имя мутации (то есть добавляем ‘Input’).
  4. Даём мутации ровно одно входное значение и называем его ‘input’.
  5. Возвращаем объект, на который ссылается имя мутации.

Например, если надо создать объект ‘Card’, вызываем мутацию ‘CreateCard’. Затем создаём тип входных данных ‘CreateCardInput’, и он всегда будет возвращать созданный объект ‘Card’.

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

Добавляем новый тип и мутацию к нашему файлу schema.ts:

// src/schema.ts
import { gql } from 'apollo-server'

// Схема - это коллекция определений типов (обозначаемых "typeDefs"),
// которые вместе определяют "форму" запросов, выполняемых применительно к
// нашим данным.
const typeDefs = gql`
    # Комментарии в строках GraphQL (такие, как этот) начинаются со значка (#).

    # Этот тип "Card" определяет запрашиваемые поля для каждой карты в источнике данных.
    type Card {
        title: String
        author: String
        body: String
    }

    # Тип "Query" особенный: он перечисляет все доступные запросы, которые
    # могут выполнить клиенты, вместе с возвращаемым типом для каждого. В 
    # нашем случае запрос "cards" возвращает массив нуля или большего количества Карт (определённый выше).
    type Query {
        cards: [Card]
    }

    input CreateCardInput {
        title: String
        author: String
        body: String
    }

    type Mutation {
        CreateCard(input: CreateCardInput): Card
    }
`

export default typeDefs

Теперь мы можем перейти к окну интерактивного запуска кода GraphQL и сделать мутацию. Здесь можно увидеть, как в БД появляется новая карта и данные возвращаются из мутации.

mutation {
  CreateCard(input: { title: "title", body: "card body", author: "Tom Nagle" }){
    title
    body
    author
  }
}

Возможно, вы ещё не заметили, но у нас небольшой баг, который (если мы оставим всё как есть) обернётся головной болью для любого разработчика пользовательских интерфейсов, пожелавшего использовать наш API. У наших карт нет ID. Это может показаться ерундой, но клиенту они понадобятся для отслеживания данных, находящихся в запоминающем устройстве. Давайте исправим ошибку. 

Возвращаемся к schema.ts, чтобы добавить _id в качестве идентификатора типа к нашему типу ‘Card’. В конце строчки добавляем !, чтобы сделать его обязательным.

// src/schema.ts
import { gql } from 'apollo-server'

const typeDefs = gql`
    type Card {
        _id: ID!
        title: String
        author: String
        body: String
    }

    type Query {
        cards: [Card]
    }

    input CreateCardInput {
        title: String
        author: String
        body: String
    }

    type Mutation {
        CreateCard(input: CreateCardInput): Card
    }
`

export default typeDefs

Почему используем _id вместо id? Потому что так его называет MongoDB. Если не хотите переименовывать переменную при каждом запросе id, делайте так же.

⭐ Часть 4 — Добавляем подписки

Готовый репозиторий для четвёртой части находится здесь:

Подписки позволяют клиентским приложениям подписываться на изменения данных. Они дают клиенту чувствовать «реальное время», хотя всем известно, что HTTP — это протокол без сохранения состояния и «реальное время» в Сети не больше, чем иллюзия.

В app.ts импортируем PubSub из apollo-сервера и создаём новый его экземпляр:

// src/app.ts
import { ApolloServer, PubSub } from 'apollo-server'
import typeDefs from './schema'
import connect from './database/connect'
import resolvers from './resolvers'

export const pubsub = new PubSub()

// Конструктору ApolloServer требуются два параметра: определение
// схемы и набор резолверов.
const server = new ApolloServer({ typeDefs, resolvers })

// Перемещаем это в конфигурационный файл
const DATABASE_NAME = 'test-database'

// Метод `listen` запускает веб-сервер.
server.listen().then(async ({ url }: { url: string }) => {
    console.log(`🚀  Server ready at ${url}`)
    // Подключаемся к нашей БД
    await connect({ db: `mongodb://localhost:27017/${DATABASE_NAME}` })
})

Теперь в нашей схеме добавляем тип подписок вместе с подпиской ‘CardCreated’, которая возвращает ‘Card’:

// src/schema.ts
import { gql } from 'apollo-server'

const typeDefs = gql`
    # Новый тип подписок
    type Subscription {
        CardCreated: Card
    }
    type Card {
        _id: ID!
        title: String
        author: String
        body: String
    }

    type Query {
        cards: [Card]
    }

    input CreateCardInput {
        title: String
        author: String
        body: String
    }

    type Mutation {
        CreateCard(input: CreateCardInput): Card
    }
`

export default typeDefs

Подобно многочисленным реализациям “подписчик-издатель”, подписки Apollo используют соответствие издателя и подписчика со строками. Эти две строчки должны быть точными, поэтому создаём файл constants.ts и добавляем новую константу, которую мы будем импортировать туда, где она потребуется:

// src/constants.ts
export const CARD_CREATED = 'CARD_CREATED'

Импортируем pubsub и константу ‘CARD_CREATED’ в наш файл resolvers.ts и добавляем к резолверам тип подписки вместе с подпиской CardCreated:

// src/resolvers/ts
import {
    getAllCards,
    GetAllCardsInput,
    createCard,
    CreateCardInput,
} from './controllers/card.controller'
import { CARD_CREATED } from './constants'
import { pubsub } from './app'

const resolvers = {
    Subscription: {
        CardCreated: {
            // Дополнительные метки событий могут быть переданы на создание асинхронного итератора asyncIterator
            subscribe: () => pubsub.asyncIterator([CARD_CREATED]),
        },
    },
    Query: {
        cards: (_: null, { input }: { input: GetAllCardsInput }) =>
            getAllCards({ ...input }),
    },
    Mutation: {
        CreateCard: (_: null, { input }: { input: CreateCardInput }) =>
            createCard({ ...input }),
    },
}

export default resolvers// src/resolvers/ts
import {
    getAllCards,
    GetAllCardsInput,
    createCard,
    CreateCardInput,
} from './controllers/card.controller'
import { CARD_CREATED } from './constants'
import { pubsub } from './app'

const resolvers = {
    Subscription: {
        CardCreated: {
            // Дополнительные метки событий могут быть переданы на создание асинхронного итератора asyncIterator
            subscribe: () => pubsub.asyncIterator([CARD_CREATED]),
        },
    },
    Query: {
        cards: (_: null, { input }: { input: GetAllCardsInput }) =>
            getAllCards({ ...input }),
    },
    Mutation: {
        CreateCard: (_: null, { input }: { input: CreateCardInput }) =>
            createCard({ ...input }),
    },
}

export default resolvers

Если подписаться на ‘CardCreated’ сейчас, то останется только мечтать о получении хоть каких-то данных. При создании карты нужно опубликовать это создание с помощью объекта pubsub, возникшего в app.ts. В нашем card.controller.ts выполняем рефакторинг функции card created в асинхронную, присваиваем новую карту переменной, а затем возвращаем карту.

Импортируем pubsub и константу ‘CARD_CREATED’ в card.controller.ts и публикуем новую карту с помощью pubsub:

// src/controllers/card.controller.ts
import CardModel, { Card } from '../models/card.model'
import { CARD_CREATED } from '../constants'
import { pubsub } from '../app'

export interface CreateCardInput {
    title: string
    author: string
    body: string
}

// Создаём новую карту из заданного входного значения
export async function createCard({
    title,
    author,
    body,
}: CreateCardInput): Promise<Card | Error> {
    const card = await CardModel.create({
        title,
        author,
        body,
    })
        .then((data: Card) => data)
        .catch((error: Error) => {
            throw error
        })

    // Публикуем создание новой карты для тех, кто слушает
    pubsub.publish(CARD_CREATED, { CardCreated: card })

    return card
}

export interface GetAllCardsInput {
    limit?: number
}

export function getAllCards({ limit }: GetAllCardsInput) {
    return CardModel.find({})
        .limit(limit ? limit : 0)
        .then((data: Card[]) => data)
        .catch((error: Error) => {
            throw error
        })
}

Переходим в окно интерактивного запуска кода GraphQL и создаём новую подписку:

subscription {
  CardCreated {
    _id
    title
    author
    body
  }
}

Нажимаем на play и возвращаемся к нашей мутации ‘CreateCard’. Здесь видим, как только что созданная карта появляется у нас в подписке. Создадим ещё несколько карт и отследим их появление.

Спасибо за то, что уделили время чтению этого руководства. Надеюсь, вы нашли для себя что-то полезное!

Полный репозиторий проекта: https://github.com/tomanagle/apollo-graphql-server


Перевод статьи Tom Nagle: Create a GraphQL server with Queries, Mutations & Subscriptions