GraphQL + Prisma: 『PlanetGraphQL』を使ってGraphQL APIをサクッと作ってみる

## はじめに

今回はアプリ開発におけるGraphQL API実装で用いるライブラリを紹介していきます。
紹介するライブラリは『[PlanetGraphQL](https://github.com/planet-graphql/planet-graphql)』です。

### PlanetGraphQLの特徴

- Node.js用ORMのPrismaと親和性が高く、DBのモデル定義からCode Firstで簡単にGraphQLスキーマの生成・カスタマイズが可能
- バリデーションや権限、ページネーションなど様々な機能に対応

## 簡単なGraphQL APIを実装してみよう

ライブラリを利用したGraphQL API実装の流れを見ていきます。
なお、今回は[PlanetGraphQLのサンプル](https://github.com/planet-graphql/planet-graphql/tree/master/examples/prisma)に則って、ざっくり概要を追っていきます。

### インストール

```
npm install @planet-graphql/core
```

### GraphQLスキーマの定義

Prismaを初期化し、スキーマファイルに対してDBモデルの定義を書いていきます。

```prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

// PlanetGraphQLのジェネレータを設定
generator pg {
  provider = "planet-graphql"
}

// Userモデルを定義
model User {
  id        Int      @id @default(autoincrement())
  email     String
  firstName String
  lastName  String
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

// Postモデルを定義
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  isPublic  Boolean
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  tags      String[]
}
```

ここからGraphQLスキーマをコードベースで定義していきます。

まずは、Userモデルをカスタマイズして定義します。

```src/models/user.ts
export const user = pgpc.redefine({
  name: 'User',
  fields: (f, b) => ({
    ...omit(f, 'email'),
    posts: f.posts.prismaArgs(() => args.findManyPost.build()),
    fullName: b.string(),
    latestPost: b.object(() => postWithoutRelation).nullable(),
  }),
  relations: () => getRelations('User'),
})
```

なお、`redefine`メソッドによってカスタマイズしたモデルを定義しますが、それぞれのプロパティは以下を表しています。

- `name`: モデル名
- `fields`: Userモデルのfield
- `relations`: リレーションの型を正しく推論するために必要なもの（今回の場合は、postsフィールドの型を後述する再定義したPostの型として推論される）

GraphQLにおけるモデルのフィールド定義は`fields`の中で設定することができます。
特に、フィールドを追加・変更・削除することが可能になっており、DB上では持つ必要がないがGraphQLスキーマ上では持ちたい場合や、その逆など、さまざまな要件に対応しています。

上記のUserモデルにおいては、以下の4つの操作を行っています。

- 元々の`email`というフィールドを削除
- 元々の`posts`というフィールドを変更
- `fullName`というstring型のフィールドを新たに追加
- `latestPost`というオブジェクト型のフィールドを新たに追加

一つずつ詳細を見ていきましょう。

```ts
...omit(f, 'email'),
```

元々DBモデルの`User`に存在した`email`というフィールドをGraphQLスキーマ上からは削除しています。

```ts
posts: f.posts.prismaArgs(() => args.findManyPost.build()),
```

`posts`というフィールドに対して、元々の`posts`フィールドに`prismaArgs()`でargsを付与しています。

```ts
fullName: b.string(),
```

fullNameという新たなフィールドを追加しています。

```ts
latestPost: b.object(() => postWithoutRelation).nullable(),
```

lastestPostという新たなフィールドを追加し、後述の`postWithoutRelation`という別のモデルに紐づけています。
また、`nullable()`によって、nullを許容したフィールドになっています。

続いて、Postモデルを定義していきます。

```src/models/post.ts
export const post = pgpc.redefine({
  name: 'Post',
  fields: (f) => ({
    ...f,
    attachments: f.attachments.relay(),
  }),
  relations: () => getRelations('Post'),
})

export const postWithoutRelation = post.copy({
  name: 'PostWithoutRelation',
  fields: (f) => omit(f, 'author', 'attachments'),
})
```

`User`モデルと同様にGraphQLにおける`Post`モデルのフィールド定義を`fields`の中で行っていきます。

```ts
attachments: f.attachments.relay(),
```

`relay()`を使うことで、ページネーションに対応した後述の`attachments`モデルに紐づくフィールドを再定義することができます。

また、以下のような形で新規にモデルを定義することができます。

```ts
export const postWithoutRelation = post.copy({
  name: 'PostWithoutRelation',
  fields: (f) => omit(f, 'author', 'attachments'),
})
```

今回は`copy()`によって、既に定義されている`post`を利用して、フィールドをカスタマイズした上で定義しています。

続いて、同様に`attachments`モデルも定義していきます。

```src/models/attachments.ts
export const attachmentWithoutRelation = () =>
  objects.Attachment.copy({
    name: 'AttachmentWithoutRelation',
    fields: (f) => omit(f, 'post'),
  })
```

### リゾルバの定義

まずは、先ほど`User`モデルに追加した`fullName`と`latestPost`というフィールドについて、実際にどのようにデータ操作を行うかをリゾルバで定義していきます。

```src/resolvers/user-resolvers.ts
user.implement((f) => ({
  fullName: f.fullName.resolve(({ source }) => source.firstName + source.lastName),
  latestPost: f.latestPost.resolve((params) => {
    return pg.dataloader(params, async (userList) => {
      const posts: any[] = await prisma.$queryRaw`
        SELECT *
        FROM "Post"
        WHERE
          "authorId" IN (${Prisma.join(userList.map((x) => x.id))}) AND
          NOT EXISTS (
            SELECT *
            FROM "Post" AS InnerPost
            WHERE InnerPost."authorId" = "Post"."authorId" AND InnerPost."createdAt" > "Post"."createdAt"
          )
      `
      return userList.map((user) => posts.find((x) => x.authorId === user.id) ?? null)
    })
  }),
}))
```

この際、`resolve()`を用いることで定義することができます。

それぞれの定義を見ていきましょう。

```ts
fullName: f.fullName.resolve(({ source }) => source.firstName + source.lastName),
```

`fullName`はAPIレスポンス時に`firstName`と`lastName`を結合して送るようなデータ操作を定義しています。

```ts
latestPost: f.latestPost.resolve((params) => {
  return pg.dataloader(params, async (userList) => {
    const posts: any[] = await prisma.$queryRaw`
      SELECT *
      FROM "Post"
      WHERE
        "authorId" IN (${Prisma.join(userList.map((x) => x.id))}) AND
        NOT EXISTS (
          SELECT *
          FROM "Post" AS InnerPost
          WHERE InnerPost."authorId" = "Post"."authorId" AND InnerPost."createdAt" > "Post"."createdAt"
        )
    `
    return userList.map((user) => posts.find((x) => x.authorId === user.id) ?? null)
  })
}),
```

`latestPost`は全Userに対して最新の`Post`をDBからそれぞれ取得し、結果を返すようなデータ操作を定義しています。
なお、ここでは`dataloder()`によって、取得対象Userに対して一括でDBからデータを取得できる機能を用いており、それによって各Userに対するデータ操作の中で都度DBアクセスが発生しないため、処理の効率化を行うことができます。

`User`モデルに追加したフィールドに関するリゾルバ定義も完了したので、ここからqueryを定義していきます。

```src/resolvers/user-resolvers.ts
export const usersQuery = pg.query({
  name: 'users',
  field: (b) =>
    b
      .object(() => user)
      .relay()
      .relayCursor((node) => ({ id: node.id }))
      .relayOrderBy({ createdAt: 'desc' })
      .relayTotalCount(async () => await prisma.user.count())
      .auth(({ context }) => context.isAdmin)
      .resolve(async ({ prismaArgs }) => {
        return await prisma.user.findMany(prismaArgs)
      }),
})
```

`query()`を用いてqueryを定義していきます。

なお、フィールド設定に当たって、今回は以下のメソッドを用いています。

- `object()`: query対象のオブジェクト型を設定する
- `relay()`: 設定したオブジェクト型をページネーションに対応するrelay形式に変換する
- `relayCursor()`: relay形式で用いるCursor句を設定する
- `relayOrderBy()`: relay形式で用いるOrderBy句を設定する
- `relayTotalCount()`: relay形式で用いるTotalCountフィールドを設定する
- `auth()`: queryに対する実行権限を設定する
- `resolve()`: queryのリゾルバを設定する

続いて、`Post`に対してもqueryを定義していきます。

```src/resolvers/post-resolvers.ts
export const postsQuery = pg.query({
  name: 'post',
  field: (b) =>
    b
      .object(() => post)
      .prismaArgs(() =>
        args.findFirstPost
          .edit((f) => ({
            where: f.where,
          }))
          .build(),
      )
      .resolve(async ({ prismaArgs }) => {
        return await prisma.post.findFirstOrThrow(prismaArgs)
      }),
})
```

ここで、`prismaArgs()`を使って、GraphQL上でqueryを呼び出す際のargsを設定しています。
このargsは`resolve()`に引数として渡すことができ、リゾルバ定義に用いることが可能です。

```ts
.resolve(async ({ prismaArgs }) => {
  return await prisma.post.findFirstOrThrow(prismaArgs)
}),
```

`prismaArgs()`は、リレーションフィールドを含むqueryが投げられた場合に、リレーションもDBから取得するよう、include句が自動で指定されるという特徴を持っています。例えば、今回の`post`というqueryにおいて`author`フィールドが指定された場合は、`Post`に紐づく`User`もDBから取得されるようにinclude句が自動で指定されます。
また同時に、PrismaClientに渡すためのargsも個別カスタマイズして指定することで、上記のように`prismaArgs`という`resolve()`の引数を、Prismaの各メソッドの引数として渡すだけで簡潔にリゾルバを定義することができます。
ちなみに、PlanetGraphQLは、`prismaArgs()`の他にも`args()`というメソッドを持っています。
どちらを用いても生成されるGraphQLスキーマは変わりませんが、`args()`を使った場合、上述の`prismaArgs()`にあるようなPrismaに対する動的な処理がない分、GraphQLに閉じた処理を汎用的に`resolve()`内で記述することが可能です。

`Post`については、mutationの方も定義していきましょう。

```ts
export const createPostMutation = pg.mutation({
  name: 'createPost',
  field: (b) =>
    b
      .object(() => postWithoutRelation)
      .args(() =>
        args.createOnePost
          .edit((f) => ({
            input: f.data
              .select('PostUncheckedCreateInput')
              .edit((f) => ({
                title: f.title.validation((schema) => schema.max(20)),
                content: f.content,
                isPublic: f.isPublic.default(true),
              }))
              .validation((value) => {
                return !(value.title.length === 0 && value.isPublic)
              }),
          }))
          .build({ type: true }),
      )
      .resolve(async ({ context, args }) => {
        const created = await prisma.post.create({
          data: {
            ...args.input,
            authorId: context.userId,
          },
        })
        return created
      }),
})
```

こちらでは、postを新規作成するために必要な各項目を`args()`を利用して、inputという名前のargsで受け取るような形になっています。

また、`args()`の中で使用している`args.createOnePost`は、下記の`builder.ts`の中で宣言したものを使ってており、`edit()`によってバリデーションやデフォルト値の設定などをカスタマイズすることができるようになっています。

```src/builder.ts
export const pgpc = getPGPrismaConverter(pg, dmmf)
export const { args } = pgpc.convertBuilders()
```

続いて、同様に`attachment`に対してもmutationを定義していきます。

```src/resolvers/attachment-resolver.ts 
export const createAttachmentMutation = pg.mutation({
  name: 'createAttachment',
  field: (b) =>
    b
      .object(attachmentWithoutRelation)
      .args(() =>
        args.createOneAttachment
          .edit((f) => ({
            input: f.data.select('AttachmentUncheckedCreateInput').edit((f) => ({
              name: f.name,
              buffer: f.buffer,
              meta: f.meta,
              postId: f.postId,
            })),
          }))
          .build({ type: true }),
      )
      .resolve(async ({ context, args }) => {
        await prisma.post.findFirstOrThrow({
          where: {
            id: args.input.postId,
            authorId: context.userId,
          },
        })
        const created = await prisma.attachment.create({
          data: {
            ...args.input,
            size: args.input.buffer.byteLength,
          },
        })
        return created
      }),
})
```

### スキーマをビルドする

GraphQLのスキーマとリゾルバの定義が完了したので、ビルドを行いGraphQL APIを構築します。

```src/server.ts
const server = createServer({
  schema: pg.build([
    usersQuery,
    postsQuery,
    createPostMutation,
    createAttachmentMutation,
  ]),
  maskedErrors: {
    formatError: (error) => {
      const e = error as GraphQLError
      e.extensions.stack = e.originalError?.stack
      return e
    },
  },
  context: {
    userId: 1,
    isAdmin: true,
  },
})
```

`build()`の引数に定義したquery, mutationを渡すことで、ビルドを行うことができます。

```ts
pg.build([
  usersQuery,
  postsQuery,
  createPostMutation,
  createAttachmentMutation,
]),
```

### APIにクエリを投げてみる

サンプルで実装したGraphQL APIに対して、クエリを投げてみましょう。
今回は、事前に以下の`User`がDBに登録されている状態からスタートします。

```
{
  id: 1,
  email: "xxx@example.com",
  firstName: "Taro",
  lastName: "Tanaka",
}
```

まずは`users`を実行して、`User`を全件取得してみましょう。

```
# query
query {
  users {
    edges {
      node {
        id
        firstName
        lastName
        fullName
        posts {
          id
        }
      }
    }
  }
}

# レスポンス
{
  "data": {
    "users": {
      "totalCount": 1,
      "edges": [
        {
          "node": {
            "id": "1",
            "firstName": "Taro",
            "lastName": "Tanaka",
            "fullName": "TaroTanaka",
            "posts": []
          }
        }
      ]
    }
  }
}
```

relayの形式になっているため、結果は上記のようになります。
また、firstName / lastNameに加えて、カスタマイズで定義したfullNameも取得できていることがわかります。

続いて、`createPost`によって`Post`を新規作成してみましょう。

```
# mutation
mutation {
  createPost (input: {title: "title1", content: "content1"}) {
    id
    title
    content
    isPublic
    authorId
  }
}

# レスポンス
{
  "data": {
    "createPost": {
      "id": "1",
      "title": "title1",
      "content": "content1",
      "isPublic": true,
      "authorId": 1
    }
  }
}
```

`Post`が新規作成されていることがわかります。
また`isPublic`は`args()`で定義した通り、デフォルト値としてtrueが登録されており、`authorId`はクエリの実行ユーザ（今回は、事前に登録しておいたユーザ）のIDになっていることがわかります。

続いて、`post`というqueryを実行して、先ほど登録した`Post`を取得してみましょう。

```
# query
query {
  post ( where: { authorId: { equals: 1 } }){
    id
    title
    content
  }
}

# レスポンス
{
  "data": {
    "post": {
      "id": "1",
      "title": "title1",
      "content": "content1",
      "author": {
        "id": "1"
      }
    }
  }
}
```

`prismaFindArgs`によって、where句を指定でき、かつリレーションフィールドである`author`が取得できていることがわかります。

ここで`post`をさらにもう1件追加登録した上で、再度`users`を実行してみましょう。

```
# query
query {
  users {
    edges {
      node {
        id
        fullName
        posts {
          id
          title
        }
        latestPost {
          id
          title
        }
      }
    }
  }
}

# レスポンス
{
  "data": {
    "users": {
      "edges": [
        {
          "node": {
            "id": "1",
            "fullName": "TaroTanaka",
            "posts": [
              {
                "id": "1",
                "title": "title1"
              },
              {
                "id": "2",
                "title": "title2"
              }
            ],
            "latestPost": {
              "id": "2",
              "title": "title2"
            }
          }
        }
      ]
    }
  }
}
```

上記の通り、リレーションフィールドである`posts`と`latestPost`が取得できたことがわかります。
また、カスタマイズで定義した`latestPost`については、`resolve()`内で定義した通り、最新の`Post`を取得していることもわかります。

## まとめ

今回はGraphQL APIをサクッと作れるライブラリ『PlanetGraphQL』を紹介し、実際に簡単なAPIの実装まで行いました。
この『PlanetGraphQL』は、他の同系統のライブラリと比較して、以下の利点があります。

- Prismaとの親和性が高く、かつカスタマイズの自由度が高い
- インタフェースを定義することなく、型安全に実装できる
- スキーマやリゾルバが比較的簡単に設定できる

今後、GraphQL APIを実装する際にぜひ利用してみてください。