Closed28

Dropbox の API を試す

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

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

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コード例

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();
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

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>;
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ようやくリダイレクトできた

app/routes/home.tsx
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>
  );
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ログインしたら callback ページへ移動した

コールバックページはないので作成する必要がある。

プロンプト
/callback ページを追加してください。
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

先に Authorization Code Grant を試そう

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

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ようやくアクセストークンを取得できた

.env.local(追記)
SESSION_SECRET="openssl rand -hex 32"
app/sessions.server.ts
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 };
app/routes/callback.tsx
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),
    },
  });
}
app/routes/home.tsx
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>
  );
}


アクセストークンとリフレッシュトークンが表示された

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ファイル保存のコード例

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();
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

アップロード成功!

Dropbox フォルダの アプリ/My First App 20250116/hello.txt に追加された!

app/routes/home.tsx(一部)
  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);
    }
  };


これは嬉しい

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

続いて読み込み

コード例
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();
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

読み込めた!

app/routes/home.tsx
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>
  );
}


ファイルの内容が表示された

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

fileBlob が無い問題

型情報に fileBlob が無いのでエラーメッセージが表示されてしまう。

この辺りは Zod や Valibot を使った方が良いのかな?

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

おわりに

Dropbox API を使うのも React Router v7 を使うのも初めてだったので、結構手こずったが、目標通り読み書きができてよかった。

Dropbox はバージョン管理もしてくれるので、何かあっても戻せるのが良いところ。

定期的にデータをアップロードする仕組みを作ると良いかも知れない。

これで準備が整ったのでユーザーが自分自身でデータを管理できる PWA を作ってみようかな。

楽しかった!また興味が湧いたらスクラップを作成して調べてみよう。

このスクラップは2025/01/17にクローズされました