レック・テクノロジー・コンサルティング株式会社TECH BLOG

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

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

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

image-6.png

以降で早速、具体的な実装方法について見ていきましょう。

事前準備

最初に、以下のAPIを有効化しておきます。

  • Google Picker API
  • Google Drive API

セットアップ

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

Next.js のセットアップ

npx create-next-app@latest

Google API のインストール

npm install googleapis

型定義追加

npm install -D @types/gapi @types/gapi.drive

Google Picker 設定

APIキー生成

「APIとサービス」→「認証情報」を開き、APIキーを作成します。

image-1.png

編集画面でキーの名前や制限の設定を行います。

image-2.png

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

  • アプリケーションの制限:なし
  • APIの制限:キーを制限(Google Picker API)

OAuthクライアント設定

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

「認証情報」を開き、「クライアント」からOAuthクライアントIDの作成を行います。

image-3.png

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

プログラム

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

認証処理プログラム

認証フローで利用するコードをまとめたものです。

lib/google/oauth.ts

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

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

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

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

"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 というライブラリのコードを借用したものです。(React-google-drive-pickerを直接使っても良かったですが、細かいカスタマイズのため下記以外は独自の実装としました)

utils/app/useInjectScript.ts

// 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のファイルはfiledocumentで扱いが異なるため、両方に対応させる形でプログラムを記述しています。documentの場合はファイル形式を指定する必要があるので、一律PDF化してダウンロードするようにしています。

app/api/google-drive/route.ts

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

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

初回アクセスでCookieが無い場合、認証ページにリダイレクトされます。

image-4.png

「許可」をクリックすると、元のページにリダイレクトされます。認証完了後に開発者ツールで「Application」→「Cookies」を確認すると、認証情報がCookieに登録されていることが確認できます。

image-5.png

再度「Google Drive」のボタンをクリックすると、ファイル選択ダイアログが表示されます。

image-6.png

適当なファイルを選んで「選択」をクリックするとダウンロード処理が実行され、ローカルにファイルがダウンロードされます。

image-7.png

さいごに

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

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

参考

この記事をシェアする

  • Facebook
  • X
  • Pocket
  • Line
  • Hatena
  • Linkedin

資料請求・お問い合わせはこちら

ページトップへ戻る