社内向けアプリの開発にあたり、各ユーザの権限で Google Drive のファイルをダウンロードできる機能を実装しようとして試行錯誤した結果をまとめます。
完成するとこのような感じで、ダイアログからファイルを選択しダウンロードできるようになります。
以降で早速、具体的な実装方法について見ていきましょう。
事前準備
最初に、以下の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キーを作成します。
編集画面でキーの名前や制限の設定を行います。
今回の設定は以下の通りです。
- アプリケーションの制限:なし
- APIの制限:キーを制限(Google Picker API)
OAuthクライアント設定
各ユーザの権限でアクセスできるよう、OAuthクライアントを構成します。
「認証情報」を開き、「クライアント」からOAuthクライアントIDの作成を行います。
承認済みのリダイレクト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のファイルはfile
とdocument
で扱いが異なるため、両方に対応させる形でプログラムを記述しています。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 でアプリケーションを起動すると、以下の通りボタンが表示されます。
初回アクセスでCookieが無い場合、認証ページにリダイレクトされます。
「許可」をクリックすると、元のページにリダイレクトされます。認証完了後に開発者ツールで「Application」→「Cookies」を確認すると、認証情報がCookieに登録されていることが確認できます。
再度「Google Drive」のボタンをクリックすると、ファイル選択ダイアログが表示されます。
適当なファイルを選んで「選択」をクリックするとダウンロード処理が実行され、ローカルにファイルがダウンロードされます。
さいごに
Google Picker のドキュメント自体はあるものの、いざ Next.js 上で実装しようとすると必要な情報がなかったり、思ったようにいかなかったりで一苦労しました。
Google認証と連携させて Google Drive のファイルを扱いたいという場面はよくあると思うので、本記事が同じような課題にぶつかった方の一助になれば幸いです。