🦄

【Next.js】GraphQL + OctokitでGitHubの草を取得して表示するまで

2022/12/14に公開約11,400字

この記事は https://qiita.com/advent-calendar/2022/tryt の14日目の記事です。


みなさまこんにちは。

今回はGraphQLとOctokitを利用して、GitHubの草を取得し、フロントに表示するまでの流れを書いていきたいと思います。

私自身、興味はあったもののGraphQLを触るのは今回が初めてでした。

そのため、同じように興味はあるが触ったことがない、とりあえずGraphQLを動かしてなにかを取得してみたい、ひとつものを作ってみたい、といった方々に役に立つような記事になればいいなと思っています。

また、描画するまでを解説している記事は少なかったため、見せ方まで考えてみた内容になります。

🌊 はじめに

GraphQLとは

GraphQL は、アプリケーション・プログラミング・インタフェース (API) 向けのクエリ言語とサーバーサイドランタイムの両方を指します。クライアントがリクエストしたデータだけを提供することを優先します。
GraphQL は、API の速度、柔軟性、開発者にとっての使いやすさを向上させるために設計されました。GraphiQL と呼ばれる統合開発環境 (IDE) にデプロイすることもできます。GraphQL は REST の代わりに、データを複数のデータソースから取得するリクエストを 1 つの API 呼び出しで構成できます。

https://www.redhat.com/ja/topics/api/what-is-graphql

要するに、フロントからリクエストして好きなデータだけを取得することができるということ...だと思います。

よく対比されるREST APIは利用シーンに沿った形で決まったデータを返却するAPIです。

全国の気象情報取得API、東日本の気象情報取得API、東京の気象情報取得API...のようにです。

これがGraphQLを使うと、全国の気象情報のデータさえあればそこから好きなようにデータを取得することができるようになります。

「自分と友達が住んでいる地域のデータだけほしいから、東京と神奈川と大阪と広島のお天気情報をリクエスト!」といったクエリを組むことができます。

引用元に書いてあるように、非常に柔軟で不要なデータを含めないようにできることが魅力に感じます。

触ってみる

GitHubからGraphQL API Explorerというものが提供されており、ブラウザ上でGraphQLを使いデータをリクエストし、レスポンスを確認することができます。

そこで最初から入力されている内容で再生ボタンを押すと、下図のように自身のGitHubのユーザー名が返ってきます。

では次に、下記のクエリでリクエストを投げてみます。

query contributions {
  user(login: "ユーザー名") {
    contributionsCollection(to: "YYYY-MM-DDT00:00:00", from: "YYYY-MM-DDT00:00:00")  {
      contributionCalendar {
        weeks {
          contributionDays {
            date
            contributionCount
          }
        }
      }
    }
  }
}

ユーザー名とto, fromはご自身用に置き換えてください。

結果は下図になりました。

今回ほしかった草の情報、つまりcontributionCountが日付とセットで返ってきました。

ここで、先ほどの気象情報の例えがより理解しやすくなったのではないでしょうか。

自分のほしいデータを取得するようにクエリを組むことで、GitHubから提供されているデータ全体からそこだけを取得することができています。

今回はNext.jsのアプリケーション内で上のようなクエリを実行するように実装し、最終的にページに描画させる、という内容になっています。

まずは完成したものから見ていきます。

🚀 成果物

流れとしてはクエリでコミット数を取得し、その数値によってCSSを動的に与え、自身のポートフォリオにこのように表示してみました。

実際にページをご覧になる方は下記からどうぞ(ローディング中の表示を考えないと...)。

https://pk-yakkun.com/

改善点

いきなりですが改善点としては下記が浮かびました。

  • 公式を意識したデザインにしてみたが、データを取得して並べているだけなので曜日が合っていない
  • 直近の資材をmainにマージするまでサボってるように見える
    • そのため、コミット数の数値を利用してもっとグラフィカルでランダム性のある見た目にすればおもしろいかな、と思った
    • p5.jsで作成するジェネラティブアートみたいなイメージ
    • コミット数とかはわからなくなるけど、それが知りたいならGitHub見ればいいか...と思った(本末転倒)

個人開発では作業内容ごとにdevelop, featureブランチを使い分け、最終的にmainブランチにマージするようにしています。

そのため、規模の大きい実装をしている間はmainにマージされず空白になってしまうのが少し悲しいです。

とはいえ、いったん情報を取得してそれっぽく表示できたのでよしとしました。

それでは具体的な方法について触れていきます。

📖 手順と解説

環境

環境はざっくり下記です。
今回関係ありそうなものを抜粋しているため、テスト系のライブラリなどは省略しています。

  • フレームワーク
    • Next.js (v13)
  • ライブラリ
    • typescript (v4.8.4)
    • graphql (v16.6.0)
    • @octokit/core (v4.1.0)
    • dayjs (v1.11.6)
  • デザインシステム
    • Atomic Design
  • UIライブラリ
    • 自作(なのでわかりにくいかもしれませんが、emotionを利用したMUI Likeなものです)
  • 補足事項
    • エイリアス設定により@/src配下を参照できるようにしています

🖥 1. 環境構築と必要なライブラリのインストール

まずは下記コマンドでNext.js + TypeScriptの環境を構築します。

npmにしているのは私が使い慣れているためです。

(🐇:すでに環境がある方は次ステップまでスキップしてください)

npx create-next-app {アプリ名} --use-npm --typescript

なんか質問されたらyと答えます。

次に、構築されたアプリケーションまで階層を移動してから各ライブラリをインストールします。

まずは先ほど構築したNext.jsのアプリケーションまで階層を移動します。

cd {アプリ名}

そこで必要な下記3つのライブラリをインストールします。

npm i graphql @octokit/core dayjs

📐 2. 各コンポーネントの設計と役割

それぞれ必要となるパーツを下記のように分割、配置してみました。

  • GitHubトークンを環境変数として定義するところ
    • .env.local
  • クエリを書いたりGitHubからデータを取得したり整形するところ
    • src/pages/api/contributions/[userName].ts
  • ↑のAPIを実行してデータを返すカスタムhook
    • src/hooks/useContributes.ts
  • 草情報のReactコンポーネント
    • src/components/organisms/Contributions/index.tsx
  • 描画先となるページコンポーネント
    • src/pages/index.tsx

先にファイルを作っても中身がないからLintエラーが出て気持ち悪いよ〜という方はあとから作成するか、ディレクトリだけ作って配下に拡張子のない適当な名前のファイルをひとつ作っておくのがよいと思います。

ディレクトリ構造だけ整えていったんコミットさせたいときは、ファイル名をgitChangeとして差分に出すようにしています。

では順序よく上から見ていきます。

🛠 3. 実装(データ取得編)

3-1. GitHubトークンを環境変数として定義する

まず、自分のGitHubトークンがない方は生成します(その手順については割愛します)。

そして、そのトークンを環境変数として.env.localに定義します。

.env.local
NEXT_PUBLIC_GITHUB_TOKEN = '{自身のGitHubトークンに置き換える}'

このトークンを利用しないと、自身のユーザー情報に紐づくデータを取得することができません。

3-2.クエリの記述とデータの取得、整形、返却

メインとなる部分です。

src/pages/contributions/api/[userName].ts
import { NextApiRequest, NextApiResponse } from "next";
import { Octokit } from "@octokit/core";
import dayjs from "dayjs";

// レスポンスの型
export type Contributions = {
  user: {
    contributionsCollection: {
      contributionCalendar: {
        weeks: [
          {
            contributionDays: [
              {
                date: string;
                contributionCount: number;
              }
            ];
          }
        ];
      };
    };
  };
};

// 最終的に描画時に利用するデータの型
export type MyContributes = {
  values: number[];
};

// メインとなる関数
export default async function handler(
  request: NextApiRequest,
  response: NextApiResponse
) {
  // リクエストのクエリをuserNameに代入
  const { userName } = request.query;

   // インスタンスを作成し、認証情報として環境変数に定義したGitHubトークンを渡す
  const octokit = new Octokit({
    auth: process.env.NEXT_PUBLIC_GITHUB_TOKEN,
  });

  // 現在の年月日と時刻を取得
  const now = await dayjs().format("YYYY-MM-DDThh:mm:ss");
  
  // 6ヶ月前の年月日と時刻を取得
  const sixMonthBefore = await dayjs()
    .subtract(6, "month")
    .format("YYYY-MM-DDThh:mm:ss");

  /**
   * クエリ部分
   * @param userName ユーザー名
   * @param now 現在の年月日
   * @param sixMonthBefore 6ヶ月前の年月日
   */
  const query = `
    query contributions ($userName:String!, $now:DateTime!, $sixMonthBefore:DateTime!) {
      user(login: $userName) {
        contributionsCollection(to: $now, from: $sixMonthBefore) {
          contributionCalendar {
            weeks {
              contributionDays {
                date
                contributionCount
              }
            }
          }
        }
      }
    }
  `;

  // クエリとそれに必要な引数を渡し、octokitを使いデータを取得する
  const contributions = await octokit.graphql<Contributions>(query, {
    userName,
    now,
    sixMonthBefore,
  });

  // レスポンスからコミット数だけを抜き出し格納するための配列を定義
  let contributionCount: number[] = [];

  // ループさせコミット数のみを配列にpushする       
  contributions.user.contributionsCollection.contributionCalendar.weeks.forEach(
    (week) => {
      week.contributionDays.forEach((contributionDay) => {
        contributionCount.push(contributionDay.contributionCount);
      });
    }
  );

  // コミット数のみ格納された配列を返却
  return response.status(200).json({
    values: contributionCount,
  });
}

ファイル名の[userName]部分はNext.jsの動的ルーティング機能を利用している証です。

ご自身のGitHubのユーザー名に置き換えるという意味ではないのでご注意を。

動的ルーティングについては公式ドキュメントがわかりやすいです。

https://nextjs-ja-translation-docs.vercel.app/docs/routing/dynamic-routes

各処理についてはコメントで書いてある通りです。

3-3. カスタムフック化

先ほどの処理をカスタムフック化します。

src/hooks/useContributes.ts
// 型をimportしておく
import { MyContributes } from "@/pages/api/contributions/[userName]";

// カスタムフック本体
export const useContributions = () => {
  
  // userNameを引数に受け取り、先ほどの取得処理を行い、dataとして返す
  const getContributions = async (userName: string) => {
    const response = await fetch(`../api/contributions/${userName}`);
    const data: Promise<MyContributes> = await response.json();
    return data;
  };

  // 関数を返却
  return {
    getContributions,
  };
};

dataの型に最近知ったAwaited<T>とか使いたかったんですが、シンプルな形で返ってくるのとわざわざそのために関数をimportするのも違うな〜と思ってやめました。

🛠 4. 実装(Reactコンポーネント編)

データを取得、整形する処理とそのカスタムフックができたあとは、実際に描画されるコンポーネントを作成します。

4-1. 草情報のコンポーネント作成

src/components/organisms/Contributions/index.tsx
import { Box } from "@/components/atoms/Box/Box";
import { Grid } from "@/components/atoms/Grid/Grid";
import { GridItem } from "@/components/atoms/GridItem/GridItem";
import { useContributions } from "@/hooks/useContributes";
import { MyContributes } from "@/pages/api/contributions/[userName]";
import { useState, useEffect } from "react";

export const Contributions = () => {
  // 取得したコミット数の配列データを管理するステート
  const [myContributes, setMyContributes] = useState<MyContributes>();
  
  // カスタムフックを代入
  const { getContributions } = useContributions();

  // 描画時に一度、カスタムフックにユーザー名を渡しデータを取得、それをステートにセットする
  useEffect(() => {
    (async () => {
      const data = await getContributions("PK-Yakkun");
      setMyContributes(data);
    })();
  }, []);

  /**
   * GitHubの草の色を決める関数
   * @param count APIで取得したコミット数
   * @returns opacityのCSS
   */
  const createOpacity = (count: number) => {
    let opacity;
    count === 0
      ? (opacity = "0")
      : 1 <= count && count <= 2
      ? (opacity = "0.2")
      : 3 <= count && count <= 6
      ? (opacity = "0.4")
      : 7 <= count && count <= 10
      ? (opacity = "0.6")
      : 11 <= count && count <= 13
      ? (opacity = "0.8")
      : 14 < count && (opacity = "1");

    return opacity;
  };

  return (
    <Box
      maxW="636px"
      bgColor="#000000bf"
      p={{ sm: "18px", md: "24px" }}
      borderRadius={{ sm: "12px", md: "18px" }}
      mx="auto"
      overflowX="scroll"
    >
      <Grid
        gridTemplateColumns="repeat(27, 1fr)"
        gridTemplateRows="repeat(7, 1fr)"
        gridAutoFlow="column"
        gap="4px"
      >
        {myContributes &&
          myContributes.values.map((count: number, index: number) => (
            <GridItem key={index}>
              <Box
                w={{ sm: "14px", md: "16px" }}
                h={{ sm: "14px", md: "16px" }}
                bg="#39D353"
                borderRadius="4px"
                opacity={createOpacity(count)}
              />
            </GridItem>
          ))}
        <Box mr={{ sm: "32px" }} />
      </Grid>
    </Box>
  );
};

カスタムフックを代入しているのは、(カスタムフックを含む)hooksはコンポーネントのトップレベルでしか使用できないというルールがあるためです。

つまりuseEffect()の中で今回作成したカスタムフックを利用することができないために、一度代入しているということになります。

useRouter()を定数routerに代入してuseEffect()の中で使うときとよく似ています。

今回はuseEffect()の第二引数を[]としているため、描画時に一度実行されます。

そこでは"PK-Yakkun"という文字列(私のGitHubのユーザー名)を引数として指定し、作成したクエリに渡り、Octokitを通してデータが取得されます。

そこで返却されたnumber型の配列をステートにセットし、描画に利用しています。

ちなみにこのReactコンポーネント内で利用している<Box>などのコンポーネントはすべて自作したものです。

MUIなどのUIライブラリを利用されたことのある方には馴染み深いかもしれませんが、それぞれ名前通りの役割だけをもった、抽象度と再利用性の高いコンポーネント群です。

それぞれに渡すpropsからレスポンシブに対応したCSSが生成されるようになっています。

そういうものを作りました〜という記事もあるので気になる方は覗いてみてください。

https://zenn.dev/pk_yakkun/articles/54a8fcbcc0195e

結論、return内でやっていることはnumber型の配列をmapし、その数値によってCSSのopacityが変わるdivをGridで並べているだけです。

4-2. ページコンポーネントで描画する

ついに最終ステップです。

pages配下で先ほどのコンポーネントを呼び出し、描画します。

src/pages/index.tsx
import { Box } from "@/components/atoms/Box/Box";
import { Typography } from "@/components/atoms/Typography/Typography";
import { Contributions } from "@/components/organisms/Contributions";
import type { NextPage } from "next";

const Home: NextPage = () => {
  return (
    <>
      {/* 省略 */}
      
      {/* GitHub contribute */}
      <Box mt={{ sm: 8, md: 10 }}>
        <Contributions />
        <Typography variant="caption" display="block" textAlign="center" mt={1}>
          直近6ヶ月間のContributes
        </Typography>
      </Box>

      {/* 省略 */}
    </>
  );
};

export default Home;

これにて無事にブラウザで確認することができ、今回のミッションはクリアとなります。

(せっかくなのでダークモードにしてみました)

🗒 参考記事

たいへん参考になりました。ありがとうございました。

https://topaz.dev/recipes/b70f1010f9f447ce7289
https://zenn.dev/yuichkun/articles/b207651f5654b0

🦄 おわりに

まだまだ不慣れですが、必要な情報を好きなように取得できるのはやはり魅力的に感じました。

今度はその利点がもっと活きるようなデータを作成した上でアプリケーションを開発してみようかなと思っています。

それではまた。

Discussion

ログインするとコメントできます