Imgur APIを使ったmacOSアプリを作成する

# はじめに
- 下記の記事で`Imgur API`をターミナルから使用する方法を紹介しました。
    - [Imgur APIを使ったOAuth2\.0認証と画像のアップロード \- Qiita](https://qiita.com/IKEH/items/c93854c95a863941a188)
- これを`Swift`を使って、下記のようなmacOSアプリを実装したいと思います。
    - `Imgur API`を使用する部分の実装はiOSでも共通ですので、iOSで実装したいという方も是非。

<img width="512" alt="image" src="https://i.imgur.com/fnZf9u1.jpg">

# GitHub
- https://github.com/pommdau/Qiita_ImgurAPI_Demo/tree/master

# 概要
- 今回2つのプロジェクトを作りました。以下、実装1,実装2と呼びます。
    - https://github.com/pommdau/Qiita_ImgurAPI_Demo/tree/master/QItta_ImgurAPI_Demo_01
    - https://github.com/pommdau/Qiita_ImgurAPI_Demo/tree/master/QItta_ImgurAPI_Demo_02
- 前者は一つのクラスにつらつらと書きました。
    - How to実装を示すためのものです。
- 後者は前者を参考に、クラス分けをしながらそれなりの形のmacOSアプリにしています。
    - 最終的な成果物はこちらです。
- ※記事に` Client ID`・`Client Secret`の直値を載せていますが、試す際は自身のものを使用してください。
    - 載せているものは失効させているので使用できません。

# 実装1 - 基本的な実装の流れ
## Imgur APIの設定
- WEB上で下記のパラメータの通り設定します。

| 項目            | 値                                           |
|---------------|:--------------------------------------------|
| コールバックURL     | qiita-demo-2cdc3a06e7197c2://oauth-callback |
| Client ID     | 2cdc3a06e7197c2                             |
| Client Secret | fd2cd5bda2a673213a8db56d1a79eb4f74f9a0cf    |
 
## OAuth2.0認証 - ユーザ認証
### CallbackURLの受け取り設定
- ユーザ認証用のURLを作成し、認証後CallbackURLをアプリで受け取るまでまずは実装します。
- CallbackURLによってアプリを開くために、`URL Types`にスキームを登録します。

![-w1141](https://i.imgur.com/oUr8axM.jpg)

- `AppDelegate.swift`に以下を記述します。
    - [setEventHandler\(\_:andSelector:forEventClass:andEventID:\)](https://developer.apple.com/documentation/foundation/nsappleeventmanager/1416131-seteventhandler)
    - コールバックURLを受け取る設定、およびその際の動作を実装します。
    - 今回は`Notification`を使用して`ViewController`にコールバックURLを伝えるようにします。

```swift
func applicationDidFinishLaunching(_ aNotification: Notification) {
    NSAppleEventManager.shared().setEventHandler(self, andSelector:#selector(AppDelegate.handleGetURL(event:withReplyEvent:)),
                                                        forEventClass: AEEventClass(kInternetEventClass),
                                                        andEventID: AEEventID(kAEGetURL))
}

@objc func handleGetURL(event: NSAppleEventDescriptor!, withReplyEvent: NSAppleEventDescriptor!) {
    if let urlString = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue,
        let url = URL(string: urlString) {
        NotificationCenter.default.post(name: OAuthInfo.Imgur.callBackNotificationName,
                                        object: nil,
                                        userInfo:  [OAuthInfo.Imgur.UserinfoKey.callbackURL: url])
    }
}
```

- なお利便性のため、定数はStructにまとめています。

```swift
public final class OAuthInfo {
    public struct Imgur {
        private enum UserDefaultsKey: String {
            case accessToken
            case expires
            case tokenType
            case refreshToken
            case accountUserName
            case accountID
        }
        
        static let clientID     = "2cdc3a06e7197c2"
        static let clientSecret = "fd2cd5bda2a673213a8db56d1a79eb4f74f9a0cf"
        static let callBackNotificationName = Notification.Name("ReceiveCallBackURLNotification")
        struct UserinfoKey {
            static let callbackURL = "callbackurl"
        }
        
       static var accessToken: String {
            get {
                return UserDefaults.standard.string(forKey: UserDefaultsKey.accessToken.rawValue) ?? ""
            }
            
            set(accessToken) {
                UserDefaults.standard.set(accessToken, forKey: UserDefaultsKey.accessToken.rawValue)
            }
        }
// （省略）
```

- では`ViewController`側に書いていきます。
- Notificationを受け取るように`addObserver`を行います。
    - 実際の処理`handleCallbackURL(_:)`は後ほど実装します。

```swift
NotificationCenter.default.addObserver(self, selector: #selector(handleCallbackURL(_:)),
                                       name: OAuthInfo.Imgur.callBackNotificationName,
                                       object: nil)
```
   
### 認証用URLの作成
    
- 続いて認証用URLを作成します。
- 具体的なURLは下記の通りです。
    - 参考：[OAuth2.0認証](https://qiita.com/IKEH/items/c93854c95a863941a188#oauth20%E8%AA%8D%E8%A8%BC)

```
https://api.imgur.com/oauth2/authorize
?client_id=2cdc3a06e7197c2
&response_type=token
&state=hoge
```

- SwiftにはURLを扱うのに便利なクラス`URLComponents`や`URLQueryItem`が用意されているのでこちらを使用します。
    -  [URLComponents](https://developer.apple.com/documentation/foundation/urlcomponents)
- 勿論`String`でも実装可能ですが、後でクラス分けするときに使い勝手が悪いのでオススメはしません。
- クエリ・フラグメントの知識は以下を参照。
    - [ウェブ開発者なら必ず知っておくべき基礎知識（２）URI（URL）の構文](http://info-i.net/url-syntax)
- イメージとしては、`URLComponents`のオブジェクトに、クエリやフラグメント情報を渡して、最終的にURLを発行してもらう、という感じです。


```swift
let baseURL           = URL(string: "https://api.imgur.com")!
let relativePath      = "oauth2/authorize"
let authenticationURL = baseURL.appendingPathComponent(relativePath)
var components = URLComponents(url: authenticationURL , resolvingAgainstBaseURL: true)  // URL構成要素を表現するクラス。URLも楽に書き出せて便利。

let queryItems: [URLQueryItem] = [URLQueryItem(name: "client_id",     value: OAuthInfo.Imgur.clientID),
                                  URLQueryItem(name: "response_type", value: "token"),
                                  URLQueryItem(name: "state",         value: "sample")]

components?.queryItems = queryItems

guard let openingURL = components?.url else {
    return
}

// ブラウザで認証ページを開く
if !NSWorkspace.shared.open(openingURL) {
    fatalError()
}
```

- 以上で認証URLを作成しブラウザに表示することができました。

### CallbackURLの受け取り後の処理

- では残しておいた`Callback URL`取得後の処理を実装します。
- `Callback URL`が例えば以下の通りに取得されます。

```
imgur-e6d8ea34904ab93://oauth-callback?state=sample
#access_token=37b3888f2801db013d35e4bec3a8f103b43344aa
&expires_in=315360000
&token_type=bearer
&refresh_token=ba2bb6ea7df5c0a3ecca48c5412b1d06e6135a3b
&account_username=IKEH1024
&account_id=104356397
```

- フラグメントの情報取得をします。
- まず`callbackURL.fragment`を使って取り出し、一旦クエリに保存します。
- その後`URLComponents`からクエリとして取り出します。
    - [How to parse URL \# fragments with query items in Swift](https://stackoverflow.com/questions/57933247/how-to-parse-url-fragments-with-query-items-in-swift)
- また実際には安全性のため`UserDefaults`ではなく`KeyChain`に保存するべきですね。

```swift
@objc func handleCallbackURL(_ notification: Notification) {
    NotificationCenter.default.removeObserver(self)
    
    guard let callbackURL = notification.userInfo?[OAuthInfo.Imgur.UserinfoKey.callbackURL] as? URL else {
        return
    }
    
    var components = URLComponents(url: callbackURL , resolvingAgainstBaseURL: true)  // フラグメント部分は無視されるようです
    components?.query = callbackURL.fragment  // フラグメントをクエリとして保存する
    guard let queryItems = components?.queryItems else {
        return
    }
    
    var oauthInfo = OAuthInfo.Imgur()
    for queryItem in queryItems {
        oauthInfo.update(for: queryItem)
    }
}
```

- `URLQueryItem`の情報を受け取って保存するメソッドを用意しておきます。

```swift
public final class OAuthInfo {
    public struct Imgur {
// （省略）
        mutating func update(for queryItem: URLQueryItem) {

            guard let queryValue = queryItem.value else {
                return
            }
            
            switch queryItem.name {
            case "access_token":
                Self.accessToken = queryValue
            case "expires_in":
                Self.expires = Int(queryValue) ?? 0
            case "token_type":
                Self.tokenType = queryValue
            case "refresh_token":
                Self.refreshToken = queryValue
            case "account_username":
                Self.accountUserName = queryValue
            case "account_id":
                Self.accountID = queryValue
            default:
                break
            }
        }
    }
}
```

## Access Tokenの更新
- 続いてAccess Tokenの更新処理を実装します。
- まず、`URLSession`で通信を行うために、下記にチェックをします。

![-w534](https://i.imgur.com/ZHbxqUm.jpg)

- 以下の通り、Swiftで実装していきます。
    - [Access Tokenの更新](https://qiita.com/IKEH/items/c93854c95a863941a188#access-token%E3%81%AE%E6%9B%B4%E6%96%B0)
    - [POST Generate Access Token](https://apidocs.imgur.com/?version=latest#3f80c836-8f49-4fb1-95a7-a4b058265d72)
- HTTPリクエストのbodyの設定方法
    - [How to make HTTP Post request with JSON body in Swift](https://stackoverflow.com/questions/31937686/how-to-make-http-post-request-with-json-body-in-swift)
    - また上記回答のコメントにヘッダに関して言及があるので採用しています。（無いとAPIからレスポンスエラーが返ってくる）

```swift
func updateAccessToken() {
    let baseURL      = URL(string: "https://api.imgur.com")!
    let relativePath = "oauth2/token"
    let url          = baseURL.appendingPathComponent(relativePath)
    
    var urlRequest = URLRequest(url: url, timeoutInterval: Double.infinity)
    urlRequest.httpMethod = "POST"
    
    let bodyJSON = [
        "refresh_token" : OAuthInfo.Imgur.refreshToken,
        "client_id"     : OAuthInfo.Imgur.clientID,
        "client_secret" : OAuthInfo.Imgur.clientSecret,
        "grant_type"    : "refresh_token",
    ]
    
    guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyJSON) else {
        return
    }
    urlRequest.httpBody = bodyData
    
    
    urlRequest.addValue("\(bodyData.count)", forHTTPHeaderField: "Content-Length")
    urlRequest.addValue("application/json",  forHTTPHeaderField: "Content-Type")
    
    let session = URLSession.shared
    let task = session.dataTask(with: urlRequest) { data, urlResponse, error in
        if let urlResponse = urlResponse as? HTTPURLResponse {
            if urlResponse.statusCode != 200 {
                print("アクセストークンのアップデートに失敗しました。")
                return
            }
            
            let dic = try? JSONSerialization.jsonObject(with: data ?? Data(), options: []) as? [String: Any]
            if let dic = dic {
                for (key, value) in dic {
                    print("\(key):\(value)")
                }
                
                if let accessToken = dic["access_token"] as? String {
                    OAuthInfo.Imgur.accessToken = accessToken
                }
                
                if let refreshToken =  dic["refresh_token"] as? String {
                    OAuthInfo.Imgur.refreshToken = refreshToken
                }
            }
        }
    }
    
    task.resume()
}
```

- 以上で`Access Token`の更新の実装は完了です。

## 認証の画像アップロード
- `Access Tokenの更新`と同様の流れで、APIのドキュメントに沿って実装します。
    - [画像のアップロード](https://qiita.com/IKEH/items/c93854c95a863941a188#%E7%94%BB%E5%83%8F%E3%81%AE%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89)
    - [POST Image Upload ](https://apidocs.imgur.com/?version=latest#c85c9dfc-7487-4de2-9ecd-66f727cf3139)

```swift
func uploadImage() {
    
    guard let image = readImageFromClipboard() else {
        return
    }
    
    guard let imageData = image.tiffRepresentation else {
        return
    }

    let baseURL      = URL(string: "https://api.imgur.com")!
    let relativePath = "/3/image"
    let url          = baseURL.appendingPathComponent(relativePath)
    
    var urlRequest = URLRequest(url: url, timeoutInterval: Double.infinity)
    urlRequest.httpMethod = "POST"
    
    let base64 = imageData.base64EncodedString()
    let bodyJSON = [
        "image" : base64,
    ]
    
    guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyJSON) else {
        return
    }
    
    urlRequest.httpBody = bodyData
    
    urlRequest.addValue("application/json",                      forHTTPHeaderField: "Content-Type")
    urlRequest.addValue("Bearer \(OAuthInfo.Imgur.accessToken)", forHTTPHeaderField: "Authorization")
    
    let session = URLSession.shared
    let task = session.dataTask(with: urlRequest) { data, urlResponse, error in
        if let urlResponse = urlResponse as? HTTPURLResponse {
            if urlResponse.statusCode != 200 {
                if let errorString = String(data: data ?? Data(), encoding: .utf8) {
                    print(errorString)
                }
                print("画像のアップロードに失敗しました。")
                return
            }
            
            // アップロード結果の確認
            let dic = try? JSONSerialization.jsonObject(with: data ?? Data(), options: []) as? [String: Any]
            if let dic = dic {
                for (key, value) in dic {
                    print("\(key):\(value)")
                }
            }
        }
    }
    
    task.resume()
}
```

- アップロードに成功すると以下のようなレスポンスが返ってきます。

```JSON
data:{
    "account_id" = 104356397;
    （中略）
    datetime = 1596414978;
    deletehash = 60zvjsIbzBl8aHK;
    link = "https://i.imgur.com/yjU8yCh.png";
    type = "image/png";
}
status:200
success:1
```

- 以上で基本的な動作を`Swift`で実装することができました。

# 実装2 - クラス分けとUnitTestsの実装

- 残りの作業として下記を行います。
    - 重複している冗長な部分が多いので、通信部分やデータモデルへのクラス分けをします
    - UnitTests・スタブの実装
    - UI部分の実装
- [増補改訂第3版 Swift実践入門](https://www.amazon.co.jp/dp/4297112132/)
    - 「第18章　実践的なSwiftアプリケーション ── Web APIクライアントを作ろう」を主に参考にして実装しました。
    - (複雑で難しい所ですね…。何日もかけてトライアンドエラーで実装しました。)
- 下記の通りクラス分けしています。
    - [詳しくはGitHubを参照くださいませ。](https://github.com/pommdau/Qiita_ImgurAPI_Demo/tree/master/QItta_ImgurAPI_Demo_02)
    - 実装の流れはほぼ本の通りです。

<img width="512" alt="image" src="https://i.imgur.com/BDeYcdM.jpg">

# おわりに
- 以上SwiftによるOAuth2.0認証やAPI利用の実装方法でした。
- 「Swift実践入門」を参考に、Imgur APIを使って構造的にアプリの作成を行うことができました。