Google PickerでGoogle Driveのファイル選択機能を実装する

みなさん、こんにちは！

社内向けアプリの開発にあたり、各ユーザの権限で Google Drive のファイルをダウンロードできる機能を実装しようとして試行錯誤した結果をまとめます。

完成するとこのような感じで、ダイアログからファイルを選択しダウンロードできるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/cef807a3-692a-4692-8d3e-0367ec3a7ec7.png)
以降で早速、具体的な実装方法について見ていきましょう。

## 事前準備
最初に、以下のAPIを有効化しておきます。
* Google Picker API
* Google Drive API

## セットアップ
プロジェクトのセットアップをしていきます。今回は Next.js を使用しています。

Next.js のセットアップ
```ruby:qiita.rb
npx create-next-app@latest
```

Google API のインストール
```ruby:qiita.rb
npm install googleapis
```

型定義追加
```ruby:qiita.rb
npm install -D @types/gapi @types/gapi.drive
```

## Google Picker 設定
### APIキー生成
「APIとサービス」→「認証情報」を開き、APIキーを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/671d9cd1-20e1-4db5-81aa-69a0771bd13a.png)

編集画面でキーの名前や制限の設定を行います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/ac2d66ed-52c2-45a6-b221-80710669486c.png)

今回の設定は以下の通りです。

* アプリケーションの制限：なし
* APIの制限：キーを制限（Google Picker API）

## OAuthクライアント設定
各ユーザの権限でアクセスできるよう、OAuthクライアントを構成します。

「認証情報」を開き、「クライアント」からOAuthクライアントIDの作成を行います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/50e44d74-822e-4c6a-98d1-6e474dd2adc0.png)

承認済みのリダイレクトURIは、認証完了後にリダイレクトされるURLです。
リダイレクト時のリクエストには認証コードが含まれているため、認証コードからトークンを生成するためのAPIのURLを指定します。（今回の場合、`/api/auth/google-oauth/callback`）

## プログラム
以下の各種プログラムをプロジェクト配下に配置します。

### 認証処理プログラム
認証フローで利用するコードをまとめたものです。

lib/google/oauth.ts
```ruby:qiita.rb
import { google } from "googleapis";
import { Credentials } from "google-auth-library/build/src/auth/credentials";
import { cookies } from "next/headers";

const OPTIONS = {
  clientId: process.env.NEXT_PUBLIC_CLIENT_ID, // OAuthクライアントID
  clientSecret: process.env.NEXT_PUBLIC_CLIENT_SECRET, // OAuthクライアントシークレット
  redirectUri: process.env.NEXT_PUBLIC_REDIRECT_URI, // OAuthクライアントのリダイレクトURI
};

const OAUTH_TOKEN_BASE_URL = "https://accounts.google.com/o/oauth2/token";

export function createOAuth2Client(options?: {
  clientId?: string;
  clientSecret?: string;
  redirectUri?: string;
}) {
  const { clientId, clientSecret, redirectUri } = {
    ...OPTIONS,
    ...options,
  };
  return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
}

// エンドポイントからアクセストークンを取得するための関数
export async function getToken(code: string): Promise<Credentials> {
  const url = OAUTH_TOKEN_BASE_URL;
  const json = {
    code: code,
    client_id: OPTIONS.clientId,
    client_secret: OPTIONS.clientSecret,
    redirect_uri: OPTIONS.redirectUri,
    grant_type: "authorization_code",
  };
  const params = {
    method: "POST",
    body: JSON.stringify(json),
  };

  const response = await fetch(url, params);
  const data = await response.json();
  return data;
}

const COOKIE_TOKEN_NAME = "google-oauth2-tokens";

// トークンをCookieに保存するための関数
export async function setOAuthTokenCookie(credentials: Credentials) {
  return (await cookies()).set({
    name: COOKIE_TOKEN_NAME,
    value: JSON.stringify(credentials),
    maxAge: 60 * 60 * 24 * 30, // 一ヶ月
    path: "/",
    sameSite: "lax",
    secure: true,
  });
}

// トークンをCookieから取得するための関数
export async function getOAuthTokenCookie(): Promise<Credentials | undefined> {
  const tokens = (await cookies()).get(COOKIE_TOKEN_NAME)?.value;

  if (!tokens) return undefined;

  return JSON.parse(tokens);
}
```
※テストのため clientId, clientSecret も`NEXT_PUBLIC_`にしていますが、機密情報のため本来は`NEXT_PUBLIC_`にせず扱ったほうがよいです。

### API
認証処理を行うためのAPIを作成します。

以下は「Google Drive」ボタンを初めて押した際または「Refresh」ボタンを押した際に実行される、Google認証用のAPIです。Google認証へのリンクを作成し、リダイレクトさせます。

app/api/auth/google-oauth/route.ts
```ruby:qiita.rb
import { createOAuth2Client } from "@/lib/google/oauth";
import { NextResponse, type NextRequest } from "next/server";

// ユーザーに許可を得る認可スコープ
// この場合は、DriveへのRead権限の認可
const SCOPES = ["https://www.googleapis.com/auth/drive.readonly"];

export async function GET(req: NextRequest) {
  const oauth2Client = createOAuth2Client();

  // Google 認証へのリンク生成
  const url = oauth2Client.generateAuthUrl({
    access_type: "offline",
    scope: SCOPES,
    prompt: "consent",
  });

  // Google認証リンクへリダイレクト
  return NextResponse.redirect(url);
}
```

以下は認証完了後にリダイレクトされるURLのAPIです。以下の処理を行います。

* リクエストのパラメータから認証コードを取得
* 認証コードをもとにトークンを生成
* 元のページ（`req.url`）にリダイレクト

app/api/auth/google-oauth/callback/route.ts
```ruby:qiita.rb
import { getToken } from "@/lib/google/oauth";
import { NextResponse, type NextRequest } from "next/server";
import { cookies } from "next/headers";

export async function GET(req: NextRequest) {
  const { origin } = new URL(req.url);

  // 認証コードの取得
  // ?code=xxxxxのURLパラメータをget
  const searchParams = req.nextUrl.searchParams;
  const code = searchParams.get("code");

  if (!code) {
    return new Response(`Missing query parameter`, {
      status: 400,
    });
  }

  // 認証コードからトークンを生成
  const tokens = await getToken(code);

  // Cookieを設定
  (await cookies()).set({
    name: "google-oauth2-tokens",
    value: JSON.stringify(tokens),
    maxAge: 60 * 60 * 24 * 30, // 一ヶ月
    path: "/",
    sameSite: "lax",
    secure: true,
  });

  return NextResponse.redirect(`${origin}`);
}
```

### アプリケーション本体
アプリケーション本体のコンポーネントです。Google Picker だけの最小限の構成です。

app/page.tsx
```ruby:qiita.rb
import GooglePicker from "@/components/ui/GooglePicker";
import { getOAuthTokenCookie } from "@/lib/google/oauth";

export default async function Home() {
  const credentials = await getOAuthTokenCookie();
  const accessToken = credentials?.access_token || "";

  return (
    <main className="min-h-screen flex flex-col gap-5 justify-center items-center p-10">
      <GooglePicker accessToken={accessToken} />
    </main>
  );
}
```

以下は Google Picker 本体のコンポーネントです。Google API を利用する都合上、クライアントサイドコンポーネントとして切り分けています。

components/ui/GooglePicker.tsx
```ruby:qiita.rb
"use client";
declare let google: any;
declare let window: any;
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import useInjectScript from "@/utils/app/useInjectScript";
import { useRouter } from "next/navigation";
import DriveLogo from "../logo/Drive";
import { GoogleDocument } from "@/types/google";
import { RefreshCcw } from "lucide-react";
import toast from "react-hot-toast";

type Props = {
  accessToken: string;
};

const GooglePicker = ({ accessToken }: Props) => {
  const router = useRouter();

  const [loaded, error] = useInjectScript("https://apis.google.com/js/api.js");
  const [pickerApiLoaded, setPickerApiLoaded] = useState(false);

  const API_KEY = process.env.NEXT_PUBLIC_API_KEY;
  const APP_ID = process.env.NEXT_PUBLIC_CLIENT_ID;

  // Picker API 読み込み
  useEffect(() => {
    if (loaded && !error && !pickerApiLoaded) {
      loadApi();
    }
  }, [loaded, error, pickerApiLoaded]);

  const openPicker = () => {
    // アクセストークンがない場合は取得させる
    if (!accessToken) {
      router.push("/api/auth/google-oauth");
    }

    if (accessToken && loaded && !error && pickerApiLoaded) {
      createPicker();
    }
  };

  const loadApi = () => {
    window.gapi.load("picker", { callback: onPickerApiLoaded });
  };

  const onPickerApiLoaded = () => {
    setPickerApiLoaded(true);
  };

  const createPicker = () => {
    const view = new google.picker.View(google.picker.ViewId.DOCS);
    const uploadView = new google.picker.DocsUploadView();

    const picker = new google.picker.PickerBuilder()
      .enableFeature(google.picker.Feature.NAV_HIDDEN)
      .enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
      .setAppId(APP_ID)
      .setDeveloperKey(API_KEY)
      .setOAuthToken(accessToken)
      .setLocale("ja")
      .addView(view)
      .addView(uploadView)
      .setCallback(pickerCallback)
      .build();

    picker.setVisible(true);
  };

  const pickerCallback = (data: any) => {
    // ファイルが選択された場合
    // APIを呼び出し、ファイルのダウンロードを行う
    if (data.action === "picked") {
      const docs = data.docs;
      docs.forEach((doc: GoogleDocument) => {
        fetch("/api/google-drive", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ doc }),
        })
          .then((res) => res.json())
          .then((data) => {
            toast.success("Download completed successfully.");
            console.log(data);
          })
          .catch((error) => {
            toast.error("Download Failed.");
            console.log(error);
          });
      });
    }
    console.log(data);
  };

  const refreshToken = () => {
    toast.loading("Redirecting...");
    router.push("/api/auth/google-oauth");
  };

  return (
    <div className="flex flex-col gap-3">
      <Button
        variant="outline"
        className="hover:cursor-pointer"
        onClick={openPicker}
      >
        <DriveLogo width={24} height={24} />
        Google Drive
      </Button>
      <Button
        variant="secondary"
        className="hover:cursor-pointer"
        onClick={refreshToken}
      >
        <RefreshCcw size={24} />
        Refresh
      </Button>
    </div>
  );
};

export default GooglePicker;
```

以下は [React-google-drive-picker](https://github.com/Jose-cd/React-google-drive-picker/tree/master) というライブラリのコードを借用したものです。
（React-google-drive-pickerを直接使っても良かったですが、細かいカスタマイズのため下記以外は独自の実装としました）

utils/app/useInjectScript.ts
```ruby:qiita.rb
// https://github.com/Jose-cd/React-google-drive-picker/blob/master/src/useInjectScript.tsx
import { useEffect, useState } from "react";

type InjectorType = "init" | "loading" | "loaded" | "error";
interface InjectorState {
  queue: Record<string, ((e: boolean) => void)[]>;
  injectorMap: Record<string, InjectorType>;
  scriptMap: Record<string, HTMLScriptElement>;
}

const injectorState: InjectorState = {
  queue: {},
  injectorMap: {},
  scriptMap: {},
};

type StateType = {
  loaded: boolean;
  error: boolean;
};

export default function useInjectScript(url: string): [boolean, boolean] {
  const [state, setState] = useState<StateType>({
    loaded: false,
    error: false,
  });

  useEffect(() => {
    if (!injectorState.injectorMap?.[url]) {
      injectorState.injectorMap[url] = "init";
    }
    // check if the script is already cached
    if (injectorState.injectorMap[url] === "loaded") {
      setState({
        loaded: true,
        error: false,
      });
      return;
    }

    // check if the script already errored
    if (injectorState.injectorMap[url] === "error") {
      setState({
        loaded: true,
        error: true,
      });
      return;
    }

    const onScriptEvent = (error: boolean) => {
      // Get all error or load functions and call them
      if (error) console.log("error loading the script");
      injectorState.queue?.[url]?.forEach((job) => job(error));

      if (error && injectorState.scriptMap[url]) {
        injectorState.scriptMap?.[url]?.remove();
        injectorState.injectorMap[url] = "error";
      } else injectorState.injectorMap[url] = "loaded";
      delete injectorState.scriptMap[url];
    };

    const stateUpdate = (error: boolean) => {
      setState({
        loaded: true,
        error,
      });
    };

    if (!injectorState.scriptMap?.[url]) {
      injectorState.scriptMap[url] = document.createElement("script");
      if (injectorState.scriptMap[url]) {
        injectorState.scriptMap[url].src = url;
        injectorState.scriptMap[url].async = true;
        // append the script to the body
        document.body.append(injectorState.scriptMap[url] as Node);
        injectorState.scriptMap[url].addEventListener("load", () =>
          onScriptEvent(false)
        );
        injectorState.scriptMap[url].addEventListener("error", () =>
          onScriptEvent(true)
        );
        injectorState.injectorMap[url] = "loading";
      }
    }

    if (!injectorState.queue?.[url]) {
      injectorState.queue[url] = [stateUpdate];
    } else {
      injectorState.queue?.[url]?.push(stateUpdate);
    }

    // remove the event listeners
    return () => {
      //checks the main injector instance
      //prevents Cannot read property 'removeEventListener' of null in hot reload
      if (!injectorState.scriptMap[url]) return;
      injectorState.scriptMap[url]?.removeEventListener("load", () =>
        onScriptEvent(true)
      );
      injectorState.scriptMap[url]?.removeEventListener("error", () =>
        onScriptEvent(true)
      );
    };
  }, [url]);

  return [state.loaded, state.error];
}
```

以下はダウンロード処理を切り出したAPIです。Driveのファイルは`file`と`document`で扱いが異なるため、両方に対応させる形でプログラムを記述しています。
`document`の場合はファイル形式を指定する必要があるので、一律PDF化してダウンロードするようにしています。

app/api/google-drive/route.ts
```ruby:qiita.rb
import { createOAuth2Client, getOAuthTokenCookie } from "@/lib/google/oauth";
import { GoogleDocument } from "@/types/google";
import { createWriteStream } from "fs";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const body = await req.json();
  const doc: GoogleDocument = body.doc;

  const credentials = await getOAuthTokenCookie();
  if (!credentials) {
    return NextResponse.json({
      message: "Invalid credential",
    });
  }

  const auth = createOAuth2Client();
  auth.setCredentials(credentials);

  const service = google.drive({ version: "v3", auth });

  try {
    if (doc.type == "file") {
      const dest = createWriteStream(`./${doc.name}`);
      const result = await service.files.get(
        { fileId: doc.id, alt: "media" },
        { responseType: "stream" }
      );
      result.data
        .on("end", () => {
          console.log("Done.");
        })
        .on("error", (error) => {
          console.log(error);
        })
        .pipe(dest);
    }
    if (doc.type == "document") {
      // documentの場合、PDF化してダウンロードする
      const dest = createWriteStream(`./${doc.name}.pdf`);
      const result = await service.files.export(
        {
          fileId: doc.id,
          mimeType: "application/pdf",
        },
        { responseType: "stream" }
      );
      result.data
        .on("end", () => {
          console.log("Done.");
        })
        .on("error", (error) => {
          console.log(error);
        })
        .pipe(dest);
    }
  } catch (error) {
    console.log(error);
    return NextResponse.json({
      message: `Failed to fetch file: ${error}`,
    });
  }

  return NextResponse.json({
    message: `Fetched file: ${doc.id}`,
  });
}
```

### 型
上記プログラムで使用している独自の型です。
Google Picker のレスポンスで得られたデータをもとに定義しています。

types/google.ts
```ruby:qiita.rb
export interface GoogleDocument {
  description: string;
  driveSuccess: boolean;
  embedUrl: string;
  iconUrl: string;
  id: string;
  isShared: boolean;
  lastEditedUtc: number;
  mimeType: string;
  name: string;
  organizationDisplayName: string;
  serviceId: string;
  sizeBytes: number;
  type: string;
  url: string;
}
```

## 実際のアプリケーション
npm run dev でアプリケーションを起動すると、以下の通りボタンが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/fa9a2f2d-cb4d-440d-80fe-1adb3aa59c61.png)

初回アクセスでCookieが無い場合、認証ページにリダイレクトされます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/e04c7916-4050-4fa8-9acf-3135f7cfb489.png)

「許可」をクリックすると、元のページにリダイレクトされます。
認証完了後に開発者ツールで「Application」→「Cookies」を確認すると、認証情報がCookieに登録されていることが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/cc3bfbbe-7ba9-4396-a1d1-e3603f139e63.png)

再度「Google Drive」のボタンをクリックすると、ファイル選択ダイアログが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/e123936c-4307-4610-acf9-376f38898c88.png)

適当なファイルを選んで「選択」をクリックするとダウンロード処理が実行され、ローカルにファイルがダウンロードされます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956386/1e5cc98f-81e7-42ee-89a1-9cb8a0c16d0d.png)

## さいごに
Google Picker のドキュメント自体はあるものの、いざ Next.js 上で実装しようとすると必要な情報がなかったり、思ったようにいかなかったりで一苦労しました。

Google認証と連携させて Google Drive のファイルを扱いたいという場面はよくあると思うので、本記事が同じような課題にぶつかった方の一助になれば幸いです。

## 参考
* [Guide: Ingesting Google Drive data for RAG | Learn from Paragon](https://www.useparagon.com/learn/what-to-know-about-ingesting-google-drive-data-for-rag/)
* [Google認証とOAuth2.0で、Googleサービスと連携する例のアレを作る](https://zenn.dev/acompany/articles/51e1dcc83279ee)
* [The Google Picker API](https://developers.google.com/workspace/drive/picker/guides/overview)