📝

ゼロからGraphQLを学ぶ

2023/08/18に公開

はじめに

こんにちは。Rustをお勉強するシリーズ・・・だったのですが、今回はRustではありません。本来はRustでGraphQLをやりたかったのですが、僕はGraphQL自体をまだちゃんと理解できてない部分が多いんですよね。

できれば生のGraphQLを直接触りながら学びたいと思っているのですが、型安全を重視するRustにおいては、Rustの型定義から自動でスキーマを生成するアプローチになっているため、直接GraphQLを触るタイミングはなさそうです。

というわけで今回は生のGraphQLにさわれる実装ということで、TypeScript + Apollo を使うことにします。当然ですがジェネレータの類は使いません。

ソースはこちら。

https://github.com/kengo-k/graphql-simple-example

そもそもGraphQLとは

GraphQLは、APIからデータを取得するためのクエリ言語です。GraphQL自体はデータベースやストレージの具体的な実装とは独立した仕様となっています。つまりデータソースはRDBMSでもJsonでもメモリでも構いません。

GraphQLを使用するためには、データの型を示す「スキーマ」を定義します。そしてGraphQLのクエリはこのスキーマに従って記述されます。データの取得方法は「リゾルバ」という関数で指定され、リゾルバの実装次第で様々なデータソースからデータを取得することができます。

よって実装の手順としては

  1. スキーマを定義する
  2. リゾルバを実装する
  3. スキーマとリゾルバからGraphQLサーバを起動する
  4. クエリをGraphQLサーバに送信する

となります。

環境構築

割愛します。GitHubリポジトリのpackage.jsonを参照してください。

スキーマを定義する

src/schema.tsにスキーマを記述していきます(ファイル名は任意です)。

src/schema.ts
import { gql } from 'apollo-server'

export const typeDefs = gql`
(ここにスキーマを記述する)
`

スキーマではtype宣言を使用して型を定義していきます。スキーマはQueryという型を必ず持たなくてはなりません。この型はデータを取得するためのクエリを意味しています。データを更新するための型としてMutationも存在しますが、こちらはオプショナルとなります。

今回はUserとそのUserが持つタスクの一覧を表現してみます。

type Query {
  getUser(id: ID!): User
  getTask(id: ID!): Task
}

ひとまずUserとTaskを取得するためのクエリを定義しました。ただしUser型とTask型がまだ定義されていません。これらの型も追加します。

type User 
  id: ID!
  name: String!
  email: String!
  tasks: [Task!]!
}

type Task {
  id: ID!
  title: String!
  description: String
  status: TaskStatus!
  owner: User!
}

型名が!で終わるのは非nullであることを表します。tasksの型が[Task!]!なので、配列自体がnullになることがなく、配列内の要素がnullになることもありません。

statusはタスクのステータスを表す値で、いくつかある状態のうちのどれかを取ります。これはenumとして表現されます。TaskStatus型は下記のようになります。

enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}

さらにunionを使う例は是非とも入れておきたいので、Queryをもう一つ追加することにします。無理にでもunionを使うために汎用的な検索メソッドを追加することにしましょう。キーワードにヒットする全てのユーザーとタスクを返せるようにします。

union SearchResult = User | Task

type Query {
    search(keyword: String): [SearchResult!]!
}

UserかTaskのどちらかであるSearchResultの配列を返すsearchメソッドを定義しました。引数keywordはNULLを許容するので、指定されない場合は全件を返すことにします。

最後にMutationを定義しておきましょう。

input CreateUserInput {
  name: String!
  email: String!
}

input CreateTaskInput {
  title: String!
  description: String
  status: TaskStatus!
  ownerId: ID!
}

input UpdateUserInput {
  name: String
  email: String
}

input UpdateTaskInput {
  title: String
  description: String
  status: TaskStatus
}

type Mutation {
  createUser(input: CreateUserInput!): User
  createTask(input: CreateTaskInput!): Task
  updateUser(id: ID!, input: UpdateUserInput!): User
  updateTask(id: ID!, input: UpdateTaskInput!): Task
}

UserとTaskを作成/更新をするMutationを定義しました。上記の例で分かるとおり引数の型はtypeではなくinputを使用して定義する必要があります。以上でスキーマは完成です。

※削除は面倒臭いの省略します

最後にスキーマ全体を掲載しておきます。

src/schema.ts
import { gql } from 'apollo-server'

export const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
    tasks: [Task!]!
  }

  type Task {
    id: ID!
    title: String!
    description: String
    status: TaskStatus!
    owner: User!
  }

  enum TaskStatus {
    OPEN
    INPROGRESS
    DONE
  }

  union SearchResult = User | Task

  input CreateUserInput {
    name: String!
    email: String!
  }

  input CreateTaskInput {
    title: String!
    description: String
    status: TaskStatus!
    ownerId: ID!
  }

  input UpdateUserInput {
    name: String
    email: String
  }

  input UpdateTaskInput {
    title: String
    description: String
    status: TaskStatus
  }

  type Query {
    getUser(id: ID!): User
    getTask(id: ID!): Task
    search(keyword: String): [SearchResult!]!
  }

  type Mutation {
    createUser(input: CreateUserInput!): User
    createTask(input: CreateTaskInput!): Task
    updateUser(id: ID!, input: UpdateUserInput!): User
    updateTask(id: ID!, input: UpdateTaskInput!): Task
  }
`

リゾルバを定義する

データを取得するためのリゾルバを実装します。src/resolver.ts(ファイル名は任意です)に実装を記述していきます。なるべくシンプルに実装するためにデータはメモリ上に持つことにしましょう。

まずは初期データとリゾルバ内で使用する各種データ型を定義します。まずは型から。

src/resolver.ts
interface User {
  id: string
  name: string
  email: string
}

interface Task {
  id: string
  title: string
  description: string
  status: TaskStatus
  ownerId: string
}

type TaskStatus = 'OPEN' | 'INPROGRESS' | 'DONE'
type CreateUserInput = Omit<User, 'id'>
type CreateTaskInput = Omit<Task, 'id'>
type UpdateUserInput = Partial<Omit<User, 'id'>>
type UpdateTaskInput = Partial<Omit<Task, 'id' | 'ownerId'>>

input系の型は既存の型であるUserとTaskから導出させることで冗長にならないようにしてみました。つづいて初期データがこちらになります。

src/resolver.ts
const users: User[] = [
  {
    id: '1',
    name: 'Alice Smith',
    email: 'alice@example.com',
  },
  {
    id: '2',
    name: 'Bob Johnson',
    email: 'bob@example.com',
  },
  {
    id: '3',
    name: 'Charlie Brown',
    email: 'charlie@example.com',
  },
]

const tasks: Task[] = [
  {
    id: '1',
    title: 'Buy groceries',
    description: 'Milk, Bread, Eggs',
    status: 'OPEN',
    ownerId: '1',
  },
  {
    id: '2',
    title: 'Schedule dentist appointment',
    description: 'Visit Dr. Alice next week',
    status: 'OPEN',
    ownerId: '1',
  },
  {
    id: '3',
    title: 'Finish report',
    description: 'Complete the financial report for Q2',
    status: 'INPROGRESS',
    ownerId: '2',
  },
  {
    id: '4',
    title: 'Book flight tickets',
    description: 'Travel to New York in September',
    status: 'DONE',
    ownerId: '2',
  },
  {
    id: '5',
    title: 'Plan birthday party',
    description: 'Organize a surprise party for Bob',
    status: 'DONE',
    ownerId: '3',
  },
  {
    id: '6',
    title: 'Renew gym membership',
    description: 'Membership expires next month',
    status: 'OPEN',
    ownerId: '3',
  },
]

長くて見にくくなってしまいました。「searchの結果でUserとTaskが同時に取得できるようなテストデータを作って」とChatGPTにお願いして作ってもらったデータをそのまま貼り付けました。データを自分で作るの結構大変ですよね・・・。本当に助かります。

ここでのポイントはメモリ上のデータはスキーマの型と一致していない点です。User型にはtasksの配列は定義されてませんが、スキーマのUser型はtasks配列を持っています。Task型はownerIdを持っていますが、スキーマのTask型が持つのはownerという名前のUser型の値となります。

リゾルバは、このギャップを埋めてあげる必要があります。以下リゾルバの実装となります。

src/resolver.ts
export const resolvers = {
  Query: {
    getTask: (_: any, args: { id: string }) =>
      tasks.find((task) => String(task.id) === args.id),
    getUser: (_: any, args: { id: string }) =>
      users.find((user) => String(user.id) === args.id),
    search: (_: any, args: { keyword: string | undefined | null }) => {
      const keyword = args.keyword
      if (keyword == null) {
        return [...tasks, ...users]
      }
      const matchedTasks = tasks.filter(
        (task) =>
          task.title.includes(keyword) || task.description.includes(keyword)
      )
      const matchedUsers = users.filter(
        (user) => user.name.includes(keyword) || user.email.includes(keyword)
      )
      return [...matchedTasks, ...matchedUsers]
    },
  },
  SearchResult: {
    __resolveType: (result: User | Task) => {
      if ('email' in result) {
        return 'User'
      }
      if ('title' in result) {
        return 'Task'
      }
      return null
    },
  },
  User: {
    tasks: (user: User) => tasks.filter((task) => task.ownerId === user.id),
  },
  Task: {
    owner: (task: Task) => users.find((user) => user.id === task.ownerId),
  },
  Mutation: {
    createUser: (_: any, args: { input: CreateUserInput }): User => {
      const newUser = {
        id: String(users.length + 1),
        ...args.input,
        tasks: [],
      }
      users.push(newUser)
      return newUser
    },
    createTask: (_: any, args: { input: CreateTaskInput }): Task => {
      const user = users.find((user) => user.id === args.input.ownerId)
      const newTask = {
        id: String(tasks.length + 1),
        ...args.input,
        owner: user,
      }
      tasks.push(newTask)
      return newTask
    },
    updateUser: (
      _: any,
      args: { id: string; input: UpdateUserInput }
    ): User => {
      const user = users.find((user) => user.id === args.id)
      if (!user) {
        throw new Error(`User with ID ${args.id} not found`)
      }
      Object.assign(user, args.input)
      return user
    },
    updateTask: (
      _: any,
      args: { id: string; input: UpdateTaskInput }
    ): Task => {
      const task = tasks.find((task) => task.id === args.id)
      if (!task) {
        throw new Error(`Task with ID ${args.id} not found`)
      }
      Object.assign(task, args.input)
      return task
    },
  },
}

リゾルバの実装を詳しく見ていきたいところですが、ひとまず先にすべての実装を作り上げて実際に動作確認できるようにしてしまいます。

サーバを起動するコードを書いていきます。src/server.tsに以下の内容を記述します。

import { resolvers } from './resolver'
import { typeDefs } from './schema'
import { ApolloServer } from 'apollo-server'

export function init() {
  const server = new ApolloServer({
    typeDefs,
    resolvers,
  })

  return {
    start: async () => {
      const { url } = await server.listen()
      console.log(`🚀 Server ready at ${url}`)
    },
    stop: async () => {
      await server.stop()
    },
  }
}

スキーマとリゾルバを指定してGraphQLサーバを生成しています。ここではサーバの開始と終了をするための関数を返しているだけなので注意が必要です

(テストを考慮してこのような形にしてあります)。

テストを書いて動作確認する

テスト(jestを使います)では下記のようにテスト開始前と終了後に、サーバの起動と終了処理をそれぞれ呼び出すようにします。

test/test.ts
import { init } from '../src/server'

const { start, stop } = init()
beforeAll(async () => {
  console.log('> start server...')
  await start()
})

afterAll(async () => {
  console.log('> stop server...')
  await stop()
})

実際にテストケースを書いてみます。上記のテストにさらに以下を追加します。

test('get task(id=1)', async function () {
  const response = await axios.post('http://localhost:4000', {
    query: `query ($taskId: ID!) {
      getTask(id: $taskId) {
        id
        title
        description
        status
      }
    }`,
    variables: {
      taskId: '1',
    },
  })
  expect(response.data.data).toMatchInlineSnapshot(``)
})

expectする値は空にしてあります。自分で頑張って埋めても良いのですが面倒なので、jestに自動で埋めてもらってから答え合わせをすることにします。

下記のコマンドを実行します。

$ npx jest -u

するとexpect部分にサーバから帰ってきた値が自動的に埋め込まれます。

test('get task(id=1)', async function () {
  const response = await axios.post('http://localhost:4000', {
    query: `query ($taskId: ID!) {
      getTask(id: $taskId) {
        id
        title
        description
        status
      }
    }`,
    variables: {
      taskId: '1',
    },
  })
  expect(response.data.data).toMatchInlineSnapshot(`
    {
      "getTask": {
        "description": "Milk, Bread, Eggs",
        "id": "1",
        "status": "OPEN",
        "title": "Buy groceries",
      },
    }
  `)
})

リゾルバで実装した初期データの値と一致していることが確認できます。

上記テストコードのtoMatchInlineSnapshotはあくまでも文字列比較をしているだけなので、一文字でも余計な空白のズレなどが発生するとテストは失敗します。この文字列をメンテナンスしていくのは手間なので、初回だけ答えを埋めてもらい、今後は通常の比較をするようにします。

expect(response.data.data).toMatchObject({
  getTask: {
    id: '1',
    title: 'Buy groceries',
    description: 'Milk, Bread, Eggs',
    status: 'OPEN',
  },
})

toMatchObjectでObjectの比較を行うようにしました(ついでに順番も入れ替えておきました)。

リゾルバの実装の詳細

実際に動かせる環境が手に入ったので、クエリを投げつつリゾルバの実装の詳細を見ていきます。

まずはQuery部分を見ていきます。

export const resolvers = {
  // ... 省略 ...  
  Query: {
    getTask: (_: any, args: { id: string }) =>
      tasks.find((task) => String(task.id) === args.id),
    getUser: (_: any, args: { id: string }) =>
      users.find((user) => String(user.id) === args.id),
    search: (_: any, args: { keyword: string | undefined | null }) => {
      const keyword = args.keyword
      if (keyword == null) {
        return [...tasks, ...users]
      }
      const matchedTasks = tasks.filter(
        (task) =>
          task.title.includes(keyword) || task.description.includes(keyword)
      )
      const matchedUsers = users.filter(
        (user) => user.name.includes(keyword) || user.email.includes(keyword)
      )
      return [...matchedTasks, ...matchedUsers]
    },
  },
  // ... 省略 ...  

Query内には、スキーマのQueryで定義されたメソッドと同名の関数が実装されています。つまりリゾルバのQueryにはエントリポイントとなるトップレベルのリゾルバを実装します。

ここでは特別なことは何もしていません。引数で指定されたidやkeywordに合致するレコードをメモリの配列から抽出しているだけです。

getTask

以前書いたように、メモリ上の型とスキーマ上の型は厳密に一致していないのでした。そしてこのギャップをリゾルバが埋めてあげる必要があると書きました。この点について見ていきます。まずは先ほど実行したテスト結果をもう一度見てみます。

test('get task(id=1)', async function () {
  const response = await axios.post('http://localhost:4000', {
    query: `query ($taskId: ID!) {
      getTask(id: $taskId) {
        id
        title
        description
        status
      }
    }`,
    variables: {
      taskId: '1',
    },
  })
  expect(response.data.data).toMatchObject({
    getTask: {
      id: '1',
      title: 'Buy groceries',
      description: 'Milk, Bread, Eggs',
      status: 'OPEN',
    },
  })
})

クエリでgetTaskを記述しているのでリゾルバのQuery内で定義されているgetTaskが呼び出されます。しかしgetTaskリゾルバが返す型はスキーマの型とは異なるはずです。具体的にはTask型はスキーマの型が持っていないownerIdを持っています。

しかし上記のテスト結果を見てわかるように、レスポンスにはownerIdは含まれていません。もちろん、これはクエリで要求した項目だけがレスポンスに含まれるようなっているためです。この挙動はリゾルバが返した値の中からクライアントが要求してない値を自動的にフィルタリングしてくれているからです。つまりこのテストの例では型のギャップはまだ生まれていないことになります。

少しクエリを修正してみます。

test('get task(id=1) with owner', async function () {
  const response = await axios.post('http://localhost:4000', {
    query: `query ($taskId: ID!) {
      getTask(id: $taskId) {
        id
        title
        description
        status
        owner {
          id
          name
          email
        }
      }
    }`,
    variables: {
      taskId: '1',
    },
  })
  expect(response.data.data).toMatchObject({
    getTask: {
      id: '1',
      title: 'Buy groceries',
      description: 'Milk, Bread, Eggs',
      status: 'OPEN',
      owner: {
        id: '1',
        name: 'Alice Smith',
        email: 'alice@example.com',
      },
    },
  })
})

この例ではTask型に存在しない(が、スキーマのTask型には存在する)ownerを取得しています。リゾルバはどのようにownerを取得するのでしょうか?これを解決するのがネストしたリゾルバになります。

リゾルバの実装に戻ります。

export const resolvers = {
  // ... 省略 ...  
  Task: {
    owner: (task: Task) => users.find((user) => user.id === task.ownerId),
  },
  // ... 省略 ...  
}

このリゾルバがギャップを埋める処理を行なっている本体となります。

クエリにはowner { ... }という記述があります。このownerはTask型であり、上記のowner関数が対応するリボルバとして呼び出されることになります。引数であるtask変数は、getTaskで取得したTask型の値になります。これはメモリ上の値なのでownerIdを持っています。

このownerIdを使って、メモリ上のUser配列から合致するものを取得して返却します。このようにして型のギャップを埋めていきます。

つづいてunionの扱い方を確認するためにsearchのテストを書いてみます。

test('search(keyword=Bob)', async function () {
  const response = await axios.post('http://localhost:4000', {
    query: `query($keyword: String) {
      search(keyword: $keyword) {
        __typename
        ... on User {
          id
          name
          email
        }
        ... on Task {
          id
          title
          description
          status
          owner {
            id
          }
        }
      }
    }`,
    variables: {
      keyword: 'Bob',
    },
  })
  expect(response.data.data).toMatchObject({
    search: [
      {
        __typename: 'Task',
        id: '5',
        title: 'Plan birthday party',
        description: 'Organize a surprise party for Bob',
        status: 'DONE',
        owner: {
          id: '3',
        },
      },
      {
        __typename: 'User',
        id: '2',
        name: 'Bob Johnson',
        email: 'bob@example.com',
      },
    ],
  })
})

キーワードBobで検索することでUserとTaskを一件ずつ取得することができました。

ところでSearchリゾルバの実装ではメモリ上のUser配列とTask配列の中から条件を満たすものを一つの配列に混ぜ込んで返していました。GraphQLサーバは、ごちゃ混ぜ配列の各要素がUserかTaskのどちらであるかを判別して、クエリに記述された項目だけを返すようにフィルタリングする必要があります。

一体どのようにしてGraphQLサーバはUnion型の値の実際の型を判別するのでしょうか?GraphQLサーバは自動で判別することなどできないので、これもリゾルバを定義してやる必要があります。

下記が該当するリゾルバです。

export const resolvers = {
  // ... 省略 ...  
  SearchResult: {
    __resolveType: (result: User | Task) => {
      if ('email' in result) {
        return 'User'
      }
      if ('title' in result) {
        return 'Task'
      }
      return null
    },
  },
  // ... 省略 ...    
}

リゾルバのトップレベルにスキーマのUnion型と同名のプロパティを定義し、その中で__resolveTypeを定義します。判別ロジックとしては、どちらか片方にしか存在しないプロパティ名が存在するかどうかで型を判別し、型名を返します。

更新系

ひととおり見るべきエッセンスは見てきました。

最後にざっくりと残りの更新系もテストして終了にしたいと思います。

test('a series of steps', async function () {
  const createUserRes = await axios.post('http://localhost:4000', {
    query: `mutation ($input: CreateUserInput!) {
      createUser(input: $input) {
        id
        name
        email
      }
    }`,
    variables: {
      input: {
        name: 'David Thompson',
        email: 'david@examplle.com',
      },
    },
  })
  expect(createUserRes.data.data).toMatchObject({
    createUser: {
      id: '4',
      name: 'David Thompson',
      email: 'david@examplle.com',
    },
  })

  const updateUserRes = await axios.post('http://localhost:4000', {
    query: `mutation($updateUserId: ID!, $input: UpdateUserInput!)  {
      updateUser(id: $updateUserId, input: $input) {
        id
        name
        email
      }
    }`,
    variables: {
      updateUserId: '4',
      input: {
        email: 'david@example.com',
      },
    },
  })
  expect(updateUserRes.data.data).toMatchObject({
    updateUser: {
      id: '4',
      name: 'David Thompson',
      email: 'david@example.com',
    },
  })

  const createTaskRes = await axios.post('http://localhost:4000', {
    query: `mutation($input: CreateTaskInput!)  {
      createTask(input: $input) {
        id
        title
        description
        status
        owner {
          id
        }
      }
    }`,
    variables: {
      input: {
        description: 'Buy basic GraphQL books',
        ownerId: '4',
        status: 'OPEN',
        title: 'Buy Books',
      },
    },
  })
  expect(createTaskRes.data.data).toMatchObject({
    createTask: {
      id: '7',
      title: 'Buy Books',
      description: 'Buy basic GraphQL books',
      status: 'OPEN',
      owner: {
        id: '4',
      },
    },
  })

  const updateTaskRes = await axios.post('http://localhost:4000', {
    query: `mutation UpdateTask($updateTaskId: ID!, $input: UpdateTaskInput!) {
      updateTask(id: $updateTaskId, input: $input) {
        id
        title
        description
        status
        owner {
          id
        }
      }
    }`,
    variables: {
      updateTaskId: '7',
      input: {
        status: 'DONE',
      },
    },
  })
  expect(updateTaskRes.data.data).toMatchObject({
    updateTask: {
      id: '7',
      title: 'Buy Books',
      description: 'Buy basic GraphQL books',
      status: 'DONE',
      owner: {
        id: '4',
      },
    },
  })

  const searchRes = await axios.post('http://localhost:4000', {
    query: `query($keyword: String) {
      search(keyword: $keyword) {
        ... on User {
          id
        }
        ... on Task {
          id
          owner {
            id
          }
        }
      }
    }`,
    variables: {
      keyword: 'Buy',
    },
  })
  expect(searchRes.data.data).toMatchObject({
    search: [
      {
        id: '1',
        owner: {
          id: '1',
        },
      },
      {
        id: '7',
        owner: {
          id: '4',
        },
      },
    ],
  })
})

特に言及するべき点はありません。簡単にテストの内容を書くと、

  • 新しいUserを作成
  • 新しいUserに紐づくTaskを作成
  • 作成したTaskのステータスを更新
  • searchで作成したTaskを取得できることを確認

をしています。

おわりに

駆け足でしたがGraphQLの基本は押さえることができたのではないかと思います。僕はGraphQLについては完全に未経験というわけではなかったのですが、あやふやに理解していた部分が多かったので今回の記事を作成する過程で頭の中をすっきりと整理することができました。

この記事を参照する方のお役に立てれば幸いです。

Discussion