😀

【GraphQL】Apollo Server v4・Prismaでcontextによるデータ共有方法

2023/07/03に公開

はじめに

GraphQL学習でApollo Serverv4系を使用していたが、context実装時にかなり詰まり、参考文献等も少なく、公式を見つつ手探りでようやく実装できたので、備忘録として本記事を残そうと思います。

実装内容はタイトルの通りApollo Servercontextを用いてリゾルバーやプラグイン全体でデータを共有するといった内容になっています。

スキーマ

まずは、テーブル定義でもあるスキーマを見ていきます。

Prismaスキーマ

shcema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Link {
  id          Int      @id @default(autoincrement())
  createdAt   DateTime @default(now())
  description String
  url         String
  user        User?    @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId      Int?
}

model User {
  id       Int    @id @default(autoincrement())
  name     String
  email    String @unique
  password String
  links    Link[]
}

ここで重要なのはリレーションの部分でLinkモデルのuserカラムとUserモデルのlinksカラムです。

この定義をGraphQLのスキーマ定義にも適用させるようにします

GraphQLスキーマ

schema.graphql
type Query {
  feed: [Link]!
}

type Mutation {
  post(url: String!, description: String!): Link!
  signUp(email: String!, password: String!, name: String!): AuthPayload
  login(email: String!, password: String!): AuthPayload
}

type Link {
  id: ID!
  description: String!
  url: String!
  user: User
}

type User {
  id: ID!
  name: String!
  email: String!
  links: [Link!]!
}

type AuthPayload {
  token: String
  user: User
}

重要な点を解説すると、GraphQL側のスキーマ定義では、Prismaのスキーマと整合性がとれる形で定義しているという点です。

QueryとMutation

::: note info
初学者向けの解説
:::
GraphQL初学者の方向けに解説をするとQueryとMutationは以下の通りです。

  • Query:SQLのSELECT文、CRUDのREADにあたり、データ取得に使用
  • Mutation:SQLのINSERT・UPDATE・DELETE文、CRUDのREAD以外にあたり、データの追加・変更に使用
    ※以下が対応表です
SQL/REST GraphQL
SELECT Query
INSERT Mutation
UPDATE Mutation
DELETE Mutation

さらに、QueryやMutationでfeedpostなどと定義していますが、これはリゾルバーという関数の型を定義しています。

リゾルバーとは

::: note info
リゾルバーとは・・・クエリの型に対して情報を入れること
:::
簡単に言うと、スキーマで定義した型に対して実際の値や操作を定義するということです。
例えば、Queryのリゾルバーは以下のようになっています。

export const feed = async (_: unknown, __: unknown, context: Context) => {
  return context.prisma.link.findMany()
}

スキーマの型情報が以下なので、リゾルバーでは、feed関数を定義し、返り値にLinkを配列で返すようにしています。

schema.graphql
type Query {
  feed: [Link]!
}

つまり、スキーマでは関数名(リゾルバーの名称)と返す型を定義しているということです。

Mutationについても同様です。

const APP_SECRET = process.env.APP_SECRET as string

// ユーザー新規登録
export const singUp = async (
  _: unknown,
  args: { email: string; password: string; name: string },
  context: Context
) => {
  // パスワードの設定
  const password = await bcrypt.hash(args.password, 10)

  if (!args.email || !password || !args.name) {
    throw new Error('Email、Password or Name is required')
  }

  // ユーザー新規作成
  const user = await context.prisma.user.create({
    data: {
      ...args,
      password,
    },
  })

  const token = jwt.sign({ userId: user.id }, APP_SECRET)

  if (!token) {
    throw new Error('Token is required')
  }

  return {
    token,
    user,
  }
}
schema.graphql
type Mutation {
  signUp(email: String!, password: String!, name: String!): AuthPayload
}

type User {
  id: ID!
  name: String!
  email: String!
  links: [Link!]!
}

type AuthPayload {
  token: String
  user: User
}

signUpを例にしますが、引数にemail・password・nameをとり、返り値はAuthPayloadとし、tokenuserを返却します。
そして、リゾルバーでは詳細な処理を記述します。

:::note info
カスタムリゾルバーについて
:::

先ほどの、スキーマ定義ではただ、User型やLink型を実装していたように思えますが、これらの型のuserlinksカスタムリゾルバーとして定義しています。
つまり、Prismaのスキーマではリレーションを組んでいますが、実際にGraphQLでUserに紐づくLink配列のデータを取得するなどの処理を行うには、リゾルバーが必要となります。

このように、QueryやMutationとは別に自分でリゾルバーを実装することもできます。
これをカスタムリゾルバーといいます。
(以下のlinksがカスタムリゾルバー)

schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  links: [Link!]!
}

以下が、リゾルバーの処理内容です。
やっていることは簡単でユーザーに紐づくlinksを配列で取得しているだけです。

特徴的なのは、引数の部分で、parent.idとなっていますが、これは親のリゾルバーが返すオブジェクトからの値を扱うことができます。
簡単に言うと、PrismaでUserとLinkはリレーションを組んでいましたが、親テーブルがUserとなるため、取得できるデータは以下のようになるということです。
parent.id = user.id

export const links = (parent: { id: any }, __: unknown, context: Context) => {
  return context.prisma.user
    .findUnique({
      where: { id: parent.id },
    })
    .links()
}

リゾルバーの引数について

引数についての解説もここでしておこうと思います。
以下の表が全てを表しています。
細かいことは各自で調べてみてください。

引数 解説
parent 親のリゾルバーが返すオブジェクトの値
args クライアントから渡される値(フォームの入力値など)
context リクエスト全体で共有される値(認証情報やDB接続情報など)
info 実行されるクエリに関する詳細情報(ほとんど使用しない)

Contextでデータを共有する方法

前提の解説が終わったところで、本題です。
引数の解説でもでたcontextでデータを共有する方法について解説します。

Apollo Server v4系を扱っている記事が公式+αくらいしかないので、ベースは公式のものをベースとしています。
以下が、実装コードです。

server.ts
import 'dotenv/config'

import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'
import { loadSchemaSync } from '@graphql-tools/load'
import { addResolversToSchema } from '@graphql-tools/schema'
import { PrismaClient } from '@prisma/client'
import { join } from 'path'

import { user } from './resolvers/Link'
import { login, post, singUp } from './resolvers/Mutation'
import { feed } from './resolvers/Query'
import { links } from './resolvers/User'
import { getUserId } from './utils'

const prisma = new PrismaClient()

const schema = loadSchemaSync(join(__dirname, './schema.graphql'), {
  loaders: [new GraphQLFileLoader()],
})

// リゾルバー関数
const resolvers = {
  Query: {
    feed: feed,
  },

  Mutation: {
    signUp: singUp,
    login: login,
    post: post,
  },

  Link: {
    user: user,
  },

  User: {
    links: links,
  },
}

const schemaWithResolvers = addResolversToSchema({ schema, resolvers })
const server = new ApolloServer({
  schema: schemaWithResolvers,
})

const startServer = async () => {
  const { url } = await startStandaloneServer(server, {
    context: async ({ req }) => ({
      ...req,
      prisma,
      userId: req && req.headers.authorization ? getUserId(req) : null,
    }),
    listen: { port: 4000 },
  })
  console.log(`🚀  Server ready at: ${url}`)
}

startServer()

contextの実装部分は以下です。

const startServer = async () => {
  const { url } = await startStandaloneServer(server, {
    context: async ({ req }) => ({
      ...req,
      prisma,
      userId: req && req.headers.authorization ? getUserId(req) : null,
    }),
    listen: { port: 4000 },
  })
  console.log(`🚀  Server ready at: ${url}`)

公式によるとContextの役割は以下とのことです。

GraphQL 操作中に、contextValue という名前のオブジェクトを作成することで、サーバーのリゾルバーとプラグイン全体でデータを共有できます。

contextValue を介して、認証スコープ、データをフェッチするソース、データベース接続、カスタムフェッチ関数など、リゾルバーに必要な便利なものを渡すことができます。 データローダーを使用してリゾルバー間でリクエストをバッチ処理している場合は、データローダーを共有 contextValue にアタッチすることもできます。

コンテキスト関数は非同期であり、オブジェクトを返す必要があります。 このオブジェクトは、contextValue という名前を使用してサーバーのリゾルバーとプラグインにアクセスできるようになります。

コンテキスト関数を選択した統合関数 (expressMiddleware や startStandaloneServer など) に渡すことができます。

サーバーはリクエストごとに context 関数を 1 回呼び出し、各リクエストの詳細 (HTTP ヘッダーなど) を使用して contextValue をカスタマイズできるようにします。

要約するに、startStandaloneServerなどの統合関数にContextを渡すことで、認証情報などリゾルバーに必要な情報をリゾルバーやプラグイン全体で共有できるというもののようです。
また、以下の2つの要件を満たす必要もあるようです。

  • 非同期で実装する必要
  • オブジェクトを返す必要がある

つまり、私の実装では、以下をリゾルバー全体で共有できるということです。

  • req:リクエスト情報
  • prisma: prismaClientのインスタンス
  • userId: 認証情報(ユーザーIDからトークンを生成するためuserIdが認証情報となる)

contextの利用例

実際にいい例として投稿APIの実装を見てみましょう。

Mutation.ts
export const post = async (
  _: unknown,
  args: { description: string; url: string },
  context: Context
) => {
  const { userId } = context

  const newLink = await context.prisma.link.create({
    data: {
      url: args.url,
      description: args.description,
      user: { connect: { id: userId } },
    },
  })

  return newLink
}

Contextの型は以下のようにしています。

Context.ts
import type { Prisma, PrismaClient } from '@prisma/client'
import type { DefaultArgs } from '@prisma/client/runtime'

export type Context = {
  prisma: PrismaClient<
    Prisma.PrismaClientOptions,
    never,
    Prisma.RejectOnNotFound | Prisma.RejectPerOperation | undefined,
    DefaultArgs
  >
  userId: number
}

contextが使用されているのは、userIdの分割代入と、context.prismaの部分です。
このようにすることで、他のリゾルバー関数でも同様にcontext.prisma.メソッドと実装できます。
メリットとしては、いちいち認証の実装やPrismaClientのインスタンス化が不要なるので、記述量が減ったり、メモリの使用量が減るなどが挙げられます。

ちなみに、user: { connect: { id: userId } }の部分ですが、PrismaでLinkとUserはリレーションを組んでいますよね。この記述はその関係性を表しています。

connectオプションを使用することで既存のUserレコード(既存ユーザー)にLinkのレコード(ここでは新規作成されたLink)を関連づけられます。キーとしては、userIdで紐付けを行うので、{ id: userId }としています。
よくある勘違いですが、「{ id: userId }がなぜ、userIdになるのか」「LinkのIDではないのか」という勘違いがあります。
これについては、user: {connect: ・・・}と最初にuserという項目を指定しているので、{ id: userId}であっているということになります。

おわりに

最後になりますが、当然、公式の方が情報が正確ですので、その点はご留意ください。
「参考文献」のセクションの「Context and contextValue」が公式ドキュメントになりますので、気になる方は、そちらもチェックしてみてください。

参考文献

Discussion