0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

みなさん、こんにちは!

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

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

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

事前準備

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

  • Google Picker API
  • Google Drive API

セットアップ

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

Next.js のセットアップ

qiita.rb
npx create-next-app@latest

Google API のインストール

qiita.rb
npm install googleapis

型定義追加

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

Google Picker 設定

APIキー生成

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

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

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

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

OAuthクライアント設定

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

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

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

プログラム

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

認証処理プログラム

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

lib/google/oauth.ts

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

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

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

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

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

utils/app/useInjectScript.ts

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

app/api/google-drive/route.ts

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

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

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

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

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

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

さいごに

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

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

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?