SwiftでTwitter API v2を使ってみよう（MVVM風）

おはよう。猛暑だね。:relaxed: 

今日は、色々あってお仕事でSwiftUIでアプリを作成したのだけれど、
そのときのAPI通信の仕様の作りを晒しつつ、
以前PythonでやってみたTwitterAPIv2を試してみるよ。

SwiftUIのチュートリアルやって遊んでたレベルの初心者なので、これが正しいのかは不明ですが、
なかなかこれだ！というAPI通信のサンプルを見つけらず、苦労をしたのでだれかの参考になれば嬉しいな。

TwitterAPIの種類や使い方はこちらの記事を見てね。
[PythonでTwitter API v2を使ってみよう。ツイートを探す編
](https://qiita.com/akky-tys/items/4a1b6a11acb1fe226361#apitweet-recent-search)


####今回試したAPI
1 API:Tweet lookup (IDを指定して探す方法) ツイートだけver
2 API:Tweet lookup (IDを指定して探す方法)　指標(RT数とか)も一緒にver
3 API:Tweet recent search(キーワードを指定して探す方法)ツイートだけver
4 API:Tweet recent search(キーワードを指定して探す方法)　指標(RT数とか)も一緒にver

###環境など
**環境:**
Xcode: version 12.5.1 
Swift: version 5.4.2


**ファイル構成:**
Project
|--Model
|　|- TweetModel.swift
|-- View
|　|- TweetView.swift
|-- ViewModel
|　|- TweetViewModel.swift
|-- API
|　|- ApiClient.swift
|　|- ApiFetcher.swift
|　|- TweetsResponse.swift
|-- Common
|　|- ApiError.swift
|　|- ApiSetting.swift


###完成形
1 API：Tweet lookup (IDを指定して探す方法) ツイートだけver

取得対象のTweet
<img width="500" alt="Twitter公式のツイート" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482103/141a513a-92a1-fc2d-52c8-b8eed9774cda.png">

<img width="400" alt="Twitter公式のツイート" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482103/462ba936-fd7c-d615-6ab1-ad45c0e9de65.png">

---

2 API：Tweet lookup (IDを指定して探す方法)　指標(RT数とか)も一緒にver

<img width="400" alt="Twitter公式のツイート" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482103/50522d61-1af3-cffc-de3d-840ce9cff5f4.png">

---

3 API:Tweet recent search(キーワードを指定して探す方法)ツイートだけver

<img width="400" alt="Twitter公式のツイート" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482103/5077bb5f-819b-be2c-9046-857bf4cf9d26.png">

---

4 API:Tweet recent search(キーワードを指定して探す方法)　指標(RT数とか)も一緒にver

<img width="400" alt="Twitter公式のツイート" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482103/14818114-836e-752f-6a51-1e21b82a80e0.png">


個人のツイートを載せるのが怖かったので、モザイクかけたら意味がわからなくなった・・ :relaxed: 


### 具体的にどんなことやってるの？
まずは画面側から説明していくよ。
今回は、1View - 1ViewModelで構成していて、指標の有無をToggleで、APIの切り替えをPickerで行っています。

#### View: TweetView

```swift:TweetView.swift

import SwiftUI

struct TweetView: View {
    
    // ViewとViewModelをバインド(ViewModel側で変更したらView側も変わる)させたいので、@ObservedObjectをつける。
    @ObservedObject private var tweetVm = TweetViewModel() 

    // 画面上での変更を監視したいので、＠Stateをつける。

    @State private var tweetWord: String = ""
    @State private var tweetId: String = ""
    @State private var searchType: Int = 0
    @State private var withTweetDetail: Bool = false

    var body: some View {
        
        
        VStack{
            Picker(selection: $searchType, label: Text("検索方法を選択")) {
                Text("IDで検索").tag(0)
                Text("ワードで検索").tag(1)
                
            }.pickerStyle(SegmentedPickerStyle())
            .frame(width: 400)
            
            
            Toggle(isOn: $withTweetDetail) {
                Text(withTweetDetail ? "指標も一緒に！" : "指標はいらないよ")
            }.frame(width: 350, height: 50)
            
            
            if (searchType == 1) {
                TextField(
                    "調べたいワードを入力してね",
                    text: $tweetWord
                    
                )
                .frame(width: 350, height: 50)
                .font(.caption2)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                
                
                HStack{
                    Button(action: {
                        // ViewModelで設定された関数を呼び出す
                        tweetVm.searchTweets(keyword: self.tweetWord,withTweetDetail: self.withTweetDetail)
                    }, label: {
                        Text("検索")
                    })
                    
                    if (tweetVm.nextToken != "") {
                        Button(action: {
                            tweetVm.searchTweets(keyword: self.tweetWord,withTweetDetail: self.withTweetDetail, isNext: true)
                            
                        }, label: {
                            Text("次の10件>>")
                        })
                    }
                }
            } else {
                
                TextField(
                    "IDを入力してね",
                    text: $tweetId
                )
                .frame(width: 350, height: 50)
                .font(.caption2)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                
                HStack{
                    Button(action: {
                        // ViewModel側で設定された関数を呼び出す。
                        tweetVm.searchById(id: self.tweetId, withTweetDetail: self.withTweetDetail)
                    }, label: {
                        Text("検索")
                    })
                }
                
                
                
                
            }
        }

　　　　　　　　　　　　　　　
        List(tweetVm.tweets) { tweet in
            VStack{
                HStack{
                    
                    Text("ツイートID")
                        .font(.caption)
                        .bold()
                    Text(tweet.tweetId)
                        .font(.caption2)
                }
                Text(tweet.text)
                    .font(.headline)
                    .multilineTextAlignment(.leading)
                
                // 後で出てくるけれど、public_metrics　＝ ツイートの指標で、検索条件に含めるかどうかによってデータがあるかnilかが変わるよ。
                // データがあるときだけ表示するために下記のような書き方をするよ。
                if let i = tweet.public_metrics {
                    
                    HStack {
                        Label(String((tweet.public_metrics?.reply_count)!),
                              systemImage: "message")
                            .font(.caption)
                            .foregroundColor(.green)
                    
                        Label(String((tweet.public_metrics?.retweet_count)!),
                              systemImage: "arrow.2.squarepath")
                            .font(.caption)
                            .foregroundColor(.green)
                        Label(String((tweet.public_metrics?.quote_count)!),
                              systemImage: "pencil")
                            .font(.caption)
                            .foregroundColor(.green)
                        
                        

                        Label(String((tweet.public_metrics?.like_count)!),
                              systemImage: "heart.fill")
                            .font(.caption)
                            .foregroundColor(.green)
                        
                        
                    }
                    
                }
            }
        }
       　// ViewModel側でアラートを設定して表示するために使用しているよ。
        .alert(isPresented: $tweetVm.isShowError ) {
            tweetVm.alert
        }
    }
}

struct TweetView_Previews: PreviewProvider {
    static var previews: some View {
        TweetView()
    }
}



```

#### ViewModel: TweetViewModel
続いてViewModelだよ。
こちらではViewで使用していた関数や共通でbindして使用していた値を設定しているよ。

```swift:TweetViewModel.swift

import Foundation
import Combine
import SwiftUI

class TweetViewModel: ObservableObject {
    

    // View画面でも使用されているbindされている値だよ。
    // TweetModelについては後述します。
    @Published var tweets: [TweetModel] = []
    @Published var nextToken: String = ""
    @Published var isShowError: Bool = false
    @Published var alert: Alert = Alert(title: Text(""))
    
    
    var cancellationToken: AnyCancellable?
    var exKeyword: String = "";
     
    // ApiSetting.swiftの説明時に詳しく書きますが、URLを取得する関数です。
    let url = ApiSetting().getConnectURL();

    // ApiFetcher.swiftの説明時に詳しく書きます。
    let fetcher = ApiFetcher();
    

　　　　　　　　// IDで検索する1・２の場合 (TweetLookup)の関数だよ
    func searchById(id: String, withTweetDetail: Bool) {
        
　　　　　　　　　　　　　　　　　// Viewで設定したアラートはここで設定しているよ。
　　　　　　　　　　　　　　　　// バリデーションチェックなどをここで行うイメージだよ。
        if (id == "") {
            isShowError = true
            alert = Alert(title: Text("検索条件はかならず入力してください☺️"))
            return;
        }
        
        var keywords:[String: String] = ["ids": id]
        
        //　指標を取得する場合は、expansionsとtweet.fieldsの2つを引数として追加するよ。
        if (withTweetDetail) {
            keywords["expansions"]  = "author_id"
            keywords["tweet.fields"] = "public_metrics"
        }

        // ApiFetcher.swiftの関数を呼び出しているよ。
　　　　　　　　　　　　　　　　// 引数のpathはApiSetting.swiftで設定している値です。
        cancellationToken = self.fetcher.searchTweetsRequest(path:.searchById, baseUrl: url, keywords: keywords)
            // ApiFetcherからエラーが戻ってきた場合、それをアラートとして表示するように設定
            .mapError({error -> AppError in
                self.isShowError = true
                self.alert = Alert(title: Text(error.errorDescription!))
                return error
            })
            .sink(receiveCompletion: { completion in
                
                switch completion {
                case .finished:
                    break
                case .failure(_):
                    break
                }
            }, receiveValue: { row in
                
                // IDで取得する場合と、keywordで取得する場合で返り値が異なるのに注意！
                if (row.data != nil) {
                    
                    self.tweets = row.data!;
                    
                } else {
                    self.isShowError = true
                    self.alert = Alert(title: Text("一致するツイートが見つかりませんでした"))
                    self.tweets = []
                }
                
            })
        
    }
    

    // キーワードで検索する3・４の場合(Tweet recent searchh)ときの関数だよ。
    func searchTweets(keyword: String, withTweetDetail:Bool,isNext: Bool = false) {
        
        if (keyword == "") {
            isShowError = true
            alert = Alert(title: Text("検索条件はかならず入力してください☺️"))
            return;
        }
        
        
        var keywords:[String: String] = ["query": keyword]
        
        
        // キーワードで取得する場合は、1回のAPIコールで10件まで取得できるよ。
        // 続きから取得したい場合は、next_tokenの引数を追加して上げる必要があるよ。
        if (self.nextToken != ""  && exKeyword == keyword && isNext) {
            keywords["next_token"] = self.nextToken
        }

        if (withTweetDetail) {
            keywords["expansions"]  = "author_id"
            keywords["tweet.fields"] = "public_metrics"
        }
        exKeyword = keyword
        
　　　　　　　　　　　　　　　　// ApiFetcher.swiftの関数を呼び出しているよ。
　　　　　　　　　　　　　　　// 引数のpathはApiSetting.swiftで設定している値です。
        cancellationToken = self.fetcher.searchTweetsRequest(path: .searchTweets, baseUrl: url, keywords: keywords)
            .mapError({error -> AppError in
                
                return error
            })
            .sink(receiveCompletion: { completion in
                
                switch completion {
                case .finished:
                    break
                case .failure(_):
                    break
                }
            }, receiveValue: { row in
                // IDとkeywordで取得する場合には戻り値が異なるため、注意!
                if (row.meta!.result_count > 0) {
                    self.tweets = row.data!;
                    self.nextToken = row.meta!.next_token!;
                    
                } else {
                    self.isShowError = true
                    self.alert = Alert(title: Text("一致するツイートが見つかりませんでした"))
                    self.tweets = []
                    self.nextToken = "";
                    
                }
            })
        
    }
    
    
}

```
#### Common: ApiSetting
Apiに使われるpathなどを集約しているよ。
今回は使っていないけれど、本番とテスト環境の切り替えや人によって処理を変えたい場合などはここを使用するよ。

```swift:ApiSetting.swift

import Foundation
import SwiftUI


struct ApiSetting {
    
    private static let PrivateUrl     = URL(string: "https://api.twitter.com/2/")!
    static let BearerToken = XX-自分で取得したTokenを設定。-XX;
    

    //　今回はTwitterのURLだけだけど、実際には引数も持たせて、
    // 本番環境とテスト環境のどちらにアクセスするかを変えていました。
    func getConnectURL() ->URL {
        return ApiSetting.PrivateUrl;
    }
    // pathで使用していた共通URL以下のアドレスです。
    // IDで検索するときはhttps://api.twitter.com/2/tweets
    // キーワードで検索するときは、https://api.twitter.com/2/tweets/search/recentをURLとして使用していました。
    enum ApiPath: String {
        case searchById = "tweets"
        case searchTweets = "tweets/search/recent"

    }

}

```


画面側のポイントとしては、APIの種類によって戻り値が異なることです。

IDで検索する場合は、デフォルトではidとTextだけが入ったツイートフィールドしか取得できません。
なので、一致するツイートがあったかどうかは、dataが取得できたかどうかで判断する必要があります。

一方、キーワードで検索する場合は、metaフィールドがくっついて戻ってきます。
この中には、result_count(件数)やnext_token(続きから取得する場合のトークン)などが入っていて、
データがあったかどうかはresult_countで判断する必要があります。

この辺はpythonでやったときのデータの戻りを見てもらうとわかりやすいかと思います:relexed:


ちなみにAlertはこんな感じで表示されます。

検索条件が入力されていないとき↓
<img width="400" alt="アラート１" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482103/18b459ee-6864-6f9a-8dee-60b528cb8620.png">

一致するTweetがないとき↓
<img width="400" alt="アラート２" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482103/fb2a2eff-19a7-d89a-0e1a-d24f062f5df6.png">



続いて、Apiの呼び出しに関連するApiFetcher.swiftとApiClient.swiftを続けて見ていくよ。


#### API: ApiFetcher

```swift:ApiFetcher.swift

import Foundation
import Combine
import os

// ViewModel側から呼び出されていたのはこちらになります。
class ApiFetcher {
    
    var apiClient    = ApiClient()
    
    /* CALL  API : api/TweetRequest */
　　　　　　　　// ポイント①
    func searchTweetsRequest(path: ApiSetting.ApiPath,baseUrl: URL, keywords: [String: String]) -> AnyPublisher<TweetsResponse, AppError> {
        
        // ベースとなるurlに、APIごとのpathを合体させます。
        var url = baseUrl.appendingPathComponent(path.rawValue)
        
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true)
        else { return Fail(error: AppError.invalidUrl).eraseToAnyPublisher()}
        
        
        // queryの引数に追加する項目を設定
        // 例えば、query=猫で検索したい場合、 URLQueryItem(name: "query", value="猫")とすることで、URLにもたせています。
        components.queryItems = keywords.map {(key,value) in
            URLQueryItem(name:key, value: value )
        }

        var request = URLRequest(url: components.url!)
        //　今回はすべてGETなので下記のように設定
        request.httpMethod = "GET"
        
        //Twitter APIを使うのに必要なBearerを設定します。Token自体は先述のApiSettingにて設定されている値を参照しています。
        request.setValue( "Bearer \(ApiSetting.BearerToken)", forHTTPHeaderField: "Authorization")
        
        // ApiClient.swiftに続く
        return apiClient.run(with: request)
            .map(\.value)
            .eraseToAnyPublisher()
    }
}


```

#### API: ApiClient

```swift:ApiClient.swift

import Foundation
import Combine
import os

class ApiClient {
    
    let logger = Logger()
    
    struct Response<T> {
        let value: T
        let response: URLResponse
    }
    
　　　　　　　　//　ポイント②
    func run<T: Decodable>(with request: URLRequest) -> AnyPublisher<Response<T>, AppError> {
        
        return URLSession.shared
            .dataTaskPublisher(for: request)
            .tryMap { (data,urlResponse) -> Response<T> in

                // APIのステータスコードによって、画面に現れるエラーが変わるようにしています。
                // アラートをそのまま出しても良いけれど、わかりやすい言葉に変えたほうがいいかなーということでここで変換をしています。
                // ApiErrorについては、後述します。

                if let httpUrlResponse = urlResponse as? HTTPURLResponse {

                    self.logger.info("urlResponse.statusCode = \(httpUrlResponse.statusCode)")
                    
      
                    if httpUrlResponse.statusCode != 200  {
                        
                        switch httpUrlResponse.statusCode {
                        case 401:
                            throw AppError.unauthorizedRequired
                            
                        case 500:
                            throw AppError.serverError
                            
                        case 501:
                            throw AppError.gatewayTimeout
                            
                        default:
                            throw AppError.unexpectedNetworkError(code: httpUrlResponse.statusCode, message:error.localizedDescription)
                        }
                    }
                }
                
                do {
　　　　　　　　　　　　　　　　　　　　　　         // ポイント③
                    let values = try JSONDecoder().decode(T.self, from: data)
               
                    return Response(value: values, response: urlResponse)
                    
                } catch {

                        throw AppError.unexpectedNetworkError(code: 69, message: error.localizedDescription)
                    
                }
                
            }
            .mapError { $0 as! AppError}
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

```




#### API: TweetResponse
```swift:TweetsResponse.swift

import Foundation

struct TweetsResponse: Decodable {
    
    //　TweetModel.swiftを参照
    // APIの結果によって存在するかが異なるため、ない場合を想定し、「?(Optional型)」をつけます。
　　　　　　　　// こうすることで、存在しない場合はnilを返却してくれるようになります。
    var data: [TweetModel]?
    
    // TwitterAPIの場合、1回のAPIコールで一致するすべてのデータを取得できたようなときは、
　　　　　　　　// next_tokenの項目自体が戻り値からなくなります。なので、Optional型にしてあげる必要があります。
　
    struct Meta: Hashable,Codable {
        var result_count: Int
        var next_token: String?
        
    }

　　　　　　　
    var meta: Meta?

    enum CodingKeys: String, CodingKey {
        case data
        case meta
    }
}
```

#### Model:TweeetModel

```swift:TweetModel.swift

import Foundation

struct TweetModel: Codable, Hashable, Identifiable{

    var id = UUID().uuidString
    var tweetId: String
    var text: String
    var author_id: String?
    
    struct publicMetrics: Hashable, Codable {
        var retweet_count: Int
        var reply_count: Int
        var like_count: Int
        var quote_count: Int
    }

    //詳細を取得しなかった場合には、この項目が存在しないため、？(Optional)をつけます。
    var public_metrics: publicMetrics?
    

    //　APIからの戻り値にidという項目があり、おそらく一意になるとは思いますが、
    // 念の為、画面上で扱う上では、idをtweetIdと名前を変えて、idはUUIDで生成した本当に一意な値に変えています。
    //　本当はキャメルケースとスネークケースも統一したいけれど、めんどくさくて断念・・。

    enum CodingKeys: String, CodingKey {
        case tweetId = "id"
        case text
        case author_id
        case public_metrics
    }
    
}

```

#### Common:ApiError

```swift:ApiError.swift

import Foundation

enum AppError: Error {

　　　　　　　　
    // 401
    case unauthorizedRequired
    // 500
    case serverError
    // 504
    case gatewayTimeout
    // else ApiCall error
    case unexpectedNetworkError(code: Int, message: String)
    // make URL Error
    case invalidUrl
    
    //session is not in Cookie
    case sessionError
    
    // Api status = false
    case apiCallFailed(message: String)
}

extension AppError: LocalizedError {
    var errorDescription: String? {
        switch self {
        
        case .unauthorizedRequired:
            return "ネットワーク認証に失敗しました。"
        case .serverError:
            return "サーバー側でエラーが起きました。お手数ですが担当者にご連絡ください。"
        case .gatewayTimeout:
            return "一定時間内に処理が完了しませんでした。もう一度お試しください。"
        
        case .unexpectedNetworkError(let code, let message):
            return "予期せぬネットワークエラーが発生しました(\(code) : \(message))"
            
        case .invalidUrl:
            return "無効なURLです"
            
        case .sessionError:
            return "ログインが必要です"
        
        case .apiCallFailed(let message) :
            return message
        
        default :
            return "予期せぬエラーです"
        }
    }
}


```




ApiFetcherとApiClientでポイント①②③と記載した部分が重要！
①では、関数からの戻り値として、```AnyPublisher<TweetsResponse, AppError> ```と指定しています。
通常時にはTweetResponse型の値が、エラーが起きたときにはAppError型の値が返却されるよーという意味です多分。

②では、```AnyPublisher<Response<T>, AppError>``` となっています。
<T>というのはジェネリクス型というもので、関数が呼び出されたときにSwift側で柔軟に適切な型に置き換えて処理を実行してくれます。

今回の場合はApiClientを呼び出しているApiFetcher側で、TweetsResponseを通常時のアウトプットの型に指定しているため、ApiClient側でも T=TweetsResponseになります。

この値は③の部分、ApiClient側でデータをデコードする際に、デコードの雛形？として使用されます。

今回はIDとキーワードそれぞれでデータを取得する際、同じTweetsResponse型でデコードができましたが、
全く異なるデータ形式が返却される場合は、ApiFetcher内の関数は複数だけど、呼び出すApiClientは一つに集約することができます。

また、ApiFetcher.swiftの関数は今回は同じURLは異なるけれど同じTweetResponseでデコードができたため、
一つにしていますが、1Model に対し、1Responseを作っていました。



APIコールから値が戻るまでの流れとしては、

TweetViewModel：バリデーションチェックや引数の設定。
↓
ApiFetcher：認証情報やメソッドなどrequestに必要な情報の設定
↓
ApiClient：実際にコール。戻り値のエラー処理&捌き
↓
ApiFetcher:TweetViewModelにApiClientからの戻りを返す
↓
TweetViewModel:戻ってきたデータもとに、変数に詰めたり、アラートを出したり色々する。

という感じです。



### まとめ
先述の通り、Swiftについては初心者なのでこの構成が正しいかは不明・・ :relaxed: 
認識に誤りがあるところや、分かりづらいところがあればご指摘いただけると幸いです。


### 参考
Swift公式
https://developer.apple.com/documentation/swift

Tweet lookup
https://developer.twitter.com/en/docs/twitter-api/tweets/lookup/quick-start
Search Tweets
https://developer.twitter.com/en/docs/twitter-api/tweets/search/quick-start/recent-search