Dropbox SDKでファイル情報の取得とテストを実装する

## 最初に
個人で使えるストレージはいくつかあるかと思いますが、今回は`Dropbox`を使ってみました。
ただAPIやSDKを利用しようとすると中々情報が少なかったです。

そこで個人で試行錯誤した内容などをこちらの記事として公開することにしました。
間違ってる箇所があれば有識者の指摘をお待ちしています。

## Dropbox 選定理由
1. 無料で利用可能、容量はそこまで必要ではない
2. 複数ユーザーがWeb上でファイルの共有が可能
3. ユーザー追加の敷居が低い

共有ドライブとして使われる候補に`GoogleDrive`があるのですが、他のユーザーがGoogleの規約的にアウトなファイルを入れると、ドライブの持ち主がBANされるとかされないとか。
個人のアカウントは今や各所で利用しているので、使えなくなるリスクは許容できませんでした。

あとはGCPのCloudStorageやAWSのS3あたりも無料枠を利用すれば良いのですが、Cloud系のユーザー権限管理をしたくなかったので今回は利用しません。

## API利用開始
ここについては調べれば情報は出てくるので、ある程度省略します。
Dropbox V2 の認証方法を簡易的にまとめています。

<details><summary>詳細</summary><div>

### [Dropbox Developers](https://www.dropbox.com/developers/)でアプリを作成

> App folder– Access to a single folder created specifically for your app.

作成時にアプリ用のフォルダを作るか求められるので、既存のフォルダを利用する必要がなければこれを選びましょう。
これを選ばない場合自身のDropboxの全てのフォルダにアクセスできるアプリになるので注意が必要です。

### Settingsで以下の項目を取得
APIの設定で利用します
- App key
- App secret

### Permissions 設定をする
ファイルを取得したりWeb上でアクセスできるリンクを生成するだけなら、これで良いでしょう。
アップロード機能を付けたい場合は`write`の権限などを追加してください。

- account_info.read
- files.metadata.read
- files.content.read
- sharing.write
- file_requests.read

### アクセスコードを取得する
{app_id}を先程の`App key`の値に置換してアクセス

> https://www.dropbox.com/oauth2/authorize?client_id={app_id}&response_type=code&token_access_type=offline

注意点としてAPIの権限変更をした場合に再度実行しないと反映されませんでした。

### Refersh Token を取得する
ここからWeb画面でできなくなります。
POSTできるなら何でもいいのですが、せっかくなのでaxiosで取得できるコードを残しておきます。
先程までに取得した`App key`, `App secret`, `アクセスコード`を変数として渡して実行してください。
表示されたのがRefresh Tokenです。

```typescript
const options = {
  method: 'POST',
  url: 'https://api.dropbox.com/oauth2/token',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  data: {
    client_id: process.env.DROPBOX_ID,
    client_secret: process.env.DROPBOX_SECRET,
    code: process.env.DROPBOX_ACCESS_CODE,
    grant_type: 'authorization_code',
  },
};

axios
  .request(options)
  .then((response: any) => {
    console.log(response.data);
  })
  .catch((error: any) => {
    console.error(error);
  });
```

</div></details>

## 環境設定

### 環境変数として設定する
`App key`, `App secret`, `Refersh Token`をenvファイルなどに設定します。

```
DROPBOX_ID=''
DROPBOX_SECRET=''
DROPBOX_REFRESH_TOKEN=''
```

### ライブラリをインストールする
```
npm install dropbox
```

### 初期化方法
ここ以降`dbx`とあればDropboxのクライアントを初期化したものとしてください。
```typescript
import { Dropbox } from 'dropbox';

const dbx = new Dropbox({
  clientId: process.env.DROPBOX_ID,
  clientSecret: process.env.DROPBOX_SECRET,
  refreshToken: process.env.DROPBOX_REFRESH_TOKEN,
});
```

## ファイル情報を取得する

### ファイル名とパスを指定してマッチしたデータを取得する
注意点として、『xxx1』などの末尾数字で検索をすると『xxx2』のファイルも取得できてしまう。類似パターン『xxx01』の場合は『xxx02』は取得できない。

検証はできていませんが、拡張子も『json』『jsonl』だったり『js』『jsx』などの類似パターンもあるため、拡張子までを含めた完全一致検索にも穴はできる可能性があります。
そのため自身でフィルタリングを実装する必要も出てきます。
```typescript
const fileName = 'yoshi';
const pathName = '/test'

const response = await dbx.filesSearchV2({
  query: fileName,
  options: {
    path: `${pathName}`,
  },
});
```

### メタデータを取り出す
かなりデータ構造の階層が深いのと、Dropboxの型ガードを途中で挟む必要があります。
```typescript
const matches = response.result.matches;
const metadata = matches
  .map((match) => match.metadata)
  .filter((metadata): metadata is files.MetadataV2Metadata =>
    isMetadataV2Metadata(metadata)
  );

// 別関数で定義
const isMetadataV2Metadata = (
  value: unknown
): value is files.MetadataV2Metadata => {
  return value !== null && typeof value === 'object' && 'metadata' in value;
};
```

### 任意の必要なデータに加工/フィルタリングする
まずはファイル名で完全一致させて、今回必要なパス名を取得します。

参考 `path_display: '/test/yoshi.jpg'`
```typescript
const pathDisplays = metaData
  // metadataには類似した検索結果が含まれるため、厳密に名前を取り出す必要がある
  // 取得したファイル名には拡張子が含まれるため、split()で取り出して比較する
  .filter((metadata) => metadata.metadata.name.split('.')[0] === fileName)
  // path_display は型にundefinedを含むため除外する必要がある
  .map((data) => data.metadata.path_display)
  .filter(
    (pathDisplay): pathDisplay is Exclude<typeof pathDisplay, undefined> =>
      pathDisplay !== undefined
  );
```

## ファイルの共有リンクを取得する
先程取得したパス名からではWeb上のファイルにURLでアクセスすることができません。
そのため共有リンクを取得/作成を行います。

### 共有リンクを取得
返り値がない、もしくはエラーが発生した場合に共有リンクを作成します。
※ try, catch は適宜利用してください

```typescript
const response = await dbx.sharingListSharedLinks({
  path: pathDisplay,
});
const [link] = response.result.links;
return link.url;
```

### 共有リンクを作成
```typescript
const response = await dbx.sharingCreateSharedLinkWithSettings({
  path: pathDisplay,
});
return response.result.url;
```

### 共有リンクからWebのダウンロードURLに変換する
取得or作成した共有リンクはあくまでもDropboxのサービス上で閲覧可能な状態になります。
GoogleDrive的に言い換えるならば、共有リンクをもらってアクセスしただけの状態です。

そのためダウンロード用のURLに置換する必要があります。
ここまでしてやっとWeb上でアクセス可能な画像ファイルとして扱うことが可能になりました。
```typescript
const toDownloadUrl = (url: string): string => {
  return url.replace('www.dropbox.com', 'dl.dropboxusercontent.com');
};
```

## テストコードを実装する
結論からいうと自動モックは使えないためマニュアルモックが必要でした。

早速いつものようにprototypeをspyOnして設定して実行するとエラーに…
```typescript
import { Dropbox } from 'dropbox';

jest.spyOn(Dropbox.prototype, 'filesSearchV2')
  .mockRejectedValueOnce(new Error('テスト用エラー'));
```
> Cannot spy on the filesSearchV2 property because it is not a function; undefined given instead. If you are trying to mock a property, use `jest.replaceProperty(object, 'filesSearchV2', value)` instead.

試しにライブラリ自体のソース検索をすると…
node_modules/dropbox/lib/routes.js
```javascript
var routes = {};
// 中略
routes.filesSearchV2 = function (arg) {
  return this.request('files/search_v2', arg, 'user', 'api', 'rpc', 'files.metadata.read');
};
// 中略
export { routes };
```

node_modules/dropbox/src/dropbox.js
```javascript
import { routes } from '../lib/routes.js';

export default class Dropbox {
  constructor(options) {
    // 中略
    Object.assign(this, routes);
  }
  // 中略
}

```

初期化するDropboxクラスはあるのですが、肝心のメソッドが後付されてるようです。
これではprototypeアクセスでのモック設定ができません。

テストファイルで初期化して設定することならできますが、これだとクラスのテストにしかならず、今回のようなメソッドの中で初期化している場合のテストには使えません。
```typescript
const dbx = new Dropbox();
jest.spyOn(dbx, 'filesSearchV2');
```

そこで`jest`のドキュメントからマニュアルモックの利用を試みました。
※ 詳細な手順や注意点はドキュメントを参照してください。

https://jestjs.io/ja/docs/manual-mocks

マニュアルモックとして想定する標準のテストを実装して返り値に設定します。
テスト実行中に`dropbox`のライブラリが読み込まれると、このテスト用のクラスが自動で設定されることになります。

\_\_mocks\_\_/dropbox.ts
```typescript
class Dropbox {
  async filesSearchV2() {
    const metadata = {
      metadata: {
        name: 'test_filename.jpg',
        path_display: '/123456789001010101/test_filename.jpg',
      },
    };

    return {
      result: {
        matches: [{ metadata }],
      },
    };
  }

  async sharingListSharedLinks() {
    const url = 'https://www.dropbox.com/s/testdir/test_filename.jpg?dl=0';

    return {
      result: {
        links: [{ url }],
      },
    };
  }

  async sharingCreateSharedLinkWithSettings() {
    const url = 'https://www.dropbox.com/s/testdir/test_filename.jpg?dl=0';

    return {
      result: { url },
    };
  }
}

export { Dropbox };
```

マニュアルモックを設定すると常にこのテスト用の設定になるため、実際のライブラリをテストする際には`unmock`を使って解除する必要があります。
```typescript
jest.unmock('dropbox');
```

## まとめ

使用事例数が少ないのでDropboxSDKのドキュメントをかなり確認しました。
…が全部英語なので、ブラウザの翻訳機能などを活用して読み解く必要があります。
興味のある人は見てみるのも良いでしょう。大半の人は秒で閉じるんじゃないかな…

https://dropbox.github.io/dropbox-sdk-js/Dropbox.html

レスポンスのデータ構造が深かったり、テスト設定をしにくい構成なので、使いにくさを感じます。
特別な要件がなければ普通にS3やGCSをオススメしますが、それでも使いたいという人の役に立てば幸いです。