[Swift] iOSでSpotifyAPIを叩くときのポイント・備忘録(OAuth2.0)

## はじめに
iOS開発でAPI通信の学習をしています。
せっかくなので皆がよく知るAPIを使いたいと思い、SpotifyAPIを利用することにしました。

OAuth2.0認証を利用し、自身のSpotifyデータと連携する必要があるのですが、
その過程で苦戦した所や、他でも活用できそうな所を備忘録的に残しておこうと思います。

また、これからAPIの学習をする方の参考になれば幸いです。

### 環境

私が実行した時の環境

- Xcode 12.5.1
- iOS Deployment Target 13.0
- Alamofire5.4.3 (Swift Package Manager)

アーキテクチャはMVCを想定
※エラーハンドリング等の記述はだいぶ省略していたりするので、あまり参考にしないほうがいいです

### 用意しておくもの

- Spotifyのユーザー登録

## SpotifyDevelopersに登録

  - [SpotifyDevelopers](https://developer.spotify.com/)

メニューからDashBoardを選択し登録画面に進む。
登録すると`clientID`と`clientSecret`が取得できるので控えておく。

## リダイレクトURIの設定

### アプリ側の設定

アプリのTarget->Infoから設定
<img src="https://i.gyazo.com/3c0300ea7085592ccd8a1f00f411b79c.png" width="400">

info plistからも見れる

[![Image from Gyazo](https://i.gyazo.com/b965eed6d61649ac504675d0babec7e4.png)](https://gyazo.com/b965eed6d61649ac504675d0babec7e4)

(私はだいたいアプリのBundleIdentifireをベースにしていますが、Spotifyのガイドに制約が記載されているのでそれに準拠するようにします)

<img src="https://i.gyazo.com/296028987977ab68ac3ecfd240d8cb04.png" width="700">
※SpotifyDeveloper App Settingsより抜粋

### ホスト側の設定(SpotifyDevelopers)

管理画面から`EDIT SETTINGS`に行き、`Redirect URIs`を入力する

[![Image from Gyazo](https://i.gyazo.com/3551a6aeeabbfceb41dc6a98f295146a.png)](https://gyazo.com/3551a6aeeabbfceb41dc6a98f295146a)

※BundleIDSも入力例に沿った形で入力する

ここで、アプリ側で設定しておいたものを入力。
アプリで`mynamesampleapp`としているので、Redirect URIsに`mynamesampleapp://callback`と入力し登録。

※callbackの部分は無くても良いが、callbackとかredirectにするのが一般的な感じ。

## Errorの用意

API通信周りのエラー
レスポンスをResult型で受け取るときには、Error型から返すことになるためちゃんと用意しておく。
対応するエラーの種類内容については実装内容によると思うのでこの記事では一例に留めます。

```swift
enum APIError: Error, LocalizedError {
    
    case authorizationCodeError, urlError, taskError
    
    var errorDescription: String? {
        switch self {
        case .authorizationCodeError:
            return "認証エラーが発生しています"
        case .urlError:
            return "URLが取得出来ませんでした"
        case .taskError:
            return "データの取得が出来ませんでした"
        }
    }
}
```

## APIクラスの作成(パラメータの設定)

ホストとの通信に必要なパラメータを用意する
URLやパラメータのハードコーディングを減らすため、あらかじめ定数化しておく。

```API.swift
final class API {
    // シングルトン
    static let shared = API()
    private init() {}
    
    let clientID = "clientID" // 自分のclientIDを入れる
    let clientSecret = "clientSecret" // 自分のclientSecretを入れる
    let baseOAuthURL = "https://accounts.spotify.com/authorize"
    let baseAPIURL = "https://api.spotify.com/v1"
    let getTokenEndPoint = "https://accounts.spotify.com/api/token"
    
    let scopes = "user-read-private%20playlist-read-private%20playlist-read-collaborative"
    let redirectURI = "設定済みのリダイレクトURI" // 設定済みのredirectURIを入れる
    let stateStr = "stateを入力"
    let grantType = "authorization_code"
    
    enum  URLParameterName: String {
        case clientID = "client_id"
        case clientSecret = "client_secret"
        case redirectURI = "redirect_uri"
        case grantType = "grant_type"
    }
    
    var oAuthURL: URL {
        return URL(string: "\(baseOAuthURL)?response_type=code&client_id=\(clientID)&scope=\(scopes)&redirect_uri=\(redirectURI)&state=\(stateStr)&show_dialog=TRUE")!
    }
    
    // ~~~ 以下に通信メソッドを記述する ~~~  
}
```

scopesは必要なものを入れる。
spotifyAPIでは`%20`を入れて繋ぐ。

## 認証リクエスト(アクセストークンの取得)

認証用のボタンがあったと仮定して、ボタンタップで認証リクエストを送る

```AuthViewController.swift
@IBOutlet weak var authButton: UIButton! {
    didSet {
        loginButton.addTarget(self, action: #selector(tapAuthButton), for: .touchUpInside)
    }
}

@objc func tapAuthButton() {
// APIクラスから認証用のURLを呼び出して開く
    UIApplication.shared.open(API.shared.oAuthURL, options: [:])
}
```

Spotifyにログインし、URLに必要なパラメータがしっかりと入っていれば、認証コードが付いたURLをレスポンスとして受け取れる。
外部サイトからアプリを再度立ち上げることになるので、AppDelegateに立ち上げ後の処理を記述する。

```AppDelegate.swift
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
    authViewController.openURL(url: url)
}
```
認証を担当するVCでレスポンスURLの処理をする
URLに含まれる`code`がアクセストークンを取得するために必要な認証コードとなる(response_typeをcodeに指定した場合)

```AuthViewController.swift
func openURL(url: URL) {
    guard let queryItems = URLComponents(string: url.absoluteString)?.queryItems,
          let code = queryItems.first(where: {$0.name == "code"})?.value,
          let getState = queryItems.first(where: {$0.name == "state"})?.value,
          getState == API.shared.stateStr
    else {
        return
    }

// `code`を引数として渡して、トークンリクエストをする
    API.shared.postAuthorizationCode(code: code) { accessToken, error in 
        if let error = error {
            print(error.localizedDescription)
            return
        }
        guard let _accessToken = accessToken else {
            return
        }
    // キーを保持
    UserDefaults.standard.spotifyAccessToken = _accessToken.token
    }
}
```

POSTリクエストの中身。Alamofireを使用している。

```API.swift
func postAuthorizationCode(code: String, completion: ((SpotifyAccessTokenModel?, Error?) -> Void)? = nil) {
    
    guard let url = URL(string: getTokenEndPoint) else {
        completion?(nil, APIError.postAuthorizationCode)
        return
    }
    
    let basicAuthCode = Contstants.clientID+":"+Contstants.clientSecret
    let data = basicAuthCode.data(using: .utf8)
    guard let base64AuthCode = data?.base64EncodedString() else {
        completion?(nil, APIError.postAuthorizationCode)
        return
    }
    
    let parameters = [
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": redirectURI
    ]
    
    let getTokenHeaders: HTTPHeaders = [
        "Authorization": "Basic \(base64AuthCode)"
    ]
    
    AF.request(url, method: .post, parameters: parameters, headers: getTokenHeaders).responseJSON { (response) in
    
        do {
            guard let _data = response.data else {
                completion?(nil, APIError.taskError)
                return
            }
            let accessToken = try JSONDecoder().decode(SpotifyAccessTokenModel.self, from: _data)
            completion?(accessToken, nil)
        } catch let error {
            completion?(nil, error)
        }    
    }
}
```

ヘッダーにclientIDとclientSecretを入れる際、base64に変換しておく必要がある。

## ユーザーデータの取得

コントローラーからAPIのメソッドを呼び出して、データを取得する。
ヘッダーにアクセストークンを付けてリクエストを送る。
リクエストにはAlamofireを使用している。

```API.swift
func getCurrentUserProfile(completion: @escaping (Result<UserProfile, Error>) -> Void) {
    
    guard UserDefaults.standard.spotifyAccessToken != "" else { return }
    
    guard let url = URL(string: Contstants.baseAPIURL + "/me") else {
        completion(.failure(APIError.failedToGetData))
        return
    }
    let headers: HTTPHeaders = [
        "Authorization": "Bearer \(UserDefaults.standard.spotifyAccessToken)"
    ]
    
    AF.request(url, method: .get, headers: headers).responseJSON { (response) in
        do {
            guard let _data = response.data else { return }
            let result = try JSONDecoder().decode(UserProfile.self, from: _data)
            print(result)
            completion(.success(result))
        } catch let error {
            print(error.localizedDescription)
            completion(.failure(error))
        }
    }
}
```

```ViewController.swift
override func viewDidLoad() {
    super.viewDidLoad()
    fetchUserProfile()
}

private func fetchUserProfile() {
    API.shared.getCurrentUserProfile { [weak self] result in
        switch result {
        case .success(let model):
            // userを保持する例
            self?.currentUser = model
            break
        case .failure(let error):
            print(error.localizedDescription)
        }
    }
}
```

### その他
ユーザーデータとほぼ同じ手順で、プレイリストやその他のデータも取得できます。
新たな学びがあったら追記していきます。

## おわりに
Spotifyはドキュメントが親切で、しっかりと読んでいけばちゃんと答えに辿り着けるようになっていました。
(英語なので翻訳しながら読む必要がありますが汗)

コードについては、試行錯誤しながらあれこれやっていたのであまり綺麗ではないかもしれません。
この辺はコードレビュー受けるなどしてブラッシュアップしていきたいです。