CORSを解決してIGDB APIを利用する

IGDBのAPIはクエリを文字列で送る仕組みで整形が面倒なため、[igdb-api-node][igdb-api-node]を使用する。

[igdb-api-node]: https://github.com/twitchtv/igdb-api-node
__ラッパーコンポーネント__
5001はエミュレーターのポート
Cloud Functionsは指定なしでデプロイするとregionがus-central1になるため、変えたい場合各自変更する。
```ts:lib/igdb-api-node.ts
// Git repo: https://github.com/twitchtv/igdb-api-node
// IGDB API docs: https://api-docs.igdb.com/

import igdb from 'igdb-api-node';

import {
  FIREBASE_PROJECT_ID,
  FIREBASE_EMULATE,
  TWITCH_CLIENT_ID,
  TWITCH_APP_ACCESS_TOKEN,
  IGDB_API_PROXY,
} from '@/config';

export const igdbClient = igdb(TWITCH_CLIENT_ID, TWITCH_APP_ACCESS_TOKEN, {
  baseURL:
    FIREBASE_EMULATE === 'true'
      ? `http://127.0.0.1:5001/${FIREBASE_PROJECT_ID}/us-central1/${IGDB_API_PROXY}`
      : `https://us-central1-${FIREBASE_PROJECT_ID}.cloudfunctions.net/${IGDB_API_PROXY}`,
});
```
__Cloud Functionsで中継してCORSを解決する。__

通常POSTする時はオブジェクトを投げるモノだが、このAPIは文字列を投げる。
この時、中継すると{ [フロントから投げたデータ]: '' } の形式になるため、再び文字列に戻す。
```ts:src/api/igdb/index.ts
import axios from 'axios';
import * as cors from 'cors';
import * as functions from 'firebase-functions';

export const igdb = functions.https.onRequest(async (req, res) => {
  // req.bodyは { [フロントから投げたデータ]: '' } の形式で送られてくる
  cors({ origin: true })(req, res, async () => {
    const response = await axios.post(req.url, Object.keys(req.body)[0], {
      baseURL: 'https://api.igdb.com/v4',
      headers: {
        Authorization: req.headers.authorization,
        'client-id': req.headers['client-id'],
      },
    });

    res.send(response.data);
  });
});
```

__一応簡単なカスタムhookも作った。__

フィールド名の指定はピリオドで無限にネストを繋ぐ事ができるため、そのような形式に対応する型を作成する方法がよく分からず諦めた。
エンドポイントの型定義もしようと思えばできる。

searchとsortは同時にできない様で、どちらかしか使えないようにした。
```ts:hooks/useIgdbApi.ts
import { useEffect, useRef, useState } from 'react';

import { igdbClient } from '@/lib/igdb-api-node';

type SearchGameQuery = {
  name?: string;
  filter?: string;
};

type SearchGameOptions = {
  fields?: string[];
  limit?: number;
  offset?: number;
  sort?: {
    field: string;
    direction: 'asc' | 'desc';
  };
  endpoint?: string;
};

const DEFAULT_FIELDS = ['id'];
const DEFAULT_OFFSET = 0;
const DEFAULT_LIMIT = 10;
const DEFAULT_ENDPOINT = 'games';

export const useIgdbApi = () => {
  const [data, setData] = useState<{ [key: string]: any }[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const prevSearchArgs = useRef<string | null>(null);

  const search = async (query: SearchGameQuery, options?: SearchGameOptions) => {
    const searchArgs = JSON.stringify({ query, options });
    if (prevSearchArgs.current === searchArgs) {
      console.log('Search args are the same, skipping');
      return;
    }
    prevSearchArgs.current = searchArgs;

    setIsLoading(true);
    const instance = igdbClient
      .fields(options?.fields || DEFAULT_FIELDS)
      .offset(options?.offset || DEFAULT_OFFSET)
      .limit(options?.limit || DEFAULT_LIMIT);

    query.name
      ? instance.search(query.name)
      : options?.sort
      ? instance.sort(options.sort.field, options.sort.direction)
      : null;

    query.filter && instance.where(query.filter);

    const response = await instance.request(`/${options?.endpoint || DEFAULT_ENDPOINT}`);

    if (response.status === 200) {
      setData(response.data);
      setIsLoading(false);

      return {
        data: response.data,
        error: null,
      };
    } else {
      setError(new Error(response.statusText));
      setIsLoading(false);

      return {
        data: [],
        error: new Error(response.statusText),
      };
    }
  };

  return {
    data,
    isLoading,
    error,
    search,
  };
};
```
以上。かなり時間をかけてしまった。。。