Apollo Clientを使ってGitHubクライアントを作成してみた

こんにちは、クリラボユニットの早津です。

今回はGraphQLのクライアントであるApollo Clientについて取り上げてみます。 GraphQLついてはこの記事では言及しないので以下を参考にしてみてください。(とてもわかりやすくも網羅的にまとまっていて大変助かりました!)

employment.en-japan.com

これからReactを使用していきますが、Apollo ClientはVueやAngularなどにも対応しています。特定のフレームワークなどに依存しないのも魅力です。

では簡単なGitHubクライアントを例に説明していきます。 機能は以下になります。

機能

検索フォームでテキストを入力し該当するリポジトリのリストを表示する

シンプルの一言ですね。

バージョンの確認

ライブラリ version
apollo-client 2.6.0
graphql 14.3.1
react 16.8.6
react-dom 16.8.6
react-apollo 2.5.6
typescript 3.4.5

環境構築

さくっと環境構築をしたいのでcreate-react-appを使用します。 typescriptを使いたいので以下のコマンドを実行します。

create-react-app アプリケーション名 --typescript

tsconfig.jsonの設定を変更します。

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "noImplicitAny": false,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve"
  },
  "include": [
    "src"
  ]
}

必要なライブラリをインストール

まずはapollo clientを使う上で必要なものをインストールします。

yarn add apollo-client apollo-link apollo-link-http graphql graphql-tag react-apollo apollo-cache-inmemory

UIを作っていく上で必要なライブラリをインストール

yarn add @emotion/core @emotion/styled @material-ui/core

GitHub Tokenの取得

GitHubのapiを使用するのでGitHubのtokenを取得します。

以下のページへ移動してください。(GitHubでサインインしていることを前提としています。)

https://github.com/settings/tokens

以下のようにrepoに全てチェックをつけてください。

f:id:AdwaysEngineerBlog:20190531115644p:plain

チェック後、「Generate token」でtokenを発行してください。

その後、プロジェクトフォルダ直下に.envファイルを作成して以下のように設定してください。

REACT_APP_GITHUB_TOKEN=先ほど作成したtoken

Apollo Clientの設定

src/index.tsxを以下のように編集してください。

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { InMemoryCache } from "apollo-cache-inmemory";
import ApolloClient from "apollo-client";
import { createHttpLink } from "apollo-link-http";
import { ApolloProvider } from "react-apollo";

const cache = new InMemoryCache();

const httpLink = createHttpLink({
  uri: "https://api.github.com/graphql",
  headers: {
    authorization: `Bearer ${process.env.REACT_APP_GITHUB_TOKEN}`
  }
});

const client = new ApolloClient({ link: httpLink, cache });

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById("root")
);

追加したものについて簡単に説明していきます。

const cache = new InMemoryCache();

上記ではGraphQLから取得したものをキャッシュに保存しておくためにInMemoryCacheを初期化します。

const httpLink = createHttpLink({
  uri: "https://api.github.com/graphq",
  headers: {
    authorization: `Bearer ${process.env.REACT_APP_GITHUB_PERSONAL_ACCESS_TOKEN}`
  }
});

この部分はGraphQLサーバーにリクエストをするための設定です。 GitHubのapiを使用するためにheader情報に先ほど追加したtokenを設定しています。

const client = new ApolloClient({ link: httpLink, cache });

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById("root")
)

最後に上で設定したものをApolloProviderのpropsに渡します。 これによってApp以下のコンポーネントからGraphQLにアクセスできるようになります。

Apollo Clientで開発していく上で必要な開発者ツールを入れましょう。 以下のリンクから追加してください。

chrome.google.com

その後、以下のようにChromeの開発者ツールの中にApolloのタグを開き、 試しにクエリを入力し実行してみます。 レスポンスの結果として右側にreactのリポジトリの総数が表示されています。

f:id:AdwaysEngineerBlog:20190531115712p:plain

stateを定義する

Apollo Clientではフロントでもつべき状態(選択状態など)を管理する方法があります。 方法については後に説明します。

src/state.tsを作成します。

type State = {
  searchText: string;
  __typename: string;
};

const initialState:State = {
  searchText: "",
  __typename: "State"
};

export default initialState;

今回は検索用のテキストしかないので記述量は少ないです。

resolverを定義する

Apollo Clientにおけるresolverはローカルのキャッシュを書き換えたり、取得する際に使用します。今回はGraqhQLサーバーから取得したデータとは別に検索窓のテキストをキャッシュに乗せるようにします。そうすることでフロントで持つべき状態とサーバーから取得したデータを一元管理することができます。

resolverについて

https://www.apollographql.com/docs/react/essentials/local-state#local-resolvers

apollo Clientのバージョンが2.5より前の時、キャッシュにフロントの状態をのせる場合はapollo-link-stateを使用していました。ですがバージョン >= 2.5からapollo-link-stateがapollo clientに統合されたのでapollo-link-stateを別途インストールする必要がなくなりました。

apollo-link-stateを使用した場合の実装の例(一部)

import { withClientState } from "apollo-link-state";


const stateLink = withClientState({
  cache,
  defaults: initialState,
  resolvers: resolvers
});

このバージョンの違いで実装が変わるので一度以下の記事を参考にしてください。

https://www.apollographql.com/docs/react/essentials/local-state#migrating

https://blog.apollographql.com/announcing-apollo-client-2-5-c12230cabbb7

このようにapollo clientはデータを取得したり(query)、更新したり(mutation)する以外にフロントの状態を管理してくれる機能を提供してくれるのが強いですね。

src/resolvers.tsを作成してください。

import gql from "graphql-tag";

const resolvers = {
  Mutation: {
    changeSearchText: (_, { text }, { cache }) => {
      const query = gql`
        query SearchText {
          searchText @client
        }
      `;

      cache.writeQuery({
        query,
        data: {
          searchText: text
        }
      });
    }
  }
};
export default resolvers;

changeSearchTextが検索窓のテキストを変更したときに呼ばれる関数です。

https://www.apollographql.com/docs/graphql-tools/resolvers#resolver-function-signature

その中で先ずキャッシュから変更したいものを取得します。

const query = gql`
  query SearchText {
    searchText @client
  }
`;

その後、cache.writeQueryでキャッシュを書き換えます。

cache.writeQuery({
  query,
  data: {
    searchText: text
  }
});

writeQueryの引数のオブジェクトにqueryと変更したいデータを書きます。 特定のidをもつデータを変更したい場合は、writeFragmentを使用します。

これでresolverの設定は終了です。

先ほど設定したresolverとstateを使えるように設定します。

src/index.tsxを編集します。

import React from "react";
import ReactDOM from "react-dom";
import { InMemoryCache } from "apollo-cache-inmemory";
import { ApolloLink } from "apollo-link";
import ApolloClient from "apollo-client";
import { createHttpLink } from "apollo-link-http";
import { ApolloProvider } from "react-apollo";
import initialState from "./state";
import resolvers from "./resolvers";
import App from "./App";

const cache = new InMemoryCache();

const httpLink = createHttpLink({
  uri: "https://api.github.com/graphql",
  headers: {
    authorization: `Bearer ${
      process.env.REACT_APP_GITHUB_TOKEN
    }`
  }
});

const link = ApolloLink.from([httpLink]);

const client = new ApolloClient({ link, cache, resolvers });

cache.writeData({
  data: initialState
});

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById("root")
);

追加部分を順にみていきます。

const link = ApolloLink.from([httpLink]);

上記ではhttpLinkなどlinkというものを新しく追加しました。 apolo clientではこのlinkという概念がとても重要です。 linkとはGraphQLの操作(orperation)の結果をどんな風に取得したいのか、そしてその結果に対して何をしたいのかを定義することができます。

例えば以下のようなことをすることが可能です。

  • エラーハンドリング
  • サーバーからのレスポンスのデータ加工
  • キャッシュにフロント側で持っておきたい状態を保存(apollo-link-state)

今回はapollo-link-httpしか追加していないですが、エラーハンドリング用のlinkや独自でlinkを作成することもできます。 linkについては以下を参考にしてみてください。


const client = new ApolloClient({ link, cache, resolvers });

上記ではresolverを追加しました。

cache.writeData({
  data: initialState
});

stateをキャッシュに書き込んでいます。

次にリポジトリの情報を表示するためのコンポーネントを作成します。

リポジトリの情報を表示するコンポーネントを作成

src/components/Repository.tsxを作成します。

propsで受け取る情報は以下です。

  • リポジトリのurl
  • リポジトリの名前
  • リポジトリのスター数

コードは以下のようになります。

import * as React from "react";
import { Card } from "@material-ui/core";
import Typography from "@material-ui/core/Typography";
import Link from "@material-ui/core/Link";
import styled from "@emotion/styled";
import { CardProps } from "@material-ui/core/Card";

const RepositoryWrapper = styled(({ children, ...props }: CardProps) => (
  <Card {...props}>{children}</Card>
))`
  border: 1px solid #ccc;
  max-width: 800px;
  margin: 0 auto 20px auto;
  text-align: left;
  padding: 10px;
`;

type Props = {
  url: string;
  name: string;
  starCount: number;
};

const Repository: React.FC<Props> = ({ url, name, starCount }: Props) => {
  return (
    <RepositoryWrapper>
      <Typography>
        url: <Link href={url}>{url}</Link>
      </Typography>
      <Typography>name: {name}</Typography>
      <Typography>star: {starCount}</Typography>
    </RepositoryWrapper>
  );
};

export default Repository;

検索ロジックの作成

検索ロジックを作っていきます。 検索をする上でqueryを書かなくてはいけません。 今回はGitHubのGraphQLのapiを使用するので詳しい説明は省きますので気になる方は以下を参考にしてください。

GitHub GraphQL API v4 | GitHub Developer Guide

queryをコンポーネントとは別ファイルに書いていきたいのでsrc/queries.tsを作成してください。

import gql from "graphql-tag";

export const getRepositories = gql`
  query($searchText: String!) {
    search(first: 10, query: $searchText, type: REPOSITORY) {
      nodes {
        ... on Repository {
          id
          name
          description
          url
          stargazers {
            totalCount
          }
        }
      }
    }
    searchText @client
  }
`;

queryの引数に$searchTextという変数を定義します。検索窓に入力した値がここにはいってきます。(入ってくるように後でします。) searchの引数についてです。

  • first・・・上位n
  • query・・・ 検索キーワード
  • type・・・検索するための種類(今回はリポジトリですが他にもIssueなどあります。)

また@clientというディレクティブをつけることでローカルのデータもGraphQLのクエリで取得することができます。 上記からわかるようにサーバー、キャッシュ両方から同時にでデータを取得することができるのです。

次にsrc/mutations.tsを作成します。

import gql from "graphql-tag";

export const changeSearchText = gql`
  mutation($text: String!) {
    changeSearchText(text: $text) @client
  }
`;

こちらも@clientがついてることでローカルのmutationを呼び出すようにしています。

SearchFieldコンポーネントの作成

src/components/SearchField.tsxを作成します。

import * as React from "react";
import TextField from "@material-ui/core/TextField";
import { Mutation } from "react-apollo";
import _ from "lodash";
import styled from "@emotion/styled";
import { Button } from "@material-ui/core";
import { useState } from "react";
import { changeSearchText } from "../mutations";

const TextFieldWrapper = styled("div")`
  display: flex;
  align-items: center;
  justify-content: center;
  position: sticky;
  top: 0;
  background: #fff;
  padding: 20px;
`;

type Props = {
  text: string;
  onSearchRepository: (text: string) => void;
};

const SearchField: React.FC<Props> = ({ text, onSearchRepository }: Props) => {
  const [inputText, setInputValue] = useState<string>("");

  return (
    <Mutation mutation={changeSearchText} variables={{ text: text }}>
      {changeSearchText => {
        const handleInputEnter = e => {
          setInputValue(e.target.value);
          changeSearchText({ variables: { text: e.target.value } });
          if (e.keyCode === 13) {
            onSearchRepository(inputText);
          }
        };

        const handleSearchButtonClick = () => {
          onSearchRepository(inputText);
        };

        return (
          <TextFieldWrapper>
            <TextField
              label={"検索"}
              value={inputText}
              onChange={handleInputEnter}
              onKeyDown={handleInputEnter}
            />
            <Button
              variant={"contained"}
              color={"secondary"}
              disabled={_.isEmpty(inputText)}
              onClick={handleSearchButtonClick}
            >
              検索
            </Button>
          </TextFieldWrapper>
        );
      }}
    </Mutation>
  );
};

export default SearchField;

ここで重要なのがMutationコンポーネントです。 キャッシュの中にあるデータの更新処理を行います。

MtutationQuery(後に紹介する)コンポーネントはrender Propパターンで実装されています。 Mutationのpropsに先ほど定義したmutationとvariablesを設定します。 キャッシュの中にあるsearchTextを更新しています。

src/App.tsxを編集します。

import React, { useState } from "react";
import { Query } from "react-apollo";
import { CircularProgress } from "@material-ui/core";
import Typography from "@material-ui/core/Typography";
import styled from "@emotion/styled";
import _ from "lodash";
import { getRepositories } from "./queries";
import Repository from "./components/Repository";
import SearchField from "./components/SearchField";

const ProgressWrapper = styled("div")`
  && {
    width: 100vw;
    height: 100vh;
    position: absolute;
    top: 0;
    display: flex;
    justify-content: center;
    align-items: center;
  }
`;

const App = () => {
  const [text, setText] = useState<string>("");

  const handleSearchRepository = text => {
    setText(text);
  };

  return (
    <div>
      <SearchField text={text} onSearchRepository={handleSearchRepository} />
      <Query query={getRepositories} variables={{ searchText: text }}>
        {({ data, loading, error }) => {
          if (loading)
            return (
              <ProgressWrapper>
                <CircularProgress />
              </ProgressWrapper>
            );
          if (error) return `Error! ${error}`;

          return (
            <div>
              {_.isEmpty(data.search.nodes) && (
                <Typography align={"center"}>
                  検索結果がありません。もう一度検索し直してください&#x1f62d;
                </Typography>
              )}
              {data.search.nodes.map(repo => {
                return (
                  <Repository
                    key={repo.id}
                    url={repo.url}
                    name={repo.name}
                    starCount={repo.stargazers.totalCount}
                  />
                );
              })}
            </div>
          );
        }}
      </Query>
    </div>
  );
};

export default App;

先ほどはMutationコンポーネントを使いましたが、データを取得するためにreact-apolloが提供してくれているQueryコンポーネントを使用します。
Queryコンポーネントはqueryを指定します。処理の内容がMutationとは異なりますが使い方としてはほとんど同じです。
Queryコンポーネントのrender propの中では以下が受け取れます。

  • data・・・GraphQLから取得したデータ
  • loading・・・ローディング状態
  • error・・エラー状態

ローディング状態やエラーの状態を自動的に取得もこのように手軽にできるのです。

例えばエラーの状態は以下のように表示されます。

f:id:AdwaysEngineerBlog:20190531115756p:plain

これでGitHubクライアントの完成です。
Apolloの開発ツールを開いてキャッシュがどのように保存されているか確認してみたり、検索して試してみてください。
今回は簡単なものだったのでmutationの数は少ないですが、一つのコンポーネントの複数のmutationなどを渡したいといったときにrender propでネストした状態が生まれてしまいます。それを回避するにはどうすれば良いかなど考えてみるのも面白いかと思います。

以上になります。