はじめに
今回はアプリ開発における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モデルの定義を書いていきます。
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モデルをカスタマイズして定義します。
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モデルを定義していきます。
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
モデルも定義していきます。
export const attachmentWithoutRelation = () =>
objects.Attachment.copy({
name: 'AttachmentWithoutRelation',
fields: (f) => omit(f, 'post'),
})
リゾルバの定義
まずは、先ほどUser
モデルに追加したfullName
とlatestPost
というフィールドについて、実際にどのようにデータ操作を行うかをリゾルバで定義していきます。
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レスポンス時にfirstName
と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)
})
}),
latestPost
は全Userに対して最新のPost
をDBからそれぞれ取得し、結果を返すようなデータ操作を定義しています。
なお、ここではdataloder()
によって、取得対象Userに対して一括でDBからデータを取得できる機能を用いており、それによって各Userに対するデータ操作の中で都度DBアクセスが発生しないため、処理の効率化を行うことができます。
User
モデルに追加したフィールドに関するリゾルバ定義も完了したので、ここからqueryを定義していきます。
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を定義していきます。
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()
によってバリデーションやデフォルト値の設定などをカスタマイズすることができるようになっています。
export const pgpc = getPGPrismaConverter(pg, dmmf)
export const { args } = pgpc.convertBuilders()
続いて、同様にattachment
に対してもmutationを定義していきます。
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を構築します。
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
が新規作成されていることがわかります。
また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を実装する際にぜひ利用してみてください。