Git リポジトリ検索APIをステップ・バイ・ステップでリファクタリング

# はじめに

検証コードを書く時にGithub APIを使うことが多いですが、APIの種類を増やすのにコピー＆ペーストして作成してしまっています。

”コピー＆ペーストで重複したコードを増やさないようにしたい”、また、”データ通信ではなくJsonからデータ取得するテストコードと共存させたい”　という動機からリファクタリングをしていきます。

今回リファクタリングするにあたっては、
[【iOSDC2019 補足資料】具体的なコードから始めよ ~今の問題を解決し、ジェネリックなコードを見出す](https://qiita.com/shiz/items/5755a35887bcb7897464) を参考にさせてもらっています。
いつも、とても勉強になる投稿本当にありがとうございます。

まずリファクタリング前のオリジナルのコードを見てみましょう。

# リポジトリ検索のオリジナルコード

```swift
import Combine
import Foundation

protocol RepoService {
    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error>
}

class RepoServiceImpl: RepoService {
    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
        self.session = session
        self.decoder = decoder
    }

    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        guard
            var urlComponents = URLComponents(string: "https://api.github.com/search/repositories")
        else { preconditionFailure("Can't create url components...") }

        urlComponents.queryItems = [
            URLQueryItem(name: "q", value: query)
        ]

        guard
            let url = urlComponents.url
        else { preconditionFailure("Can't create url from url components...") }

        return session
            .dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: RepoResponse.self, decoder: decoder)
            .map { $0.items }
            .eraseToAnyPublisher()
    }
}

struct RepoResponse: Decodable {
   let items: [Repo]
}
```

Combineフレームワークを使っているので、スッキリ書けています。
このAPI一本だけなら、このように書くので良いと思います。

ただ、ユーザー検索APIを追加する時、上のコードをコピーして、以下の3箇所を変更することになり、共通点がとても多いことに気づきます。

* エンドポイント変更


```swift
 var urlComponents = URLComponents(string: "https://api.github.com/search/repositories")
  ↓
 var urlComponents = URLComponents(string: "https://api.github.com/search/users")
```

* エンティティの型の変更


```swift
 -> AnyPublisher<[Repo], Error> {
   ↓
 -> AnyPublisher<[User], Error> {
```

* デコーダーの型の変更


```swift
.decode(type: RepoResponse.self, decoder: decoder)
   ↓
.decode(type: UserResponse.self, decoder: decoder)
```

また、テストコードを書く場合にどうしたら良いのかも検討したいと思います。

# コードの改善

## 共通部分の洗い出し＆方針

* APIのURLは、 `/repositories` `/users` のエンドポイント以外は完全一致しているので、エンドポイントを切り出せば良さそう。
* Decode時に指定している型が `RepoResponse`、 `UserResponse` と異なりますが、Genericsを使えば解決できそう。
* 戻りの型が `-> AnyPublisher<[Repo], Error>` となっていますが、これもGenericsを使えば解決できそう。

オフィシャルページを確認してみます。今回使用するこの2つは[Search](https://developer.github.com/v3/search/)機能に属するAPIです。パラメータは

* q
* sort
* order

と共通です。

APIのResponseも itemsの外側は完全に一致しています。

```swift
{
  "total_count": 1,
  "incomplete_results": false,
  "items": [
    {
       // ...
    }
   ]
}
```

尚、[Repositories](https://developer.github.com/v3/repos/) のAPIとの共通化は検討しないものとします。

## 共通部分をAPIClientとして作成

`APIのレスポンスを格納している、RepoResponse` は `items` のみをプロパティに持つStructです。

```swift
struct RepoResponse: Decodable {
   let items: [Repo]
}
```

汎用化するにあたり、`Repo` を`associatedtype` で表現すれば解決できそうです。
`/repositories` のエンドポイントを `apiBase` として定義します。

この2点を加味すると以下のようなProtocolが作れます。

```swift
protocol Fetchable: Decodable {
    associatedtype Response
    static var apiBase: String { get }
    var items: [Response] { get set }
}
```

Fetchableに準拠させて、RepoResponseは以下のように書きかえられます。
今回は、型推論が効くのでtypealiasを指定を省略しています。

```swift
struct RepoResponse: Fetchable {
    var items: [Repo]
    static var apiBase: String { "/repositories" }
}
```

次に、`RepoServiceImpl` と `UserServiceImpl` で共通部分を抽出すると以下のようになります。

```swift
final class APIClient {
    let baseURL = "https://api.github.com/search"
    let session = URLSession.shared

    func fetch<Model>(_: Model.Type, query: String) -> AnyPublisher<[Model.Response], Error> where Model: Fetchable {
        guard var urlComponents = URLComponents(string: "\(baseURL)\(Model.apiBase)") else {
            preconditionFailure("Can't create url components...")
        }
        urlComponents.queryItems = [
            URLQueryItem(name: "q", value: query)
        ]

        guard let url = urlComponents.url else { preconditionFailure("Cant't create url from url components...") }

        return session.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: Model.self, decoder: JSONDecoder())
            .map { $0.items }
            .eraseToAnyPublisher()
    }
}
```

Genericsの `Model` は、`where Model: Fetchable` により、Fetchableに準拠させています。

これによりModelにapiBaseを定義していることになるので、APIのURLをbaseURL + apiBaseで表現することができるようになります。

 戻り値は `-> AnyPublisher<[Model.Response]` となります。

decoderは　`.decode(type: Model.self, decoder: JSONDecoder())` となります（型を指定する必要があるので、 Model.selfとしています）

`func fetch<Model>(_: Model.Type, ...)`
このModel.Typeは関数の内部で使用されていませんが何なのでしょうか。

呼び出し側で、

```swift
apiClient.fetch(RepoResponse.self, ...)
```

とすることで `Model == RepoResponse` が成り立ちます。
つまり、Genericsの型を指定するためのパラメータであることがわかります。
**Modelではなく、Model.Typeとしているのは、インスタンスではなく型を受け取るためです。**

`APIClient` に共通部分の処理を移管できましたので、 `RepoServiceImpl` は以下のように書けます。大分スッキリしました！

```swift
final class RepoServiceImpl: RepoService {
    private let apiClient: APIClient
    init() {
        apiClient = APIClient()
    }

    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        apiClient.fetch(RepoResponse.self, query: query)
    }
}
```

## テスト可能なコードに書き換え

上のコードは、URLSessionを使う前提の作りとなっているため、API通信が必要となってしまっています。

JSONを読み込んでその結果を返却するようなテスト用のMockコードを作成するには、APIClientが使えないので、 `RepoServiceImpl` とは独立した以下のようなClassを作成する必要があります。

```swift
final class RepoServiceMock: RepoService {
    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        // load data from json
        let data = repoData.items 
        if data.isEmpty {
            let error = FetchError.parsing(description: "Couldn't load data")
            return Fail(error: error).eraseToAnyPublisher()
        } else {
            return Future<[Repo], Error> { promise in
                promise(.success(data))
            }.eraseToAnyPublisher()
        }
    }
}

let repoData: RepoResponse = load("repositories.json")

func load<T: Decodable>(_ filename: String, as type: T.Type = T.self) -> T {
    let data: Data

    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }

    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }

    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}
```

APIClient内部でURLSessionが定義されていなければ、この RepoServiceMock は RepoServiceImpl と共通化できそうです。

では、URLSessionを使わないように書き換えるにあたり、どの部分まで抽出できるか考えます。

```swift
        return session.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: Model.self, decoder: JSONDecoder())
            .map { $0.items }
            .eraseToAnyPublisher()
```

Decode部分はGenericsで型指定されているので、

```swift
return session.dataTaskPublisher(for: url)
            .map { $0.data }
```

その前の部分を切り出せれば良さそうです。

## dataTaskPublisherについて調査

`dataTaskPublisher` が何か調べていきます。

このdataTaskPublisherはURLSessionの中にStructとして定義されているため、URLSessionありきのものであることがわかります。

```swift
extension URLSession {
 ...
    public func dataTaskPublisher(for url: URL) -> URLSession.DataTaskPublisher

    public struct DataTaskPublisher : Publisher {

        /// The kind of values published by this publisher.
        public typealias Output = (data: Data, response: URLResponse)

        /// The kind of errors this publisher might publish.
        ///
        /// Use `Never` if this `Publisher` does not publish errors.
        public typealias Failure = URLError

        public let request: URLRequest

        public let session: URLSession

        public init(request: URLRequest, session: URLSession)

        /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)`
        ///
        /// - SeeAlso: `subscribe(_:)`
        /// - Parameters:
        ///     - subscriber: The subscriber to attach to this `Publisher`.
        ///                   once attached it can begin to receive values.
        public func receive<S>(subscriber: S) where S : Subscriber, S.Failure == URLSession.DataTaskPublisher.Failure, S.Input == URLSession.DataTaskPublisher.Output
    }
}
```

### map { $0.data } はどんな返却値なの？

`struct DataTaskPublisher : Publisher` と定義されているので、
mapはPublisherで定義されているものとなります。 

```swift
extension Publisher {

    /// Transforms all elements from the upstream publisher with a provided closure.
    ///
    /// - Parameter transform: A closure that takes one element as its parameter and returns a new element.
    /// - Returns: A publisher that uses the provided closure to map elements from the upstream publisher to new elements that it then publishes.
    public func map<T>(_ transform: @escaping (Self.Output) -> T) -> Publishers.Map<Self, T>

}
```

戻り値の型が、 `Publishers.Map<Self, T>`  となっていますが、

Tはtransformで変換した戻り値の型であることがわかります。今回はData型に変換しているので、 T == Dataとなります。

Selfは何でしょうか？これは、自分自身　つまり、 `URLSession.DataTaskPublisher` となります。

つまり、URLSessionの一部の　DataTaskPublisher　を返却するPublisherなのでURLSessionから独立できません。

## ほしいのはData型じゃないの？

今回リファクタリングするにこの発想に至るのが時間がかかりました。このままの形ではデータはURLSessionから独立できないので、データの型を `AnyPublisher<Data, URLError>` に変換してしまえば良いのではないでしょうか。

## Transportの定義

`AnyPublisher<Data, URLError>` を戻り値の関数を持つ、Transportプロトコルを定義します。

`AnyPublisher` に変換するため、 `.eraseToAnyPublisher()` を追加します。

```swift
protocol Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError>
}

extension URLSession: Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError> {
        dataTaskPublisher(for: url).map { $0.data }.eraseToAnyPublisher()
    }
}
```

良さそうですね。 **Retroactive Modeling**も活用して、URLSessionにfetch関数を追加しています。これで、APIClientからURLSessionを排除できそうです。

APIClientは以下のように書き換えられます。

コンストラクタ時に、URLSession.shared = Transportとしているので今まで通り機能します。
リファクタリングする際にパラメータは `q` 以外に、 `sort`、`order` も指定できるように変更しました。

```swift
final class APIClient {
    let baseURL = "https://api.github.com/search"
    let transport: Transport

    init(transport: Transport = URLSession.shared) { self.transport = transport }

    func fetch<Model>(_: Model.Type, queries: [URLQueryItem]) -> AnyPublisher<[Model.Response], Error> where Model: Fetchable {
        guard var urlComponents = URLComponents(string: "\(baseURL)\(Model.apiBase)") else {
            preconditionFailure("Can't create url components...")
        }
        if !queries.isEmpty {
            urlComponents.queryItems = queries
        }
        guard let url = urlComponents.url else { preconditionFailure("Cant't create url from url components...") }

        return transport.fetch(for: url)
            .decode(type: Model.self, decoder: JSONDecoder())
            .map { $0.items }
            .eraseToAnyPublisher()
    }
}
```

コンストラクタとして、URLSession.sharedを受け取りますが、fetch関数の中にはURLSessionの記述はなくなりました。

利用する側は以下のようになります。

```swift
final class RepoServiceImpl: RepoService {
    private let apiClient: APIClient
    init(transport: Transport = URLSession.shared) {
        apiClient = APIClient(transport: transport)
    }

    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        apiClient.fetch(RepoResponse.self, queries: [URLQueryItem(name: "q", value: query)])
    }
}
```

## テスト用のコードを作成

Jsonデータを読み込んだテストコードは、Transportに準拠させれば良いので、以下のようにシンプルに書けます。これでテスト用のコードでもAPIClientを使用するようにできました。

APIリクエストしてData型を取得、Jsonファイルを読み込んでData型を取得の部分が違うだけで後は処理が共通になりました。

```swift
final class TestRepoTransport: Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError> {
        let data = loadRawData("repositories.json")
        return Future<Data, URLError> { callback in
            callback(.success(data))
        }
        .eraseToAnyPublisher()
    }
}


func loadRawData(_ filename: String) -> Data {
    let data: Data
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }
    return data
}
```

RepoServiceImplのイニシャライズ時に、
API通信させたい場合は、`RepoServiceImpl()`、
Jsonからデータを取得したい場合は、 `RepoServiceImpl(transport: TestRepoTransport())` とするだけで差し替え可能になりました。

## ソース全文

* RepoService

```swift
import Combine
import Foundation

protocol RepoService {
    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error>
}

final class RepoServiceImpl: RepoService {
    private let apiClient: APIClient
    init(transport: Transport = URLSession.shared) {
        apiClient = APIClient(transport: transport)
    }

    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        apiClient.fetch(RepoResponse.self, queries: [URLQueryItem(name: "q", value: query)])
    }
}

// MARK: - Test

final class TestRepoTransport: Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError> {
        let data = loadRawData("repositories.json")
        return Future<Data, URLError> { callback in
            callback(.success(data))
        }
        .eraseToAnyPublisher()
    }
}
```

* APIClient


```swift
import Combine
import Foundation

final class APIClient {
    let baseURL = "https://api.github.com/search"
    let transport: Transport

    init(transport: Transport = URLSession.shared) { self.transport = transport }

    func fetch<Model>(_: Model.Type, queries: [URLQueryItem]) -> AnyPublisher<[Model.Response], Error> where Model: Fetchable {
        guard var urlComponents = URLComponents(string: "\(baseURL)\(Model.apiBase)") else {
            preconditionFailure("Can't create url components...")
        }
        if !queries.isEmpty {
            urlComponents.queryItems = queries
        }
        guard let url = urlComponents.url else { preconditionFailure("Cant't create url from url components...") }

        return transport.fetch(for: url)
            .decode(type: Model.self, decoder: JSONDecoder())
            .map { $0.items }
            .eraseToAnyPublisher()
    }
}

protocol Fetchable: Decodable {
    associatedtype Response
    static var apiBase: String { get }
    var items: [Response] { get set }
}

protocol Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError>
}

extension URLSession: Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError> {
        dataTaskPublisher(for: url).map { $0.data }.eraseToAnyPublisher()
    }
}
```

* Repo


```swift
struct Repo: Decodable, Identifiable {
    var id: Int
    let owner: Owner
    let name: String
    let stargazersCount: Int
    let description: String?

    enum CodingKeys: String, CodingKey {
        case id
        case owner
        case name
        case stargazersCount = "stargazers_count"
        case description
    }

    struct Owner: Decodable {
        let avatar: URL

        enum CodingKeys: String, CodingKey {
            case avatar = "avatar_url"
        }
    }
}

// struct RepoResponse: Decodable {
//    let items: [Repo]
// }
struct RepoResponse: Fetchable {
    var items: [Repo]
    static var apiBase: String { "/repositories" }
}
```

* LoadData

```swift
let repoData: RepoResponse = load("repositories.json")

func loadRawData(_ filename: String) -> Data {
    let data: Data
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }
    return data
}
```