👏

TypeScriptとPrismaとPlanetGraphQLで作るGraphQL API【43分くらい】

2022/08/26に公開約25,800字

はじめに

この記事を読んでいる皆さんは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,
+      }))
 })

サーバーを再起動すると、以下のように任意のtakeskipを渡して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のfindManyUserfindUniqueTaskなどに対応するPGArgBuilderを受け取ることができます。PGArgBuilderを使うことで、PrismaClientのfindManyUserfindUniqueTaskを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.implementメソッドでtaskCountフィールドの実装を行っています
    • このようにDBに存在しないフィールドについてはresolverを設定し、どのように値を返すかを実装する必要があります
    • 今回はPrismaClientのcountメソッドを使って、対象ユーザのタスク数を取得しています
  • pgpc.convertTypesメソッドに{ User: () => user, Task () => task }の形でredefineしたUserとTaskを渡しています
    • これによって戻り値のobjects.Userobjects.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が取得可能になっているはずです。またfirstlastについても、デフォルト値やバリデーションが効いていることが確認できると思います。

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
        }
      }))
})

新たに定義したcreateTaskMutationpg.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,
 })

CreateTaskInputcopyメソッドを使って更新用の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

ログインするとコメントできます