[Swift] URLSession+Combine+CodableでAPIクライアントを作る

**※注意**
**本記事は Xcode 11.0 GM Seed の環境で動作確認したものです。正式版では動作が変わる可能性もあります。**~
(うまく動作しない場合は、シュミレータやXcodeを再起動したりするとうまくいきます。）

https://speakerdeck.com/hcrane/combinedeapikuraiantowozuo-ru

# 前書き

過去に以下のような記事を上げていましたが、そのCombine版になります。

- [[Swift]ObjectMapper+RxSwiftを実装した備忘録](https://qiita.com/H_Crane/items/ac3b372d969e4ec1714e)
- [[Swift]Alamofire+RxSwift+CodableでAPIクライアントを作る](https://qiita.com/H_Crane/items/d2d316b086745417ffae)

そのまんま置き換えというのは、**厳しい部分がありました**がなんとか同じような書き方に集約しております。

Combineについては[【iOS】Combineフレームワークまとめ](https://qiita.com/shiz/items/5efac86479db77a52ccc)を見ていただくのが早いかと思います。

# 導入

## 1.環境

```
- MacOS Catalina 10.15 Beta 8
- Xcode 11.0 GM Seed (Swift 5)
- Alamofire (手軽に試すならPodがおすすめ)
```
をインストールしてください。

#### 余談(Alamofireをインストールしていることに関して)
``URLSession``使うのに``Alamofire``を入れるんかい？という疑問が来そうなので、記載しておきます。

```
①当初はAlamofireをラップしたものでやろうとしていたが、URLSessionに用意されているDataTaskPublisherを用いたほうが綺麗だと気づいた
②基底クラスの作成において、Alamofireで用意されているtypealias、メソッド、エンコード部分が自前で作るより優れていたので採用したかった
```
（そのうちAlamofire本家が対応してくれるのではと思っている笑）

## 2.ネットワークプロトコルの作成

こちらに関しては、上記でもあげた以下
- [[Swift]ObjectMapper+RxSwiftを実装した備忘録](https://qiita.com/H_Crane/items/ac3b372d969e4ec1714e)
- [[Swift]Alamofire+RxSwift+CodableでAPIクライアントを作る](https://qiita.com/H_Crane/items/d2d316b086745417ffae)

に**全く同じ記載があるので省略**します。

## 3.リクエスト・レスポンスの作成

こちらも先の記事内に同様の記載あるので、必要ない方は読み飛ばして大丈夫です。レスポンスモデルが違うので一応記述しています。

### ①レスポンスの作成
例として、APIを叩くと以下のレスポンス（Json）が返ってくるとする。

```Users.json
{
  "data": [
    {
      "name": "Bob",
      "id": 0
    },
    {
      "name": "Sam",
      "id": 1
    }
  ]
}
```

上記のJsonからレスポンスを作成。

```UserResponse.swift
// MARK: - Response

struct UserResponse: Codable {
    var data: [UserModel]
}

struct UserModel: Codable {
    let name: String
    let id: Int
}
```

### ②リクエストの作成

**[2]で作成したプロトコル**に適応する**リクエスト**になるよう作成。

```UserRequest.swift
import Alamofire

// MARK: - Request

struct UserRequest: BaseRequestProtocol {
    typealias ResponseType = UserResponse

    var method: HTTPMethod {
        return .get
    }

    var path: String {
        return "/xxx/yyy/zzz" // ※必要に応じて各自でセットしてください
    }

    var parameters: Parameters? {
        return nil
    }
}
```

#### 余談
~~こちらの[Myjson](http://myjson.com/)というとサービスを使うと、指定した``json``を``https``で返してくれるURLを発行してくれるので、サクッと試したい方はお使いください。~~

サービスが終了しました。[json-server](https://nekoniki.com/20200824_json_server) などで代用しましょう。

## 4.ネットワーククライアントを作成

### ①DataTaskPublisherに関して

``URLSession``に用意された``DataTaskPublisher``を用いることで比較的きれいに書くことができます。

```.swift
URLSession.shared.dataTaskPublisher(for: /* URLRequest or URL */)
```

以下の記事を見ていただけるとわかるかと思いますが、

- [[Swift] HTTP通信部分にCombineを使ってみる #WWDC19](https://dev.classmethod.jp/smartphone/iphone/use-combine-for-http-networking/)

自前で作るとなると

```
- Subscriber
- Subscription
```
とかを意識する必要があり、結構めんどくさいです。

``DataTaskPublisher``でその辺をまるっと考えなくて済むのはありがたい感じです。
（細かいところ処理わけしたいとかある場合は、結局自前でやることになるかと思います）

### ②コード

エラーの種類を先に定義しておきます。各自の状況に応じて拡張してください。

```.swift
// MARK: - Error
enum NetworkError: Error {
    case networkError(code: Int, description: String)
    case decodeError(reason: String)
    case irregularError(info: String)
}
```



実際に作成したコードは以下になります。

```NetworkPublisher.swift

import Alamofire
import Combine

// MARK: - NetworkPublisher

struct NetworkPublisher {

    // MARK: Variables

    private static let successRange = 200..<300
    private static let retryCount: Int = 1
    private static let decorder: JSONDecoder = {
        let jsonDecoder = JSONDecoder()
        jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
        return jsonDecoder
    }()

    // MARK: Methods

    static func publish<T, V>(_ request: T) -> AnyPublisher<V, NetworkError>
        where T: BaseRequestProtocol, V: Codable, T.ResponseType == V {

            return URLSession.shared
                .dataTaskPublisher(for: try! request.asURLRequest()) // 絶対に落ちない保証があるので`try!`
                .timeout(.seconds(20), scheduler: DispatchQueue.main)
                .retry(retryCount)
                .validate(statusCode: successRange) // ※[5.オプション]で後述
                .decode(type: V.self, decoder: decorder)
                .mapDecodeError() // ※[5.オプション]で後述
                .eraseToAnyPublisher()
    }
}
```

#### 備考
メソッドの説明を記載しておきます。
- ``.timeout(Scheduler, scheduler: Queue)`` : タイムアウトの設定
- ``.retry()`` ： 名前の通り失敗したときのリトライ回数を指定
- ``.validate(statusCode: Range)`` : ステータスコードの判定。**独自関数につき後述**
- ``.decode(type: Class, decoder: JSONDecoder)`` : 指定した型にデコード
- ``.mapDecodeError()`` : デコード失敗時のエラー変換。**独自関数につき後述**
- ``.eraseToAnyPublisher()`` : 型消し（Combineではお作法みたいなもの）


### ③使用例

実際に使用した際の記述例になります。

```.swift
var cancellables: Set<AnyCancellable> = []

func publish() {
    let request = UserRequest()

    NetworkPublisher.publish(request)
        .receive(on: DispatchQueue.main)
        .sink(receiveCompletion: { result in
            switch result {
            case .finished: print("finished")
            case .failure(let e): print("failure", e) // failure
            }
        }, receiveValue: { value in
            print("receiveValue", value) // success
        }).store(in: &cancellables)
}

deinit {
    cancellables.forEach { $0.cancel() }
}
```

実行結果（成功時）

```
receiveValue [UserModel(name: "Bob", id: 0), UserModel(name: "Sam", id: 1)]
finished
```

#### 備考
① **sink**に関して
成功時でも**receiveValueで値を受け取った後**に、**receiveCompletion**の**finishedに入ってくる**点に注意。
流れが逆になってる感じが若干違和感ですが、プログレスを消す処理などは成功・失敗共にここで一元管理できそう。


② RxSwiftで考えた時
- ``.receive(on: )`` → **.observeOn()**
- ``.sink()`` → **.subscribe()**
- ``.store(in: )`` → **.disposed(by: )**
- ``Set<AnyCancellable>`` → **Disposebag**
だと思ってみると上記のコードもスーッと入ってこないでしょうか？
やっていることは同じです。（内部的には違うでしょうが笑）


### ④拡張（RxSwiftぽく使うために）

上記でも記載したが、値を受け取った後に、**receiveCompletion**の**finished**に入ってくるのが気持ち悪いので、
RxSwiftっぽく使えるようにもう一段階ラップしたものも用意してみた

```NetworkPublisher.swift
static func sink<T, V>(_ request: T, _ scheduler: DispatchQueue = DispatchQueue.main,
                       success: @escaping (V) -> Void,
                       failure: @escaping (Error) -> Void,
                       completion: @escaping () -> Void = {}) -> AnyCancellable
    
    where T : BaseRequestProtocol, V == T.ResponseType, T.ResponseType : Codable {
        
        publish(request)
            .receive(on: scheduler)
            .sink(receiveCompletion: { result in
                switch result {
                case .finished: break
                case .failure(let e): failure(e)
                }
                completion()
            }, receiveValue: success)
}
```

使用例

```.swift
~省略~

let request = UserRequest()

NetworkPublisher.sink(request, success: { value in
    print("success", value)
}, failure: { error in
    print("failure", error)
}).store(in: &cancellables)

~省略~
```

こちらのほうがより直感的で、かつ必要に応じてcompletionのクロージャをセットすれば良い仕組みになっている。
（あくまでも個人的な好み）

## 5.オプション

**[4]のネットワーククライアント**内で説明していなかったものになります。
実際はなくても動作しますが、あったほうが良いと思い作成。

### ①DataTaskPublisherを拡張

#### StatusCodeのバリデーションに対応

Alamofireに用意されている[**Validate**](https://github.com/Alamofire/Alamofire/blob/03433df845797f58a5cd299e741a32afc0f73408/Source/Validation.swift)のようなことがしたかったので作成。

```.swift 
extension Publisher {
    
    func validate<S>(statusCode range: S) -> Publishers.TryMap<Self, Data>
        where S:Sequence, S.Iterator.Element == Int {
        
        tryMap {
            guard let output = $0 as? (Data, HTTPURLResponse) else {
                throw NetworkError.irregularError(info: "irregular error")
            }
            guard range.contains(output.1.statusCode) else {
                throw NetworkError.networkError(code: output.1.statusCode, description: "out of statusCode range")
            }
            return output.0
        }
    }
}
```


また、特定のエラーコードの場合は特定の処理をしたい

```
ex
- 強制アプデのアラートが出る
- 再ログインのために、ルート画面をスプラッシュやログイン画面に入れ替える
```
みたいなことは、こちらで一元管理することもできます。

####Decodeのエラーに対応

- デコードに失敗した場合
- エラーだけど成功として違うレスポンスが返ってくる場合

などのハンドリングに対応できます。

```.swift 
extension Publisher {
    
    func mapDecodeError() -> Publishers.MapError<Self, NetworkError> {
        mapError {
            switch $0 as? DecodingError {
            case .keyNotFound(_, let context):
                return .decodeError(reason: context.debugDescription)
            default:
                return .decodeError(reason: $0.localizedDescription)
            }
        }
    }
}
```

`DecodingError` はいくつか種類があるので、分岐を細かく設定する場合は各自で設定してください。

# 後書き
RxSwift導入した頃は、なんやこれ、、、と思っていたが、、、
いざCombineに移行しようとしたときに、その便利さにあらために気づかされたりしました笑

とはいえ、公式でサポートしてくれるのはありがたいことであり、
RxSwiftはライブラリとしても地味に大きかったりするので（セットアップとかアプデ時間かかるよね問題）
その辺は移行して解消できたらなぁとは思いつつ。

コードの指摘等あればお願いしますmm

[動作するリポジトリを置いておきます](https://github.com/crane-hiromu/URLSession-Combine-Codable)

### 追記１
リポジトリではAPIのURL（エンドポイント）が古くなっているので、自前で返ってくるものを設定してください。
（それにあわせてレスポンスの型も修正してください）

### 追記２

続編を書きました

https://qiita.com/hcrane/items/288a5f89dc5b2dbec8f2