Dropbox v2 API対応まとめ(Objective-C Dropbox Official SDK版)

Dropbox v1 APIの廃止にともない、リリース中のiOSアプリで使っていたSDKを、v2用の新しいものに更新しました。v2用SDKの導入・利用方法に加えて、v1用SDKからどのように使い方が変わったか、機能別にまとめました。

なお当文書ではAPI/SDKの呼び方を以下で統一します:

呼称 | 意味
--- | ---
`v1 API` | Dropboxの古いWeb API。2017/06/28で廃止された。
`v2 API` | Dropboxの新しいWeb API。
`v1 SDK` | Dropbox社が社内開発していた、古いSDKのこと。v1 APIに接続する。`Dropbox.framework`のこと。
`v2 SDK` | Dropbox社を中心にOSSで開発された新しいSDKのこと。v2 APIに接続する。`ObjectiveDropboxOfficial.framework`のこと。


概要
------------------------------------------------------------
### API v1の廃止について
- 2016/6/28にv1 APIが非推奨となった
    - 以降はv2 APIを利用することが推奨される
    - v2ではエンドポイントを含め、API/ラッパーライブラリの仕様が変わる
    - Business APIがCore APIに統合され、ひとつになる

- 2016/9/28以降、未対応のアプリのリンク時にエンドユーザに警告表示
- 2016/3/28、未対応のアプリを利用するエンドユーザ全員に警告メール送信
- 2017/6/28(1年後)にv1 APIは無効になる

### APIの更新にともなうSDKの変更について
- APIのバージョンアップに伴い、各言語のSDKも刷新された
- Objective-C用のSDKはオープンソース化され、Dropbox Official SDKになった
    - I/Fとなるクラス構成/メソッドも変更になっている
    - SwiftとObjective-Cは別プロジェクトとして開発されているが、おおむねI/Fが統一されている


新SDKの導入と設定
------------------------------------------------------------

### SDKのインストール
- CocoaPodsでパッケージ管理
- Carthageでパッケージ管理
- Gitサブモジュールとして追加

のいずれかの方法での導入が推奨されている。
今回はCocoaPodsで導入した。

```bash
# 未導入の場合はpodをインストール
$ brew install cocoapods

# 加えてpodを初期化
$ cd path/to/proj
$ pod init
```

```diff:./Podfile
+ use_frameworks!
+ pod 'ObjectiveDropboxOfficial'
```

```bash
$ pod install
```

.xcworkspaceファイルが直下に作成されるので、以降はこのファイルでXcodeを開く。
ワークスペース内に対象プロジェクトとPodプロジェクトが含まれる。
既存プロジェクトをビルドして成功することを確認する。

### v2 SDKの設定
まずプロジェクトのplistファイルの設定を変更

```xml:project.plist
<key>LSApplicationQueriesSchemes</key>
<array>
    <string>dbapi-8-emm</string>
    <string>dbapi-2</string>
</array>

...

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>db-APP_KEY</string>
        </array>
        <key>CFBundleURLName</key>
        <string></string>
    </dict>
</array>
```

APP_KEYの部分には[Dropboxの開発者コンソール](https://www.dropbox.com/developers/apps)で登録した自分のアプリのキーが入る。
(XMLを直接編集せずに、XcodeのUIから入力してもいい)


機能別の変更点
------------------------------------------------------------

### フレームワークを読み込む

フレームワークの名称が変わった。

```objc:v1
#import <DropboxSDK/DropboxSDK.h>
```

```objc:v2
#import <ObjectiveDropboxOfficial/ObjectiveDropboxOfficial.h>
```

### 初期化する

Dropboxセッションを初期化する。
通信用クラスの入り口が`DBSession`から`DBClientsManager`に変わった。

```objc:v1
NSString *appKey = @"...";
NSString *appSecret = @"...";
DBSession *session = [[DBSession alloc] initWithAppKey:appKey appSecret:appSecret root:kDBRootDropbox];
[DBSession setSharedSession:session];
```

```objc:v2
NSString *appKey = @"...";
[DBClientsManager setupWithAppKey:appKey];
```

### リンク状態を確認する

個人用/法人用でそれぞれ別のリンク状態を持つようになった。

```objc:v1
DBSession.sharedSession.isLinked
```

```objc:v2
DBClientsManager.authorizedClient || DBClientsManager.authorizedTeamClient
```

### リンクする

実装方法の大枠は同じ。
まず、タイミングでリンクを要求する。

```objc:v1
[DBSession.sharedSession linkFromController:controller];
```

```objc:v2
[DBClientsManager authorizeFromController:UIApplication.sharedApplication
                               controller:controller
                                  openURL:^(NSURL *url){
                                          [UIApplication.sharedApplication openURL:url
                                                                           options:@{}
                                                                 completionHandler:nil];
                                  }
];
```

Dropboxアプリあるいはブラウザが開き、ユーザが認証の可否を応答することで自分のアプリに帰ってくる。

```objc:v1(UIApplicationDelegate)
- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
  sourceApplication:(NSString *)source
         annotation:(id)annotation
{
    if([DBSession.sharedSession handleOpenURL:url]){
        if(DBSession.sharedSession.isLinked){
            // 失敗
        }else{
            // 成功
        }
        return YES;
    }
    return NO;
}
```

```objc:v2(UIApplicationDelegate)
- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
  sourceApplication:(NSString *)source
         annotation:(id)annotation
{
    DBOAuthResult *authResult = [DBClientsManager handleRedirectURL:url];
    if(authResult != nil){
        if(authResult.isSuccess){
            // 成功
        }else if(authResult.isCancel){
            // キャンセル
        }else if(authResult.isError){
            // 失敗
        }
        return YES;
    }
    
    return NO;
}
```

### リンクを解除する

```objc:v1
[DBSession.sharedSession unlinkAll];
```

```objc:v2
[DBClientsManager unlinkAndResetClients];
```

### アカウント情報を取得する

デリゲーションを使って非同期実行していたが、ブロックによるコールバックに変わった。

```objc:v1
// DBRestClientDelegateを実装したクラス内の処理として
- (void)loadAccount{
    DBRestClient *client = [[DBRestClient alloc] initWithSession:DBSession.sharedSession];
    client.delegate = self;
    [self.client loadAccountInfo];
}

// デリゲートでコールバックを受ける
- (void)restClient:(DBRestClient *)client loadedAccountInfo:(DBAccountInfo *)info{

    // メールアドレスなどユーザに関する情報を得られる
    info.email;
    info.quota;
}

- (void)restClient:(DBRestClient *)client loadAccountInfoFailedWithError:(NSError *)error{
    // ...
}
```

```objc:v2
// クライアントを取得
DBUserClient *userClient = DBClientsManager.authorizedClient;

// アカウント情報取得のための"タスク"を作成
DBRpcTask *accountTask = [userClient.usersRoutes getCurrentAccount];

// レスポンス時のコールバックを設定
[accountTask setResponseBlock:^(id result, id routeError, DBRequestError *netError){
    if(routeError || netError){
        // 失敗
        return;
    }
    
    // 成功
    // v1と同様にアカウント情報を得られる
    DBUSERSFullAccount *info = (DBUSERSFullAccount *)result;
    info.email;
}];

// タスク開始
[accountTask start];
```

### 容量情報を取得する

v1 SDKでは`DBAccountInfo`に含まれていた容量情報(quota)が、v2のアカウント情報(`DBUSERSAccount`)には含まれなくなった。
容量情報は別のタスクによって取得する。


```objc:v2
DBUserClient *userClient = DBClientsManager.authorizedClient;

// 容量取得のためのタスクを取得する
DBRpcTask *usageTask = [userClient.usersRoutes getSpaceUsage];

[usageTask setResponseBlock:^(id result, id routeError, DBRequestError *netError){
    if(routeError || netError){
        // 失敗
        return;
    }
    
    // 成功
    DBUSERSSpaceUsage *usage = (DBUSERSSpaceUsage *)result;
    usage.allocation.individual.allocated.longLongValue;    // 全容量(Bytes)
    usage.used.longLongValue;                               // 利用容量(Bytes)
}];

[usageTask start];
```

### ファイル一覧を取得する

アカウントと同じく、フォルダ内のファイル一覧の取得もタスクによって行う。

- フォルダの__メタデータ__を取得する点は同じ
    - フォルダのメタデータの中に、子供のファイル/フォルダのメタデータが含まれる点も同じ

- ロードするパスはDropbox上の絶対パスで指定する
    - ルートディレクトリを読み込みたい場合は、`@"/"`ではなく`@""`を指定する
    - 取得したメタデータからさらに情報を取得したい場合、そのファイルのパスは`metadata.pathLower`で取得する

- v2の場合、子の数が多すぎる場合は、`hasMore`フラグが設定される
    - 子のメタデータをすべて取得したい場合は再帰的に取得を繰り返す必要がある


```objc:v1
// DBRestClientDelegateを実装したクラス内の処理として
- (void)loadFolderList:(NSString *)path{
    DBRestClient *client = [[DBRestClient alloc] initWithSession:DBSession.sharedSession];
    client.delegate = self;
    [self.client loadMetadata:self.path];
}

- (void)restClient:(DBRestClient *)client
    loadedMetadata:(DBMetadata *)metadata
{
    // 成功
    for(DBMetadata *childMeta in metadata.contents){
        // 子のメタデータを一覧できる
    }
}

- (void)restClient:(DBRestClient *)client
loadMetadataFailedWithError:(NSError *)error
{
    // 失敗
}
```

```objc:v2
- (void)loadDirMetadataWithClient:(DBUserClient *)client cursor:(NSString *)cursor
    DBRpcTask *task = [client.filesRoutes listFolder:self.path];
    if(cursor)
        task = [client.filesRoutes listFolderContinue:cursor];
    
    [task setResponseBlock:^(id result, id routeError, DBRequestError *netError){
        if(routeError || netError){
            // 失敗
            return;
        }
        
        DBFILESListFolderResult *folderResult = result;
        for(DBFILESMetadata *childMeta in folderResult.entries){
            // 子のメタデータを一覧できる
        }
        
        if(folderResult.hasMore.boolValue){
            // まだ子がいる場合は再帰的に読み込む
            [self loadDirMetadataWithClient:client cursor:folderResult.cursor];
        }else{
            // 読み込み完了
        }
    }];
    [task start];
}
```

### メタデータから情報を得る

取得したメタデータから情報を得られる。
v2では基底クラス`DBFILESMetadata`を継承した`DBFILESFileMetadata`と`DBFILESFolderMetadata`が存在し、対象がファイルかフォルダかによって異なる。
またサムネイルの存在可否など、メタデータからは取得できなくなったプロパティが存在する。

```objc:v1
DBMetadata *meta = ...;
meta.rev;
meta.isDirectory;
meta.filename;
meta.path;
meta.thumbnailExists;
meta.lastModifiedDate;
meta.totalBytes;
// ...
```

```objc:v2
DBFILESMetadata *meta = ...;
meta.name;
meta.pathLower;
meta.pathDisplay;

// isDirectoryプロパティはなくなったので、代わりにメタデータ自身のクラスによって判定
if([meta isKindOfClass:DBFILESFolderMetadata.class]){
    // フォルダ
    DBFILESFolderMetadata *folderMeta = (DBFILESFolderMetadata *)meta;
    // ...

}else if([meta isKindOfClass:DBFILESFileMetadata.class]){
    // ファイル
    DBFILESFileMetadata *fileMeta = (DBFILESFileMetadata *)meta;

    meta.rev;
    meta.size.longlongValue;
    meta.serverModified;
    // ...
}

// サムネイルの在否は拡張子で判定する
NSString *ext = meta.pathLower.pathExtension.lowercaseString;
if([ext isEqualToString:@"jpg"] || [ext isEqualToString:@"png"]){
    // サムネイルあり
}
```

### サムネイル / ファイルを取得する

他と同じくタスクで取得する。
サムネイルの場合はファイルサイズと形式を規定値から選択できる。

注意点として、

- 保存先のパスのNSURLは`URLWithString:`ではなく`fileURLWithPath:`で初期化すること
    - 後者の場合は`file://`が接頭辞として付加される。これがないとiOS9+のATS制約に引っかかる

- コールバックは`setResponseBlock:`ではなく`setResponseBlock:queue:`で設定する必要がある
    - 前者の場合エラーとなった

- サムネイルの有無は単純にファイルパスの拡張子で判断する
    - v1 SDKの`DBMetadata`にあった`thumbnailExists`のようなプロパティはなくなった様子
    - `DBFILESUserAuthRoutes`のライブラリヘッダによると、対応しているのは以下:

> Get a thumbnail for an image. This method currently supports files with the following file extensions: jpg, jpeg,
> png, tiff, tif, gif and bmp. Photos that are larger than 20MB in size won't be converted to a thumbnail.
    

```objc:v1
- (void)loadThumbnail:(DBMetadata *)meta to:(NSString *)pathToSave{
    DBRestClient *client = [[DBRestClient alloc] initWithSession:DBSession.sharedSession];
    client.delegate = self;
    [self.client loadThumbnail:meta.path ofSize:@"xl" intoPath:pathToSave];
}

- (void)restClient:(DBRestClient*)client
   loadedThumbnail:(NSString*)destPath
          metadata:(DBMetadata*)metadata
{
    // 成功
}

- (void)restClient:(DBRestClient*)client
loadThumbnailFailedWithError:(NSError*)error
{
    // 失敗
}
```

```objc:v2
DBUserClient *userClient = DBClientsManager.authorizedClient;
NSString *webPath = meta.pathLower;
NSURL *destURL = [NSURL fileURLWithPath:pathToSave];
DBFILESThumbnailFormat *format = [[DBFILESThumbnailFormat alloc] initWithJpeg];
DBFILESThumbnailSize *size = [[DBFILESThumbnailSize alloc] initWithW128h128];

DBDownloadUrlTask *task = [userClient.filesRoutes getThumbnailUrl:webPath
                                                           format:format
                                                             size:size
                                                        overwrite:YES
                                                      destination:destURL];

// ファイルの場合
//DBDownloadUrlTask *task = [userClient.filesRoutes downloadUrl:self.path
//                                                    overwrite:YES
//                                                  destination:url];


[task setResponseBlock:^(id  _Nullable result, id  _Nullable routeError, DBRequestError * _Nullable networkError, NSURL * _Nonnull destination){
    if(routeError || networkError){
        // 失敗
        return;
    }

    // 成功

} queue:[NSOperationQueue mainQueue]];
[task start];
```

### 通信を中断する

v2では、通信のキャンセルはタスクに対して行う。
キャンセルの可能性があるタスクは利用する側が任意に登録しておく必要がある
(と思われる。SDKによって通信中のタスクの一覧を管理取得できるのかはわからなかった)。


```objc:v1
DBRestClient *client = [[DBRestClient alloc] initWithSession:DBSession.sharedSession];
[client cancelAllRequests];
```

```objc:v1
// NS(Mutable)Array *tasksに通信中のタスクを保持しているとする

- (void)cancelAllTasks:(NSArray *)tasks{
    for(DBTask *task in tasks){
        [task cancel];
    }
}
```

### その他の課題に対応する

[公式のリファレンス](https://dropbox.github.io/dropbox-sdk-obj-c/api-docs/latest/)が機能を網羅しているが、検索性が悪く 調べるのに難儀した。
タスクによってリクエストを出す / ブロックでコールバックを得るというAPIの使用感は一貫しているので、それらしい単語をライブラリヘッダに対して直接検索していくのが早いかもしれない。


参考
------------------------------------------------------------
- [Dropbox Develper Blog - API v1 is now deprecated](https://blogs.dropbox.com/developers/2016/06/api-v1-deprecated/)
- [Dropbox - API v1 → v2 Migration guide](https://www.dropbox.com/developers/reference/migration-guide)
- [Dropbox - Dropbox for Objective-C Developers](https://www.dropbox.com/developers/documentation/objective-c)
- [Github - dropbox/dropbox-sdk-obj-c](https://github.com/dropbox/dropbox-sdk-obj-c)
- [ObjectiveDropboxOfficial Reference](https://dropbox.github.io/dropbox-sdk-obj-c/api-docs/latest/)