📈

Next.jsでHealth Planet APIのデータをグラフ表示してみた(セッション機能付き)

2024/01/15に公開

やったこと

Health Planet APIから体重・体脂肪データを取得し、それをグラフで表示させるサンプルを実装しました。
後述の「振り返り」に記載した問題があり当初の予定とは異なってしまったので、やってみた系記事として供養することにしました。
下記サイトで挙動を確認できます。

https://health-graph.yuto-m.dev/

こんな感じで表示されます。

対象読者

Next.jsを触ったことがあり、Typescriptやnpmなどが理解できている方向けに書いたので、手順は省いてコードだけ載せています。
不明点や分かりづらい点はコメント頂けると幸いです🙇‍♂️

できること

OAuth認証したユーザーの体重・体脂肪データをグラフで表示できます。
取得した情報はセッションで保持されます。
データを外部DBに保存することなどはしておらず、セッション破棄でデータが破棄されます。

以下解説

実装の主だったところを解説していきます。
Next.jsのセットアップやnpm installなどのセットアップは省いているので、適宜installしてください。

主な技術

  • Next.js v14.0.3(Page Router)
    • App Routerよりも情報量があるかなと思ってPage Routerにしました。
    • Next.jsの勉強のために作ったと言っても過言ではない。
  • next-iron-session
    • OAuth認証後に、バックエンドで取得したデータをフロントと共有するために使用してます。
  • Recharts
    • グラフ表示するために使用しましたが、別のライブラリでもOK。
  • Chakra UI
    • 最低限のデザインのために使いましたが無くてもOK。
  • tailwindcss
    • Chakra UIの補完として使いましたが、無くてもできそう。

コード解説

下記で主なコードを載せています。
注意点が以下4点あります。

  • ChatGPTのコピペがあるので実装に統一感はありません。
  • 実装めんどくさくなったため、型がanyな箇所がいくつかあります😌
  • エラー処理がconsole.log()しかしていない箇所がいくつかあります。実装がめんど(ry)。
  • CSSは適当です。実装がめ(ry)。

フロント側

フロントはindex.tsxとgraph.tsxの2つだけです。

src/pages/index.tsx
import { Text, Button, Link } from '@chakra-ui/react';
import { withSession } from '../lib/session';
import Graph from '@/ui/graph';

export const getServerSideProps = withSession(async function ({
  req,
}: {
  req: any;
}) {
  // セッションからデータを取得
  const authData = req.session.get('authData');

  if (!authData) {
    return {
      props: { authData: null },
    };
  }
  // ページコンポーネントにpropsとしてデータを渡す
  return {
    props: { authData },
  };
});

// Health Planetのデータがあればグラフを表示させる
export default function Home({ authData }: { authData: any }) {
  const HEALTH_PLANET_AUTH_URL = `https://www.healthplanet.jp/oauth/auth`;
  const CLIENT_ID = process.env.HEALTHPLANET_CLIENT_ID;
  const REDIRECT_URI = `https://${process.env.NEXT_PUBLIC_DOMAIN}/api/callback`;

  const authLink = `${HEALTH_PLANET_AUTH_URL}?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=innerscan`;

  return (
    <main
      className={`flex min-h-screen flex-col items-center justify-between p-24`}
    >
      {authData ? (
        <div className='z-10 max-w-5xl w-full items-center text-sm lg:flex flex-col'>
          <div className='text-center pb-1'>
            <Button onClick={logout}>連携解除</Button>
          </div>
          <div className='pt-1 text-center'>
            <p>
              連携解除するとグラフは表示されなくなります。
              <br />
              グラフを表示するには、再度連携を行う必要があります。
            </p>
          </div>
          <div className='pt-10 text-center w-full'>
            <Graph authData={authData} />
          </div>
        </div>
      ) : (
        <div className='z-10 max-w-5xl w-full items-center text-sm lg:flex flex-col'>
          <div className='text-center pb-1'>
            <Button as='a' href={authLink} colorScheme='blue' size='lg'>
              Health Planetと連携
            </Button>
          </div>
          <div className='pt-1 text-center'>
            <p>
              連携すると、体重・体脂肪率のデータがグラフで表示されます。
              <br />
              取得したデータはグラフの表示以外に利用しておりません。データの保存もしておりません。
              <br />
              *現在15日前までのデータのみ表示されます。
            </p>
          </div>
        </div>
      )}
      {/*ここから下はお問い合わせ関係なので不要*/}
      <div className='mb-32 w-full lg:w-full lg:mb-0  lg:text-left'>
        <Text>
          バグ修正や機能追加の要望は
          <Link
            color='blue'
            isExternal={true}
            href='https://twitter.com/yutooo_m'
          >
            X(旧Twitter)
          </Link>
          または
          <Link
            color='blue'
            isExternal={true}
            href='https://forms.gle/jbXgwXdSoUooDco67'
          >
            お問合せフォーム
          </Link>
          からご連絡ください。
        </Text>
      </div>
    </main>
  );
}

const logout = async () => {
  try {
    const response = await fetch('/api/logout');
    if (response.ok) {
      // ログアウト成功時の処理
      window.location.href = '/';
    } else {
      // エラーハンドリング
      console.error('Logout failed');
    }
  } catch (error) {
    console.error('Error:', error);
  }
};
  • index.tsxでHealth Planet APIとOAuth認証を開始し、認証成功した場合はグラフが表示されます。
  • グラフで表示させるデータは後述しているcallback.tsのhandler関数内で取得しています。
src/ui/graph.tsx
import {
  CartesianGrid,
  Legend,
  Line,
  LineChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from 'recharts';

interface RespData {
  date: string;
  keydata: string;
  model: string;
  tag: string;
}

interface Resp {
  birth_date: string;
  data: RespData[];
}

interface formattedData {
  date: string;
  weight: string;
  bodyFat: string;
}

export default function Graph({ authData }: { authData: Resp }) {
  const resp = authData;
  const data = sortData(transformData(resp));
  return (
    <ResponsiveContainer width='100%' height={400}>
      <LineChart
        width={730}
        height={250}
        data={data}
        margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
      >
        <CartesianGrid strokeDasharray='3 3' />
        <XAxis dataKey='date' />
        <YAxis dataKey='weight' yAxisId={1} />
        <YAxis dataKey='bodyFat' orientation='right' yAxisId={2} />
        <Tooltip />
        <Legend />
        <Line type='monotone' dataKey='weight' stroke='#8884d8' yAxisId={1} />
        <Line type='monotone' dataKey='bodyFat' stroke='#82ca9d' yAxisId={2} />
      </LineChart>
    </ResponsiveContainer>
  );
}

function reformatDate(dateString: string) {
  return (
    dateString.substring(0, 4) +
    '/' +
    dateString.substring(4, 6) +
    '/' +
    dateString.substring(6, 8)
  );
}

function transformData(resp: Resp): formattedData[] {
  const dataMap = new Map();

  resp.data.forEach((item) => {
    const formattedDate = reformatDate(item.date);
    if (!dataMap.has(formattedDate)) {
      dataMap.set(formattedDate, { date: formattedDate });
    }

    const entry = dataMap.get(formattedDate);
    if (item.tag === '6021') {
      entry.weight = item.keydata;
    } else if (item.tag === '6022') {
      entry.bodyFat = item.keydata;
    }
  });

  return Array.from(dataMap.values());
}

function sortData(data: formattedData[]) {
  return data.sort((a, b) => {
    const dateA = new Date(a.date);
    const dateB = new Date(b.date);
    return dateA.getTime() - dateB.getTime();
  });
}
  • Rechartsでグラフを表示させるUIコンポーネントです。
  • ここだけ最初に作ったから型がちゃんとついてます。

API側

apiはcallback.tsとlogout.ts2つだけです。

src/pages/api/callback.ts
import axios from 'axios';
import { withSession } from '../../lib/session';

const handler = async (req: any, res: any) => {
  const { code } = req.query;
  const params = new URLSearchParams();
    params.append('code', code);
    params.append('client_id', process.env.HEALTHPLANET_CLIENT_ID as string);
    params.append('client_secret', process.env.HEALTHPLANET_CLIENT_SECRET as string);
    params.append('grant_type', 'authorization_code');
    params.append('redirect_uri', `https://${process.env.NEXT_PUBLIC_DOMAIN}/`);
  try {
    const tokenResponse = await axios.post('https://www.healthplanet.jp/oauth/token', params);
    const accessToken = tokenResponse.data.access_token;

    const innerScanParam = new URLSearchParams();
    innerScanParam.append('access_token', accessToken);
    innerScanParam.append('tag', '6021,6022');
    innerScanParam.append('date', '1')
    // Note: iron-sessionはデータをcookieに保存している関係上、データ量が多いとcookieに保存できなくてエラーになるのでfrom,toを指定して取得する
    // toは現時点の日時
    const now = new Date();
    // fromは16日前の00:00:00に設定。なぜか17日以上前だとエラーになる。
    const twoWeeksAgo = new Date();
    twoWeeksAgo.setDate(now.getDate() - 16);
    twoWeeksAgo.setHours(0, 0, 0, 0);

    // フォーマットされた日時を取得
    const formattedNow = formatDate(now);
    const formattedTwoWeeksAgo = formatDate(twoWeeksAgo);
    innerScanParam.append('from', formattedTwoWeeksAgo)
    innerScanParam.append('to', formattedNow)

    const dataResponse = await axios.post('https://www.healthplanet.jp/status/innerscan.json', innerScanParam);

    req.session.set('authData', dataResponse.data);
    await req.session.save();

    res.redirect(`https://${process.env.NEXT_PUBLIC_DOMAIN}/`);
  } catch (error) {
    res.status(500).json({ message: '認証エラー'});
  }
}

export default withSession(handler);

function formatDate(date: any) {
    const year = date.getFullYear();
    const month = ('0' + (date.getMonth() + 1)).slice(-2);
    const day = ('0' + date.getDate()).slice(-2);
    const hours = ('0' + date.getHours()).slice(-2);
    const minutes = ('0' + date.getMinutes()).slice(-2);
    const seconds = ('0' + date.getSeconds()).slice(-2);

    return `${year}${month}${day}${hours}${minutes}${seconds}`;
}

  • handler関数内でアクセストークンを取得し、その後status/innerscan.jsonから体重・体脂肪データを取得しています。
  • データをセッションに保存してフロント側でデータが使用できるようにしています。
  • 最終的にはindex.tsxにリダイレクトして画面を表示させています。
src/pages/api/logout.ts
import { withSession } from '../../lib/session';

const handler = async (req: any, res: any) => {
  // セッションデータを消去
  req.session.destroy();

  // ログインページにリダイレクト
  res.redirect('/');
};

export default withSession(handler);
  • ログアウトしたらセッション削除して、index.tsxにリダイレクトしています。

その他

ライブラリ

src/lib/session.js
import { withIronSession } from 'next-iron-session';

function sessionOptions() {
  return {
    password: process.env.SECRET_COOKIE_PASSWORD,
    cookieName: 'innerscan',
    cookieOptions: {
      secure: true
    },
  };
}

export function withSession(handler) {
  return withIronSession(handler, sessionOptions());
}
  • next-iron-sessionをいい感じに使いやすくするためのラッパーです。
  • めんどくさかったのでtsではなくjsファイルです。

env

.env.local
HEALTHPLANET_CLIENT_ID=xxxxxxxxxx
HEALTHPLANET_CLIENT_SECRET=xxxxxxxxxxxxxx

# openssl rand -base64 32で生成した文字列
SECRET_COOKIE_PASSWORD=xxxxxxxxxxxx

# ローカルではngrokで生成したドメインを指定する
NEXT_PUBLIC_DOMAIN=xxxxxxxxxxxxx.xxx
  • CLIENT_IDとSECRETはHealth Planet APIにアプリを登録して取得したものを使います。参考記事
  • SECRET_COOKIE_PASSWORDは最低32文字のランダムな文字列ならなんでも大丈夫です。
    • 例えばopenssl rand -base64 32で生成できます。
  • ngrokのドメインに関しては、後述の「ローカルで開発する際の注意点」を参照してください。

ローカルで開発する際の注意点

Health Planet APIを使用するにあたりアプリーケーションの登録が必要になります(下記画像参照)。
その際にホストドメインの設定が必要になりますが、ここをlocalhostで登録することができません。
対策として、今回はngrokというローカルPCのポートを外部公開するサービスを使用しています。
ngrokを使用すると一時的に使用可能なドメインが発行されるので、それをホストドメインとして登録するとローカルでAPIを試すことができます。
ローカルのポートを外部公開することになるので、機密情報などが漏れないように気をつけましょう。
めんどくさかったのでBasic認証なしで開発していましたが心配なら下記でBasic認証設定できるらしいので設定したら良いと思われます。参考記事
$ ngrok http 3000 --basic-auth='XXXXXX:ZZZZZZ'

振り返り

実装当初はNextAuth.jsのCustom Providerを使用してOAuth認証をしてみたかったんですが、アクセストークンは取得できているのに未認証状態として判定されてしまう問題がうまく解消できなくてライブラリの使用を諦めました。
有名だからNextAuth.jsを使ってみたんですが、そんなに頑張らないでOAuth認証するくらいならライブラリなしで全然問題ないのでは?っていう感じがします。

また、なぜかfromに17日前以上を指定するとデータが取得できなかったので、今回は作りこむのを一旦保留にしました。
とはいえNext.jsで認証付きの何かを作るには、ちょうど良いくらいの分量な気がするのでOKです。

参考

手順を参考にさせてもらいました🙇‍
https://zenn.dev/kou_pg_0131/articles/tanita-health-planet-api

Discussion