3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

はじめに

今回はアプリ開発におけるGraphQL API実装で用いるライブラリを紹介していきます。
紹介するライブラリは『PlanetGraphQL』です。

PlanetGraphQLの特徴

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

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

ライブラリを利用したGraphQL API実装の流れを見ていきます。
なお、今回はPlanetGraphQLのサンプルに則って、ざっくり概要を追っていきます。

インストール

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というオブジェクト型のフィールドを新たに追加

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

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

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

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

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

fullName: b.string(),

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

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の中で行っていきます。

attachments: f.attachments.relay(),

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

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

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モデルに追加したfullNamelatestPostというフィールドについて、実際にどのようにデータ操作を行うかをリゾルバで定義していきます。

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()を用いることで定義することができます。

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

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

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

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()に引数として渡すことができ、リゾルバ定義に用いることが可能です。

.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の方も定義していきましょう。

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を渡すことで、ビルドを行うことができます。

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が新規作成されていることがわかります。
またisPublicargs()で定義した通り、デフォルト値として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"
            }
          }
        }
      ]
    }
  }
}

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

まとめ

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

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

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

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?