Dropbox の API を試す

このスクラップについて
ユーザーがデータを所有できるアプリを作るにあたり、Dropbox に読み書きできると良いなと思ったので調べる過程を記録していく。

まずは ChatGPT に聞いてみる

Dropbox App の作成
App 名はユニークである必要があるようだ

Redirect URIs の登録
http://localhost:5173/callback に設定しておこう。

PKCE Grant では Client Secret は不要?
不要のようだ。

React Router v7 のセットアップ
npx create-react-router@latest dropbox-api
cd dropbox-api
npm run dev

パッケージのインストール
npm install dropbox

環境変数
Vite なので import.meta からいけるかも。
touch .env.local
VITE_DROPBOX_APP_KEY="xxxx"

コード例
import { Dropbox } from 'dropbox';
const dbx = new Dropbox({ clientId: '<YOUR_APP_KEY>' });
function redirectToDropboxAuth() {
const authUrl = dbx.getAuthenticationUrl(
'http://localhost:3000/callback', // Redirect URI
null, // State parameter (任意)
'code', // Response type
'offline', // Token access type (offline でリフレッシュトークンを取得)
null, // PKCE code challenge method (自動生成)
true // PKCE 使用の有効化
);
window.location.href = authUrl; // ブラウザを認証ページへリダイレクト
}
redirectToDropboxAuth();

このコード例は間違っています、DropboxAuth を使います。

getAuthenticationUrl について
/**
* Get a URL that can be used to authenticate users for the Dropbox API.
* @arg {String} redirectUri - A URL to redirect the user to after
* authenticating. This must be added to your app through the admin interface.
* @arg {String} [state] - State that will be returned in the redirect URL to help
* prevent cross site scripting attacks.
* @arg {String} [authType] - auth type, defaults to 'token', other option is 'code'
* @arg {String} [tokenAccessType] - type of token to request. From the following:
* null - creates a token with the app default (either legacy or online)
* legacy - creates one long-lived token with no expiration
* online - create one short-lived token with an expiration
* offline - create one short-lived token with an expiration with a refresh token
* @arg {Array<String>} [scope] - scopes to request for the grant
* @arg {String} [includeGrantedScopes] - whether or not to include previously granted scopes.
* From the following:
* user - include user scopes in the grant
* team - include team scopes in the grant
* Note: if this user has never linked the app, include_granted_scopes must be None
* @arg {boolean} [usePKCE] - Whether or not to use Sha256 based PKCE. PKCE should be only use on
* client apps which doesn't call your server. It is less secure than non-PKCE flow but
* can be used if you are unable to safely retrieve your app secret
* @returns {Promise<String>} - Url to send user to for Dropbox API authentication
* returned in a promise
*/
getAuthenticationUrl(redirectUri: string, state?: string, authType?: 'token' | 'code', tokenAccessType?: null | 'legacy' | 'offline' | 'online', scope?: Array<String>, includeGrantedScopes?: 'none' | 'user' | 'team', usePKCE?: boolean): Promise<String>;

ようやくリダイレクトできた
import type { Route } from "./+types/home";
import { Dropbox, DropboxAuth } from "dropbox";
export function meta({}: Route.MetaArgs) {
return [
{ title: "New React Router App" },
{ name: "description", content: "Welcome to React Router!" },
];
}
export default function Home() {
const handleRedirect = async () => {
const dropboxAuth = new DropboxAuth({
clientId: import.meta.env.VITE_DROPBOX_APP_KEY,
});
const authenticationUrl = await dropboxAuth.getAuthenticationUrl(
"http://localhost:5173/callback",
undefined,
"code",
"offline",
["files.content.read", "files.content.write"],
"none",
true
);
window.location.href = authenticationUrl.valueOf();
};
return (
<main>
<button onClick={handleRedirect}>Redirect</button>
</main>
);
}

ログインしたら callback ページへ移動した
コールバックページはないので作成する必要がある。
/callback ページを追加してください。

DropboxAuth のソースコード

先に Authorization Code Grant を試そう
PKCE でも行けそうだが、localStorage などを使うまでするのも違う気がするので、せっかくサーバーがあるから Authorization Code Grant を先に試してみよう。

Client Secret の取得
アプリページで App secret の Show ボタンを押すことで取得できた。

環境変数への追加
DROPBOX_APP_SECRET="xxxx"

React Router の action
これを使えば良いのかな?

使うべきはローダーでした。

React Router のセッション
結構使うのが面倒だが、何をやっているのかがわかりやすいので個人的には好きかも知れない。

ようやくアクセストークンを取得できた
SESSION_SECRET="openssl rand -hex 32"
import { createCookieSessionStorage } from "react-router";
type SessionData = {
accessToken: string | undefined;
refreshToken: string | undefined;
};
type SessionFlashData = {
error: string;
};
const { getSession, commitSession, destroySession } =
createCookieSessionStorage<SessionData, SessionFlashData>({
cookie: {
name: "__my_session",
domain: "localhost",
httpOnly: true,
maxAge: 24 * 60 * 60,
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET as string],
secure: true,
},
});
export { getSession, commitSession, destroySession };
import type { Route } from "./+types/home";
import { DropboxAuth } from "dropbox";
import { redirect } from "react-router";
import { commitSession, getSession } from "~/sessions.server";
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
if (!code) {
throw new Response("No code provided", { status: 400 });
}
const dropboxAuth = new DropboxAuth({
clientId: process.env.VITE_DROPBOX_APP_KEY,
clientSecret: process.env.DROPBOX_APP_SECRET,
});
const redirectUrl = "http://localhost:5173/callback";
const tokenResponse = await dropboxAuth.getAccessTokenFromCode(
redirectUrl,
code
);
const session = await getSession(request.headers.get("Cookie"));
session.set("accessToken", tokenResponse.result.access_token);
session.set("refreshToken", tokenResponse.result.refresh_token);
return redirect("/", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
import { getSession } from "~/sessions.server";
import type { Route } from "./+types/home";
import { Dropbox, DropboxAuth } from "dropbox";
import { useLoaderData } from "react-router";
export function meta({}: Route.MetaArgs) {
return [
{ title: "New React Router App" },
{ name: "description", content: "Welcome to React Router!" },
];
}
export async function loader({ request }: Route.LoaderArgs) {
const session = await getSession(request.headers.get("Cookie"));
const accessToken = session.get("accessToken");
const refreshToken = session.get("refreshToken");
return {
accessToken,
refreshToken,
};
}
export default function Home() {
const { accessToken, refreshToken } = useLoaderData<typeof loader>();
const handleRedirect = async () => {
const dropboxAuth = new DropboxAuth({
clientId: import.meta.env.VITE_DROPBOX_APP_KEY,
});
const authenticationUrl = await dropboxAuth.getAuthenticationUrl(
"http://localhost:5173/callback",
undefined,
"code",
"offline",
["files.content.read", "files.content.write"],
"none"
);
window.location.href = authenticationUrl.valueOf();
};
return (
<main>
<button onClick={handleRedirect}>Redirect</button>
<dl>
<dt>Access Token</dt>
{/* <dd>{accessToken}</dd> */}
<dd>xxxx</dd>
<dt>Refresh Token</dt>
{/* <dd>{refreshToken}</dd> */}
<dd>yyyy</dd>
</dl>
</main>
);
}
アクセストークンとリフレッシュトークンが表示された

ファイル保存のコード例
async function uploadFile() {
try {
const fileContent = new Blob(['Hello Dropbox!'], { type: 'text/plain' });
const response = await dbx.filesUpload({
path: '/hello.txt',
contents: fileContent,
});
console.log('Uploaded:', response);
} catch (error) {
console.error('Error uploading file:', error);
}
}
uploadFile();

アップロード成功!
Dropbox フォルダの アプリ/My First App 20250116/hello.txt
に追加された!
const handleUpload = async () => {
try {
const dropbox = new Dropbox({
accessToken: accessToken,
});
const fileContent = new Blob(["Hello, Dropbox!"], { type: "text/plain" });
const response = await dropbox.filesUpload({
path: "/hello.txt",
contents: fileContent,
});
console.log(response);
} catch (err) {
console.error(err);
}
};
これは嬉しい

続いて読み込み
async function downloadFile() {
try {
const response = await dbx.filesDownload({ path: '/hello.txt' });
const fileBlob = response.result.fileBlob;
const url = URL.createObjectURL(fileBlob);
console.log('Download URL:', url);
// 必要に応じて <a> タグでリンクを作成してダウンロード可能にする
} catch (error) {
console.error('Error downloading file:', error);
}
}
downloadFile();

読み込めた!
export default function Home() {
const { accessToken, refreshToken } = useLoaderData<typeof loader>();
const dropbox = useMemo(() => {
return new Dropbox({
accessToken,
refreshToken,
});
}, [accessToken, refreshToken]);
const [content, setContent] = useState("");
const handleRedirect = async () => {
const dropboxAuth = new DropboxAuth({
clientId: import.meta.env.VITE_DROPBOX_APP_KEY,
});
const authenticationUrl = await dropboxAuth.getAuthenticationUrl(
"http://localhost:5173/callback",
undefined,
"code",
"offline",
["files.content.read", "files.content.write"],
"none"
);
window.location.href = authenticationUrl.valueOf();
};
const handleUpload = async () => {
try {
const fileContent = new Blob(["Hello, Dropbox!"], { type: "text/plain" });
const response = await dropbox.filesUpload({
path: "/hello.txt",
contents: fileContent,
});
console.log(response);
} catch (err) {
console.error(err);
}
};
const handleDownload = async () => {
try {
const response = await dropbox.filesDownload({
path: "/hello.txt",
});
setContent(await response.result.fileBlob.text());
} catch (err) {
console.error(err);
}
};
return (
<main>
<button onClick={handleRedirect}>Redirect</button>
<dl>
<dt>Access Token</dt>
{/* <dd>{accessToken}</dd> */}
<dd>xxxx</dd>
<dt>Refresh Token</dt>
{/* <dd>{refreshToken}</dd> */}
<dd>yyyy</dd>
</dl>
<button onClick={handleUpload}>Upload</button>
<button onClick={handleDownload}>Download</button>
<p>{content}</p>
</main>
);
}
ファイルの内容が表示された

fileBlob が無い問題
型情報に fileBlob が無いのでエラーメッセージが表示されてしまう。
この辺りは Zod や Valibot を使った方が良いのかな?

ソースコードはこちら

おわりに
Dropbox API を使うのも React Router v7 を使うのも初めてだったので、結構手こずったが、目標通り読み書きができてよかった。
Dropbox はバージョン管理もしてくれるので、何かあっても戻せるのが良いところ。
定期的にデータをアップロードする仕組みを作ると良いかも知れない。
これで準備が整ったのでユーザーが自分自身でデータを管理できる PWA を作ってみようかな。
楽しかった!また興味が湧いたらスクラップを作成して調べてみよう。