【GraphQL】Apollo Server v4・Prismaでcontextによるデータ共有方法
はじめに
GraphQL
学習でApollo Server
のv4系を使用していたが、context
実装時にかなり詰まり、参考文献等も少なく、公式を見つつ手探りでようやく実装できたので、備忘録として本記事を残そうと思います。
実装内容はタイトルの通りApollo Server
のcontext
を用いてリゾルバーやプラグイン全体でデータを共有するといった内容になっています。
スキーマ
まずは、テーブル定義でもあるスキーマを見ていきます。
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スキーマ
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でfeed
やpost
などと定義していますが、これはリゾルバー
という関数の型を定義しています。
リゾルバーとは
::: note info
リゾルバーとは・・・クエリの型に対して情報を入れること
:::
簡単に言うと、スキーマで定義した型に対して実際の値や操作を定義するということです。
例えば、Query
のリゾルバーは以下のようになっています。
export const feed = async (_: unknown, __: unknown, context: Context) => {
return context.prisma.link.findMany()
}
スキーマの型情報が以下なので、リゾルバーでは、feed関数を定義し、返り値にLinkを配列で返すようにしています。
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,
}
}
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
とし、token
とuser
を返却します。
そして、リゾルバーでは詳細な処理を記述します。
:::note info
カスタムリゾルバーについて
:::
先ほどの、スキーマ定義ではただ、User型やLink型を実装していたように思えますが、これらの型のuser
やlinks
はカスタムリゾルバー
として定義しています。
つまり、Prismaのスキーマではリレーションを組んでいますが、実際にGraphQLでUserに紐づくLink配列のデータを取得するなどの処理を行うには、リゾルバーが必要となります。
このように、QueryやMutationとは別に自分でリゾルバーを実装することもできます。
これをカスタムリゾルバーといいます。
(以下のlinksがカスタムリゾルバー)
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系を扱っている記事が公式+αくらいしかないので、ベースは公式のものをベースとしています。
以下が、実装コードです。
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の実装を見てみましょう。
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の型は以下のようにしています。
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