TypeScriptとPrismaとPlanetGraphQLで作るGraphQL API【43分くらい】
はじめに
この記事を読んでいる皆さんはGraphQLやTypeScriptを(そしておそらくPrismaも)ご存知だと思います。しかしPlanetGraphQLについて耳にしたことがある方は皆無ではないでしょうか。
PlanetGraphQLは、TypeScriptやPrismaと組み合わせることで、手早く多機能で柔軟なGraphQL APIを作ることを目指したライブラリです。このチュートリアルでは、簡易なTODOアプリ用のGraphQL APIを作ることで、PlanetGraphQLの全体感を紹介できればと思います。
読者にはnode.jsとTypeScriptについて基本的な理解があることを前提としています。GraphQLやPrismaについては実際に利用されたことがない方もいらっしゃると思うので、PlanetGraphQLと合わせてチュートリアルの中で解説していこうと思います。
最終的なソースコードはこちらのリポジトリをご確認ください。
プロジェクトの作成
node.js(v16以上)がインストールされている必要があります
まずは空っぽのTypeScriptのプロジェクトを作成しましょう。
mkdir tutorial && cd tutorial
npm init --yes
npm install typescript ts-node --save-dev
次にtsconfig.jsonを作成し、以下のように設定します。
touch tsconfig.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"lib": ["es2021"],
"module": "commonjs",
"target": "es2021",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
PrismaとPlanetGraphQLをdependenciesに追加します。
npm install prisma @planet-graphql/core
Prismaの初期設定コマンドでPrismaの環境を整えます。
npx prisma init --datasource-provider sqlite
今回は、説明や環境構築を簡略化するためにsqliteを使います。
もちろんPostgresSQLやMySQLを利用することも可能です。
DBスキーマの定義
このチュートリアルでは、以下のような機能を持ったTODOアプリ用のAPIを作ろうと思います。
- ユーザは自分のタスクを作成、編集できる
- ユーザは自分のタスクを一覧できる
- 管理者ユーザは上記に加えて、全てのユーザとユーザごとのタスクを一覧できる
今回はシンプルにUserテーブルとTaskテーブルを定義しましょう。
Prismaではモデル定義をschema.prisma
ファイルに記載します。
以下の内容をprisma/schema.prisma
に追加してください。
// ...
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id Int @id @default(autoincrement())
title String
content String?
status String
dueAt DateTime
user User @relation(fields: [userId], references: [id])
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
マイグレーションファイルの作成と実行
PrismaはDBスキーマのマイグレーション機能を備えています。
以下のコマンドでマイグレーションファイルを作成してみましょう。
npx prisma migrate dev --name init
コマンドが成功するとprisma/migrations/${yyyyMMddHHmmss}_init/migration.sql
というファイルが作成されます。ファイルにはPrismaが生成したSQLが記載されています。SQLファイルの作成と同時にDBへの適用も実行され、UserテーブルとTaskテーブルが作成されます。
PlanetGraphQL用のジェネレータを追加
Prismaにはジェネレータという概念があります。ジェネレータを使うとschema.prisma
に記載されたモデル定義を元に、対応するJSON Schemaを生成したり、TypeScriptの型定義を生成することができます。
改めてschema.prisma
を見ると以下のような記述があることに気づくと思います。
generator client {
provider = "prisma-client-js"
}
これはTypeScriptからPrismaを呼び出すためのPrismaClient
が必要とする型定義を生成するためのジェネレータの定義になります。
PlanetGraphQLもPrismaClientと同様にジェネレータを使ってTypeScriptの型定義を生成する必要があります。以下の定義をschema.prisma
に追加してください。
generator pg {
provider = "planet-graphql"
}
追加した上で、以下のコマンドを実行してください。
schema.prisma
に設定されたジェネレータがすべて再実行されPlanetGraphQLが必要とする型定義が生成されます。
npx prisma generate
GraphQL APIサーバの立ち上げ
Prisma側の準備は整ったので、PlanetGraphQLを使ってGraphQL APIサーバを実装してみましょう。
まずは、PlanetGraphQLを使ってPrismaのモデルをGraphQLのオブジェクトタイプに変換します。index.ts
ファイルを作成し、以下のように記入してください。
touch index.ts
import { dmmf, getPGBuilder, getPGPrismaConverter, PrismaTypes } from '@planet-graphql/core'
const pg = getPGBuilder<{ Prisma: PrismaTypes }>()
const pgpc = getPGPrismaConverter(pg, dmmf)
const { objects } = pgpc.convertTypes()
pgpc.convertTypes
メソッドの戻り値のobjects
にPrismaのモデルに対応するGraphQLのオブジェクトタイプであるUserとTaskが含まれています。そのなかのobjects.User
を使って、ユーザ一覧を返すusers
をQueryに定義してみます。以下の内容をindex.tsに追記してください。
import { PrismaClient } from '@prisma/client'
// ...
const prisma = new PrismaClient({ log: ['query'] })
const usersQuery = pg.query({
name: 'users',
field: (b) =>
b
.object(() => objects.User)
.list()
.resolve(() => prisma.user.findMany())
})
ここでは以下のような実装を行っています。
-
new PrismaClient()
でPrismaClientのインスタンスを取得しています -
pg.query
メソッドでGraphQLのQueryを定義しています -
b.object(() => objects.User)
でUsersQueryの戻り値のタイプがUserであると定義しています -
list()
で戻り値のタイプをUserのListタイプに変更しています -
resolve(() => prisma.user.findMany())
でUsersQueryが実際に呼び出された際の処理を定義しています。ここではPrismaClientを使ってDBに存在するすべてのユーザのレコードを取得しています。
さっそくGraphQL APIサーバーを立ち上げて見ましょう。PlanetGraphQLはあくまでGraphQLのSchema Builderなので、APIサーバーとして立ち上げるためには別途GraphQL Serverが必要になります。今回のチュートリアルでは、最近イケていると噂のGraphQL Serverであるgraphql-yogaを使ってみます。もちろん、もっともメジャーなapollo-serverなど他のライブラリを利用することも可能です。
npm install @graphql-yoga/node
更にindex.ts
に以下を追記してください。
import { createServer } from '@graphql-yoga/node'
// ...
const server = createServer({
schema: pg.build([usersQuery]),
maskedErrors: false,
})
server.start()
起動用のスクリプトをpackage.jsonに追加して、実行してみましょう。
{
"scripts": {
+ "start": "ts-node index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
}
npm run start
http://localhost:4000/graphql
にアクセスと、graphiql(グラフィカル)
と呼ばれる開発者向けのIDEが開きます。先程実装したusersQueryをgraphiql上で呼び出してみましょう。graphiqlの左ペインに以下のようなクエリを書き、実行ボタンを押してください。
query {
users {
id
name
}
}
DBが空っぽなので、以下のような結果が返ってくると思います。
{
"data": {
"users": []
}
}
DBにレコードを追加
DBが空っぽだと十分な動作確認が行えないため、DBにデータを入れるためのseedスクリプトを作成してみましょう。直にSQLを実行してもよいのですが、折角なのでPrismaClient経由でデータを作ってみましょう。テストデータの作成用にfacker.js
を導入します。
npm install @faker-js/faker --save-dev
seed.tsを作り、ユーザとユーザごとのタスクを作成するスクリプトを追加します。
touch seed.ts
import { PrismaClient } from '@prisma/client'
import { faker } from '@faker-js/faker'
const prisma = new PrismaClient()
async function clean() {
await prisma.task.deleteMany()
await prisma.user.deleteMany()
}
async function createUsersAndTasks(userCount = 100, taskCount = 10) {
for (let userId of [...Array(userCount).keys()]) {
const firstName = faker.name.firstName()
await prisma.user.create({
data: {
id: userId,
name: faker.name.fullName({ firstName }),
email: faker.internet.email(firstName),
tasks: {
create: [...Array(taskCount).keys()].map((taskId) => ({
id: userId * userCount + taskId,
title: faker.lorem.sentence(),
content: faker.lorem.paragraph(),
status: faker.helpers.arrayElement(['new', 'in_progress', 'done']),
dueAt: faker.date.future()
}))
}
}
})
}
}
async function seed() {
await clean()
await createUsersAndTasks()
}
seed()
Prismaにはpackage.jsonに設定されたseed用のコマンドをprisma db seed
で呼び出す仕組みが用意されています。以下の設定をpackage.jsonに追加して、コマンドを実行してみてください。
{
"scripts": {
"start": "ts-node index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
+ "prisma": {
+ "seed": "ts-node seed.ts"
+ },
"keywords": [],
}
npx prisma db seed
改めてAPIサーバーを立ち上げて先程のqueryを実行してみてください。seedが成功していれば、今度はユーザ一覧が返ってくるはずです。
npm run start
query {
users {
id
name
}
}
リレーションの取得
先程のクエリを、1度でユーザごとのタスクも取得できるように変更してみましょう。
query {
users {
id
name
tasks {
id
title
}
}
}
実行するとCannot return null for non-nullable field User.tasks.
というエラーが返ってくるはずです。このエラーを解消するためにusersQueryの実装を少しだけ変えてみましょう。
const usersQuery = pg.query({
name: 'users',
field: (b) =>
b
.object(() => objects.User)
.list()
- .resolve(() => prisma.user.findMany())
+ .resolve(() => prisma.user.findMany({
+ include: { tasks: true }
+ }))
})
サーバを再起動して再度queryを実行すると、今度はユーザごとのtasksも取得できるはずです。{ include: { tasks: true } }
を渡すことで、ユーザに紐づくタスクもDBから取得するようになったためです。
これで万事解決としたいのですが、常に{ include: { tasks: true } }
を渡すのは、パフォーマンス的に問題があります。タスクの取得が不要な場合でもDBからタスクをSelectしてしまうためです。理想は、tasksの取得が必要なときは{ include: { tasks: true } }
を渡し、必要でないときは渡さないことです。
この問題を、PlanetGraphQLのprismaArgs
を使って解決しましょう。usersQueryの実装を少しだけ変えてみましょう。
const usersQuery = pg.query({
name: 'users',
field: (b) =>
b
.object(() => objects.User)
.list()
- .resolve(() => prisma.user.findMany({
- include: { tasks: true }
- }))
+ .resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
})
変更後も、ユーザごとのtasksが取得できるはずです。resolve
メソッドのコールバックが受け取るprismaArgs
には、GraphQLのqueryから自動的に生成されたinclude句が含まれています。クエリが以下のような場合は、
query {
users {
id
name
tasks {
id
title
}
}
}
prismaArgsの値は{ include: { tasks: true } }
となります。一方で、以下のようにtasksフィールドが指定されていなければ、
query {
users {
id
name
}
}
prismaArgsの値は空オブジェクト{}
になります。
フィルタリングとソート
PrismaにはSQLと似たフィルタリングやソート機能があります。例えば、nameに"e"が含まれるユーザを取得したい場合は、以下のように書くことができます。
const users = await prisma.user.findMany({
where: {
name: { contains: "e" }
}
})
11件目〜20件目のユーザを取得したい場合は、以下のように書くことができます。
const users = await prisma.user.findMany({
take: 10,
skip: 10,
})
PlanetGraphQLを使って、上記のようなオフセットベースのペジネーション機能をusersQueryに追加してみましょう。以下のようにusersQueryを変更してください。
const usersQuery = pg.query({
name: 'users',
field: (b) =>
b
.object(() => objects.User)
.list()
+ .args((b) => ({
+ take: b.int().default(10),
+ skip: b.int().default(0),
+ }))
- .resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
+ .resolve(({ args, prismaArgs }) => prisma.user.findMany({
+ ...prismaArgs,
+ take: args.take,
+ skip: args.skip,
+ }))
})
サーバーを再起動すると、以下のように任意のtake
とskip
を渡してusersQueryを呼び出せるはずです。
query {
users(take: 10, skip: 10) {
id
name
}
}
先程の実装を、少しだけ最適化してみましょう。以下のように修正してください。
const usersQuery = pg.query({
name: 'users',
field: (b) =>
b
.object(() => objects.User)
.list()
- .args((b) => ({
+ .prismaArgs((b) => ({
take: b.int().default(10),
skip: b.int().default(0),
}))
- .resolve(({ args, prismaArgs }) => prisma.user.findMany({
- ...prismaArgs,
- take: args.take,
- skip: args.skip,
- }))
+ .resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
})
変更前と同様に、任意のtakeとskipを渡してusersQueryを呼び出せるはずです。
args
メソッドではなくprismaArgs
メソッドを使うことで、少しだけ実装をシンプルにすることができました。args
メソッドを使っても、prismaArgs
メソッドを使っても、生成されるGraphQLスキーマは変わりません。違いは、resolve時にargs
として渡ってくるかprismaArgs
として渡ってくるかだけです。
直接PrismaClientに渡したい値についてはprismaArgs
メソッドを使ったほうが、実装がシンプルになる場合があります。
PGArgBuilderの導入
先程はtakeやskipといったシンプルなargsだったので自分の手で定義することができました。しかしより複雑なargsを定義する場合はどうでしょうか。その際に非常に便利なのがPlanetGraphQLが提供するPGArgBuilder
です。以下のようにusersQueryを変更して、サーバーを再起動してみてください。
+ const { args } = pgpc.convertBuilders()
const usersQuery = pg.query({
name: 'users',
field: (b) =>
b
.object(() => objects.User)
.list()
- .prismaArgs((b) => ({
- take: b.int().default(10),
- skip: b.int().default(0),
- }))
+ .prismaArgs(() => args.findManyUser.build())
.resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
})
すると大量のargsがusersQueryに追加されます。例えば、以下のようにusersQueryを呼び出すことが可能になります。
query {
users(
where: {
email: { contains: "e" },
tasks: { some: { status: { equals: "done" } } }
},
orderBy: [ { updatedAt: desc }, { id: desc } ],
take: 10,
) {
id
name
tasks {
status
}
}
}
pgpc.convertBuilders
メソッドを呼び出すことで、PrismaClientのfindManyUser
やfindUniqueTask
などに対応するPGArgBuilderを受け取ることができます。PGArgBuilderを使うことで、PrismaClientのfindManyUser
やfindUniqueTask
をGraphQLのargsに変換することが可能です。更に、PGArgBuilderはedit
メソッドを使って内容を調整することができます。例えば以下のようにeditしてみましょう。
const usersQuery = pg.query({
name: 'users',
field: (b) =>
b
.object(() => objects.User)
.list()
.auth(({ context }) => context.isAdmin, { strict: true })
- .prismaArgs(() => args.findManyUser.build())
+ .prismaArgs(() => args.findManyUser.edit((f) => ({
+ where: f.where.edit((f) => ({
+ email: f.email.select('StringFilter').edit((f) => ({
+ equals: f.equals,
+ contains: f.contains,
+ })),
+ tasks: f.tasks.edit((f) => ({
+ some: f.some.edit((f) => ({
+ status: f.status
+ .select('String')
+ .validation((schema) => schema.regex(/new|in_progress|done/)),
+ }))
+ })),
+ })),
+ orderBy: f.orderBy,
+ take: f.take.default(10),
+ skip: f.skip.default(0),
+ })).build({ type: true }))
.resolve(({ prismaArgs }) => prisma.user.findMany(prismaArgs))
ここでは、以下のような調整をしています。
- findManyUserに用意されているargsのうちGraphQL APIとして公開しても良いと思う部分のみを使うようにした
- タスクのstatusの絞り込み条件では
new
,in_progress
,done
以外の値は受け付けないようにバリデーションを設定した -
take
,skip
にデフォルト値を設定した
いかがでしょうか?
このように、高機能なフィルタリングやソート機能を簡単にGraphQL APIとして実装することができます。型補完が効き、TypeScriptの恩恵を十分に受けることができるのも嬉しい点です。
オブジェクトタイプのカスタマイズ
ここまでPrismaのスキーマから生成されたobjects.User
をそのまま使ってきましたが、このUserをカスタマイズしてみましょう。以下の内容をindex.tsに追加してください。
+ const user = pgpc.redefine({
+ name: 'User',
+ fields: (f, b) => ({
+ ...f,
+ taskCount: b.int()
+ }),
+ relations: () => getRelations('User'),
+ })
+ const task = pgpc.redefine({
+ name: 'Task',
+ fields: (f) => {
+ const { user, ...rest } = f
+ return { ...rest }
+ },
+ relations: () => getRelations('Task'),
+ })
+ user.implement((f) => ({
+ taskCount: f.taskCount.resolve((params) => {
+ const user = params.source
+ return prisma.task.count({
+ where: {
+ userId: user.id,
+ }
+ })
+ })
+ }))
- const { objects } = pgpc.convertTypes()
+ const { objects, getRelations } = pgpc.convertTypes({
+ User: () => user,
+ Task: () => task,
+ })
一気に複雑になりました。1つ1つ解説していこうと思います。
-
pgpc.redefine
メソッドでUserとTaskを再定義しています- Userには
taskCount
というユーザの持つタスク件数を取得するためのIntタイプのフィールドを追加しています - TaskからはUserフィールドを削除しています
-
relations: () => getRelations('User')
の部分については後述します
- Userには
-
user.implement
メソッドでtaskCount
フィールドの実装を行っています- このようにDBに存在しないフィールドについてはresolverを設定し、どのように値を返すかを実装する必要があります
- 今回はPrismaClientのcountメソッドを使って、対象ユーザのタスク数を取得しています
-
pgpc.convertTypes
メソッドに{ User: () => user, Task () => task }
の形でredefineしたUserとTaskを渡しています- これによって戻り値の
objects.User
やobjects.Task
がredefine後のUserやTaskになります -
getRelations
メソッドとredefine
時のrelations
フィールドは、リレーション先のオブジェクトタイプをredefine後のオブジェクトタイプに置き換えるために必要になります - 今回の例だと、Userの
tasks
フィールドをredefine後のTaskに置き換えるために必要になっています
- これによって戻り値の
まとめるとredefineメソッドを使うことで、Prismaのスキーマ定義に対してフィールドを追加したり削除したりすることが可能になります。サーバーを再起動してusersQueryからtaskCountが取得できることを確認してください。
query {
users {
id
name
taskCount
}
}
DataLoaderの導入によるN+1問題の解消
taskCountフィールドを追加したことで、ユーザごとのタスク件数を取得できるようになりました。しかし1つ問題があります。それはSQLが最適化されておらず、タスク件数を取得するSelect文がユーザの数だけ発行されていることです。いわゆる N+1 問題です。
GraphQLではDataLoaderという仕組みを使ってN+1問題を解消します。PlanetGraphQLはdataloader機能を組み込んでいるため、手軽にdataloaderを使うことができます。以下のようにindex.tsを修正してください。
user.implement((f) => ({
taskCount: f.taskCount.resolve((params) =>
pg.dataloader(params, async (userList) => {
- const user = params.source
- return prisma.task.count({
- where: {
- userId: user.id,
- }
- })
+ return pg.dataloader(params, async (userList) => {
+ const userIds = userList.map((x) => x.id)
+ const resp = await prisma.task.groupBy({
+ by: ['userId'],
+ _count: { _all: true },
+ where: { userId: { in: userIds } },
+ })
+ return userIds.map((id) => resp.find((x) => x.userId === id)?._count._all ?? 0)
+ })
)
}))
サーバーを再起動してusersQueryからtaskCountを取得してみてください。ログを見ると明らかですが、ユーザごと発行されていたSQLが以下のような単一のSQLに変わっています。
SELECT COUNT(*), `main`.`Task`.`userId` FROM `main`.`Task` WHERE `main`.`Task`.`userId` IN (?,?,?,?,?,?,?,?,?,?) GROUP BY `main`.`Task`.`userId` LIMIT ? OFFSET ?
Relayの導入
GraphQLでのペジネーションは、Relay方式と呼ばれるカーソルベースでの実装が一般的です。カーソルベースのペジネーションは使い勝手が良い反面、実装が難しい部分があります。しかし、PrismaとPlanetGraphQLの組み合わせであれば、Relay方式に対応したAPIをとても簡単につくることができます。
さっそくTask一覧を取得するAPIを、Relay方式の形で実装してみましょう。以下をindex.tsに追加してください。
const tasksQuery = pg.query({
name: 'tasks',
field: (b) =>
b
.object(() => objects.Task)
.relay()
.resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
})
新たに定義したtasksQuery
がスキーマに含まれるようにpg.build
メソッドに引数に加えます。
const server = createServer({
- schema: pg.build([usersQuery]),
+ schema: pg.build([usersQuery, tasksQuery]),
maskedErrors: false,
})
これだけでRelay方式のtasksQueryを呼び出すことができるようになります。
query {
tasks(first: 10) {
edges {
node {
id
title
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
上で実装したとおりrelay
メソッドを呼び出すだけで、QueryやフィールドをRelay方式に対応させることができます。以下のような処理がrelay
メソッド内部で暗黙的に行われているためです。
- Relay方式に必要なGraphQLのオブジェクトタイプを生成
- Relay方式に必要なargs(first, last, before, after)を設定
- resolve内でDBから取得した値をRelay方式の形式に整形
- 各nodeのcursorを生成
- hasNextPageやhasPreviousPageなどのPageInfoを算出
細かい部分のカスタマイズも可能です。デフォルトではタスクはidの昇順に並びますがrelayOrderBy
メソッドを使って、並び順を変更することができます。
const tasksQuery = pg.query({
name: 'tasks',
field: (b) =>
b
.object(() => objects.Task)
.relay()
+ .relayOrderBy({ updatedAt: 'desc' })
.resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
})
また、relay
メソッドを呼び出すことでfirst
, last
, before
, after
のargsが設定されますがrelayArgs
メソッドで、これらにデフォルト値やバリデーションを設定することも可能です。
const tasksQuery = pg.query({
name: 'tasks',
field: (b) =>
b
.object(() => objects.Task)
.relay()
.relayOrderBy({ updatedAt: 'desc' })
+ .relayArgs((f) => ({
+ ...f,
+ first: f.first.default(10).validation((schema) => schema.max(100)),
+ last: last: f.last.validation((schema) => schema.max(100)),
+ }))
.resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
})
さらにrelayTotalCount
メソッドを使って、TotalCountを取得するためのフィールドを追加することも可能です。
const tasksQuery = pg.query({
name: 'tasks',
field: (b) =>
b
.object(() => objects.Task)
.relay()
.relayOrderBy([{ updatedAt: 'desc' }, { id: 'desc' }])
.relayArgs((f) => ({
...f,
first: f.first.default(10).validation((schema) => schema.max(100)),
last: last: f.last.validation((schema) => schema.max(100)),
}))
+ .relayTotalCount(() => prisma.task.count())
.resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
})
サーバを再起動すると、以下のようにtotalCountが取得可能になっているはずです。またfirst
やlast
についても、デフォルト値やバリデーションが効いていることが確認できると思います。
query {
tasks {
totalCount
edges {
node {
id
}
}
}
}
Contextの設定と権限制御
ここまで権限制御については一切触れてきませんでした。チュートリアルの最初に挙げたAPIの要件を改めて確認すると、これまでに実装した取得系のQueryについては以下の修正が必要そうです。
- tasksQueryは全てのタスクを返してしまっているので、呼び出したユーザ自身のタスクのみを返すようにする
- ユーザ一覧は管理者のみ取得可能なのでusersQueryは管理者ユーザのみ呼び出せるようにする
権限制御に関する実装をするには、GraphQLのContext
と呼ばれる概念の理解が不可欠です。Context
には主にAPIの呼び出し元の情報を格納します。例えば、呼び出したユーザのIDであったり、呼び出し元のIPアドレスなどです。
次に、Contextがリクエストごと生成されるようにします。Contextの生成はPlanetGraphQL(GraphQL Schema Builder)ではなくて、GraphQL Server(GraphQL YogaやApollo Server)の責務になります。GraphQL YogaのcreateServer
メソッド内に、リクエストごとのContextを生成する処理を追加してみましょう。
const server = createServer({
schema: pg.build([usersQuery, tasksQuery]),
maskedErrors: false,
+ context: ({ req }) => ({
+ userId: Number(req.headers['x-user-id'] ?? 0),
+ isAdmin: Boolean(req.headers['x-is-admin'] ?? false),
+ })
})
これでヘッダーのx-user-id
, x-is-admin
値がContextに設定され、PlanetGraphQL内で利用できるようになります。サンプルなのでヘッダーに値がなければ固定値を設定するようにしています。
次にPlanetGraphQLでContextを扱いやすくするために、Contextの型情報をPlanetGraphQLに渡すようにします。以下のようにindex.tsを変更してください。
+ type TContext = {
+ userId: number
+ isAdmin: boolean
+ }
- const pg = getPGBuilder<{ Prisma: PrismaTypes }>()
+ const pg = getPGBuilder<{ Context: TContext, Prisma: PrismaTypes }>()
これでContextに関する準備が整いました。さっそく、tasksQueryで取得できるタスクを呼び出しユーザ自身のタスクに限定してみましょう。以下のようにtasksQueryを変更してください。
const tasksQuery = pg.query({
name: 'tasks',
field: (b) =>
b
.object(() => objects.Task)
.relay()
.relayOrderBy([{ updatedAt: 'desc' }, { id: 'desc' }])
.relayArgs((f) => ({
...f,
first: f.first.default(10).validation((schema) => schema.max(100)),
last: f.last.default(10).validation((schema) => schema.max(100)),
}))
- .relayTotalCount(() => prisma.task.count())
+ .relayTotalCount(({ context }) => prisma.task.count({
+ where: { userId: context.userId },
+ }))
- .resolve(({ prismaArgs }) => prisma.task.findMany(prismaArgs))
+ .resolve(({ context, prismaArgs }) => prisma.task.findMany({
+ ...prismaArgs,
+ where: { userId: context.userId },
+ }))
})
context内のuserId
を使って、取得するデータを呼び出しユーザに紐づくTaskのみに限定しました。
次にusersQueryを管理者ユーザのみ呼び出せるように修正してみましょう。以下のようにusersQueryを変更してください。
const usersQuery = pg.query({
name: 'users',
field: (b) =>
b
.object(() => objects.User)
.list()
+ .auth(({ context }) => context.isAdmin, { strict: true })
.prismaArgs(() => args.findManyUser.edit((f) => ({
})
auth
メソッド内で権限の有無をbooleanで返すことで、フィールド単位で権限制御を行うことができます。サーバを再起動した上でusersQueryを呼び出してください。ヘッダーに{ "x-is-admin": true }
を付与しない限りエラーが返るはずです。
最後に、auth
メソッドの挙動をより詳しく理解するために、{ strict: true }
を削除してみてください。
const usersQuery = pg.query({
name: 'users',
field: (b) =>
b
.object(() => objects.User)
.list()
- .auth(({ context }) => context.isAdmin, { strict: true })
+ .auth(({ context }) => context.isAdmin)
.prismaArgs(() => args.findManyUser.edit((f) => ({
})
サーバを再起動すると、今度はエラーではなく空配列が返るはずです。auth
メソッドのデフォルトの挙動では、権限がなくても返せる値がある場合はレスポンスを返します。具体的には以下のような挙動をします。
- 戻り値がList型の場合: 権限がない場合は空配列を返します
- 戻り値がnullableの場合: 権限がない場合はnullを返します
- それ以外の場合: エラーが発生します
Mutationの実装とcopy
最後にMutationを実装してみましょう。まずはユーザを作成するMutationです。以下の内容をindex.tsに追加してください。
const taskEnum = pg.enum({
name: 'TaskEnum',
values: ['new', 'in_progress', 'done']
})
const createTaskInput = pg.input({
name: 'CreateTaskInput',
fields: (b) => ({
title: b.string().validation((schema) => schema.max(100)),
content: b.string().nullable(),
status: b.enum(taskEnum),
dueAt: b.dateTime(),
})
}).validation((value) => value.title.length > 0 || value.status !== 'new')
const createTaskMutation = pg.mutation({
name: 'createTask',
field: (b) =>
b
.object(() => task)
.args((b) => ({
input: b.input(() => createTaskInput)
}))
.resolve(({ context, args }) => prisma.task.create({
data: {
...args.input,
userId: context.userId
}
}))
})
新たに定義したcreateTaskMutation
をpg.build
メソッドの引数に追加することも忘れないようにしましょう。
const server = createServer({
- schema: pg.build([usersQuery, tasksQuery]),
+ schema: pg.build([usersQuery, tasksQuery, createTaskMutation]),
maskedErrors: false,
})
ここでは、PGArgsBuilderを使わずにpg.input
を使ってGraphQLのInputタイプを定義しています。またpg.enum
を使ってGraphQLのEnumタイプを定義して、タスクのステータスをenumで指定できるようにしています。
以下のクエリでCreateTaskを呼び出してみましょう。呼び出しユーザに紐づくタスクが生成され返されるはずです。
mutation {
createTask(input: {
title: "some title",
content: null,
dueAt: "2030-01-01T12:00:00Z",
status: new
}) {
id
title
content
dueAt
status
}
}
タスクの更新用のMutationも作成してみましょう。以下の内容をindex.tsに追加しましょう。
const updateTaskInput = createTaskInput.copy({
name: 'UpdateTaskInput',
fields: (f, b) => ({
...f,
id: b.int(),
})
})
const updateTaskMutation = pg.mutation({
name: 'updateTask',
field: (b) =>
b
.object(() => task)
.args((b) => ({
input: b.input(() => updateTaskInput)
}))
.resolve(async ({ context, args}) => {
await prisma.task.findFirstOrThrow({
where: {
id: args.input.id,
userId: context.userId,
}
})
return prisma.task.update({
where: {
id: args.input.id,
},
data: args.input
})
})
})
updateTaskMutation
も忘れずにpg.build
メソッドの引数に追加します。
const server = createServer({
- schema: pg.build([usersQuery, tasksQuery, createTaskMutation]),
+ schema: pg.build([usersQuery, tasksQuery, createTaskMutation, updateTaskMutation]),
maskedErrors: false,
})
CreateTaskInput
のcopy
メソッドを使って更新用のUpdateTaskInput
を作成しています。更新には対象タスクのIDが必要になるため、copy時にIDを受け取るためのid
フィールドを追加しています。サーバーを再起動してupdateTask
が呼び出せることを確認してください。
mutation {
updateTask(input: {
id: 0,
title: "some updated title",
content: null,
dueAt: "2030-01-01T12:00:00Z",
status: new
}) {
id
title
content
dueAt
status
}
}
構造化 (App Layout)
これまで全ての実装をindex.tsに記述していましたが、実際のアプリケーションではもう少し管理しやすいファイル構成を考える必要があると思います。ここではPlanetGraphQLとしておすすめの構成を紹介します。
-
src/models/*.ts
: 各モデルを定義する -
src/resolvers/*.ts
: 各モデルのフィールドリゾルバと、各モデルに紐づくQuery, Mutation, Subscriptionを定義する -
src/builder.ts
: PlanetGraphQLを初期化する -
src/server.ts
: GraphQLのサーバを初期化してスタートする
ファイル構成変更後の実際のソースファイルはこちらのリポジトリを確認してみてください。
まとめ
いかがだったでしょうか。PlanetGraphQLとPrismaを使って、シンプルにGraphQL APIを実装できることを感じてもらえれば幸いです。PlanetGraphQLはまだ若いプロジェクトです。なにか改善点に気づいたらGithubでイシューを立ててください!よいGraphQLライフを!
Discussion