📝

GraphQLクライアントを使ってNuxtアプリケーションの実装をする

2021/02/28に公開

今更ながら、初めてGraphQLを使ったプロジェクトをやることになったので、
UIライブラリへの依存はないような設定手順などをまとめます。

とはいえ、動かすための場が必要なので Nuxt で SSG をするアプリケーションを作ることにします。
Nuxt への組み込みには @nuxtjs/compositon-api を使います。

リポジトリはこちらです。
https://github.com/sterashima78/nuxt-graphql-example

Nuxt プロジェクトのセットアップ

$ npx create-nuxt-app nuxt-graphql-example

TypeScript と Universal と SSG を選択してあとは適当に。

composition-api を使うのでそれも入れる。
https://composition-api.nuxtjs.org/getting-started/setup

普段ならディレクトリ構成いじったりするけど、とりあえずここまで。

$ npm run dev

はい。

GraphQL 周りのセットアップ

GraphQL サーバは GitHub の GraphQL API を使うことにします。
認証が必要だったりそれなりの規模なので遊ぶには十分という判断です。

エディタの設定

VSCode を使います。
GraphQL のクエリを書いているときに補完などが効くようにします。

GraphQL 拡張を入れます。
プロジェクトの推奨拡張機能に入れて、それをインストールするという手順を踏むのが個人的に好きです。

.vscode/extentions.json
{
  "recommendations": [
    "graphql.vscode-graphql"
  ]
}

設定ファイルを書きます。

graphql.config.yml
schema: 
  - https://api.github.com/graphql:
      headers: 
        Authorization: "bearer ${GITHUB_PAT}"
documents: graphql/**/*.graphql

GITHUB_PAT は GitHub のパーソナルアクセストークン (PAT) です。
以下を参考に作りましょう。
https://docs.github.com/ja/graphql/guides/forming-calls-with-graphql#authenticating-with-graphql

GITHUB_PAT は環境変数です。.env に書いて Git のコミットには含めないようにしましょう。

.env
GITHUB_PAT=<ここにPATをかく>

試しに簡単なクエリを書いてみます。以下を参考に書いていきます。
https://docs.github.com/en/graphql/reference/queries

graphql/vue.graphql
query vueRepository {
  repository(name: "vue", owner: "vuejs"){
    createdAt
  }
}

ちゃんと補完が効きます。

コード生成の設定

TypeScriptのプロジェクトなので、型情報を生成してほしいです。
また、クエリーに対応する API クライアントも欲しいのでそれを生成してもらうことにします。

GraphQL Code Generator を利用します。

以下に沿って進めます。
https://graphql-code-generator.com/docs/getting-started/installation

依存パッケージをインストール。

$ npm i graphql
$ npm install --save-dev @graphql-codegen/cli
$ npm install --save-dev @graphql-codegen/typescript

設定は graphql.config.yml に書きます。

https://graphql-config.com/usage#extensions

graphql.config.yml
schema: 
  - https://api.github.com/graphql:
      headers: 
        Authorization: "bearer ${GITHUB_PAT}"
documents: graphql/**/*.graphql
extensions:
  codegen:
    generates:
      ./types/graphql.ts:
        plugins:
          - typescript

いったん実行してみます。

$ npx graphql-codegen -r dotenv/config

graphql schema の型情報が出力されています。

types/graphql.ts
export type Maybe<T> = T | null;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: string;
  String: string;
  Boolean: boolean;
  Int: number;



/** snip **/



/** A hovercard context with a message describing how the viewer is related. */
export type ViewerHovercardContext = HovercardContext & {
  __typename?: 'ViewerHovercardContext';
  /** A string describing this context */
  message: Scalars['String'];
  /** An octicon to accompany this context */
  octicon: Scalars['String'];
  /** Identifies the user who is related to this context. */
  viewer: User;
};

APIクライアントも生成します。
プラグインなどをインストール

$ npm i graphql-request
$ npm i -D @graphql-codegen/typescript-operations @graphql-codegen/typescript-graphql-request

設定ファイルを修正

graphql.config.yml
schema: 
  - https://api.github.com/graphql:
      headers: 
        Authorization: "bearer ${GITHUB_PAT}"
documents: graphql/**/*.graphql
extensions:
  codegen:
    generates:
      ./infrastructure/graphql/generated-client.ts:
        plugins:
          - typescript
          - typescript-operations
          - typescript-graphql-request

コード生成

$ npx graphql-codegen -r dotenv/config

試しに実行してみます。
環境変数を読み込むようにパッケージ追加

$ npm i dotenv

実行のためのスクリプト実装

infrastructure/graphql/index.ts
import { GraphQLClient } from 'graphql-request';
import { getSdk } from './generated-client';
import dotenv from "dotenv";
const { parsed: env } = dotenv.config()

async function main() {
  const client = new GraphQLClient('https://api.github.com/graphql', {
    headers: {
      Authorization: `bearer ${env?.GITHUB_PAT}`
    }
  });
  const sdk = getSdk(client);
  const { repository } = await sdk.vueRepository();

  console.log(`GraphQL data:`, repository);
}

main()

実行

$ npx ts-node ./infrastructure/graphql/index.ts
GraphQL data: { createdAt: '2013-07-29T03:24:51Z' }

生成したコードから GraphQL API へのリクエストができました。

アプリケーションへの組み込み

正直やや蛇足感がありますが、生成したクライアントをアプリケーションに組み込んでいくところまでやろうと思います。

ここでは、以下3つのOrganizationが持っているリポジトリを検索するアプリケーションを作ってみようと思います。(特に意味はないです)

アプリケーションで使う型定義

リポジトリ名とイシュー数とスター数を表示することにします。
また、APIクライアントの型とそれを注入するためのキーを用意しておきます。

domain/repository.ts
import { InjectionKey } from "@nuxtjs/composition-api";
export type Repository = {
  name: string;
  numOfIssues: number;
  numOfStars: number;
}

export type FetchRepositories = (group: string) => Promise<Repository[]>
export const fetchRepositoriesInjectionKey: InjectionKey<FetchRepositories> = Symbol("FetchRepositories")

GraphQL クエリの作成

ランタイムでの型チェックのために __typenameを含めておきます。

graphql/query.graphql
query fetchRepositories($query: String!) {
  search(query: $query, type: REPOSITORY, first: 100) {
    nodes {
      ... on Repository {
        __typename
        name
        issues {
          totalCount
        }
        stargazers {
          totalCount
        }
      }
    }
  }
}

オブジェクトのコンバータ実装

型がついているとは言え、APIの実装をアプリケーションへもっていくのはあまり好きではないので、適当なレイヤでアプリケーションの型へ変換しておきます。

infrastructure/graphql/converter.ts
import { Repository } from '~/domain/repository'
import { FetchRepositoriesQuery } from './generated-client'

/**
 * Repository型
 */
type FetchRepositoriesQueryNode = Exclude<
  FetchRepositoriesQuery['search']['nodes'],
  null | undefined
>[0]
type FetchRepositoriesQueryRepositoryNode = Extract<
  FetchRepositoriesQueryNode,
  { readonly __typename?: 'Repository' }
>

/**
 * Repository型のアサーション
 */
export type IsRepositoryNode = (
  node: FetchRepositoriesQueryNode
) => node is FetchRepositoriesQueryRepositoryNode

export const isRepositoryNode: IsRepositoryNode = (
  node
): node is FetchRepositoriesQueryRepositoryNode =>
  node?.__typename === 'Repository'

/**
 * 変換関数
 */
export type ConvertRepositoryFrom = (result: FetchRepositoriesQuery) => Repository[]
export const convertRepositoryFrom: ConvertRepositoryFrom = (result) => {
  return result.search.nodes?.filter(isRepositoryNode).map((node) => ({
        name: node.name,
        numOfIssues: node.issues.totalCount,
        numOfStars: node.stargazers.totalCount,
      }))
    || []
}

公開するクライアントは、きれいになったオブジェクトを返すものにします。

infrastructure/graphql/index.ts
import { GraphQLClient } from 'graphql-request'
import { getSdk } from './generated-client'
import { FetchRepositories } from '~/domain/repository'
import { convertRepositoryFrom } from './converter'

export const fetchRepositoriesFactory = (pat: string): FetchRepositories => (
  group
) =>
  getSdk(
    new GraphQLClient('https://api.github.com/graphql', {
      headers: {
        Authorization: `bearer ${pat}`,
      },
    })
  )
    .fetchRepositories({ query: `org:${group}` })
    .then(convertRepositoryFrom)

アプリケーションへ組み込み

あとはアプリケーションで使えるようにするだけです。
まずは、provide で API クライアントを inject できる状態にします。

plugins/apiClient.ts
import { onGlobalSetup, provide, defineNuxtPlugin } from '@nuxtjs/composition-api'
import { fetchRepositoriesInjectionKey } from "~/domain/repository";
import { fetchRepositoriesFactory } from "~/infrastructure/graphql/";
export default defineNuxtPlugin(({ $config }) => {
  onGlobalSetup(() => {
    provide(fetchRepositoriesInjectionKey, fetchRepositoriesFactory($config.AUTH_TOKEN))
  })
})

PAT は runtimeConfig 経由で渡します。

nuxt.config.js
/* snip */

  privateRuntimeConfig: {
    AUTH_TOKEN: process.env.GITHUB_PAT
  },
  publicRuntimeConfig: {
    AUTH_TOKEN: process.env.NODE_ENV === "development" ? process.env.GITHUB_PAT : ""
  }

/* snip */

後は、各ページでデータを取得すればOKです。
(マークアップ省略)

pages/organization/_name.vue
import {
  defineComponent,
  inject,
  useContext,
  useStatic,
  computed,
  ref,
} from '@nuxtjs/composition-api'
import { fetchRepositoriesInjectionKey } from '~/domain/repository'

export default defineComponent({
  setup() {
    const fetchRepository = inject(fetchRepositoriesInjectionKey)
    if (!fetchRepository) {
      throw new Error('fetchRepositories is not injected')
    }
    const query = ref('')
    const { route } = useContext()
    const org = computed(() => route.value.params.name)
    const repositories = useStatic((org) => fetchRepository(org), org, 'org')
    return {
      repositories: computed(
        () =>
          (query.value === ''
            ? repositories.value
            : repositories.value?.filter((i) =>
                i.name.includes(query.value)
              )) || []
      ),
      query,
    }
  },
})

ビルド・動作確認

$ npm run generate
$ npm start

API への通信はない状態で動作します。

おわりに

この記事では、GraphQLクライアントを使った Webフロントエンドアプリケーションの実装を行いました。
また、VSCode の拡張機能を利用し、入力補完が効く状態にし、 GraphQL Code Generator を利用して、APIクライアントの生成を行いました。
APIクライアントを Nuxtアプリケーションへ組み込み、静的サイトの生成を行いました。

APIの実装をコンポーネントへ持ち込まないよにすることで、APIサーバがGraphQLから別の実装なった場合も影響が局所化されるような構成をとりました。

GraphQLは必要な情報だけを取得できるなど、開発の体験としてはとても良かったと思います。
一方で、APIへの問い合わせという目的に強く依存した形のデータが返ってくるので、このデータをコンポーネント内部へもっていくことは避けたほうが良いなー。というのが最初に思ったことでした。

パフォーマンス的な問題も発生しやすいと思うので、必要に応じて REST や別の実装に切り替えられるような作りにしておくことが大事だと思いました。

Discussion