Swift + Codable + Combine + URLSession + OHHTTPStubs でAPI通信を実装する

# この記事は？

最近業務の関係でFlutterからSwiftに乗り換えつつある uehatsu です。今日はSwiftを使ってAPI通信(JSON)を実装する手順について記述していきたいと思います。
Swiftを使い始めて１ヶ月ちょっと（実際にはSwift出始めの頃にちょっと触ってますが）なのでツッコミお待ちしております。

# 開発環境

- MacBook Pro 2019 : macOS Monterey 12.6.1
- Xcode 14.1
- AppCode 2022.2.4

## OS

Monterey使ってますが、早いところVenturaにしたいところ。

## IDE

私はIntelliJ IDEA系のIDEが染みついてしまっているので、部分的にAppCodeも利用しています。

# どんなAPI通信をするか？

今回は私の古巣のSix Apart社が提供しているサービスMovableType.netのDataAPIのバージョン情報を取得してみたいと思います。

## MovableType.netのDataAPIとは？

Movable TypeとMovableType.netに共通して実装・提供されているAPI群の事。MovableType.netには一部実装されていない物もあります。

## DataAPIのバージョン情報取得エンドポイントと、そのレスポンス

エンドポイント：
[https://movabletype.net/.data-api/version](https://movabletype.net/.data-api/version)

レスポンス：
```json
{"endpointVersion":"v4","apiVersion":4}
```

#　実装の方針など

1. レスポンスをValueObjectとEntityで表現
1. 実装はCodableプロトコルを利用
1. URLSessionとCombineを使ってAPI通信を実装
1. テスト用のStubにはOHHTTPStubsを利用

# 実装

## Version Entity, EndpointVersion Value Object, ApiVersion Value Object の実装

レスポンスを見ると、`endpointVersion`と`apiVersion`で構成されているので、`Version` Entityは、`EndpointVersion` Value Objectと、`ApiVersion` Value Objectで構成されるものとします。

テストファーストで進めたいので、まずはテストを書きます。

```swift:MovableTypeNetDataApiTest01Tests/Domains/Entities/VersionTests.swift
import XCTest
@testable import MovableTypeNetDataApiTest01

final class VersionTests: XCTestCase {

    func testDecode() throws {
        // 元々のJSON文字列
        let json = """
                   {"endpointVersion":"v4","apiVersion":4}
                   """
        // JSON文字列から作製したDataオブジェクト
        let jsonData = json.data(using: .utf8)!

        // デコード実施
        let version = try JSONDecoder().decode(Version.self, from: jsonData)

        // 値チェック
        XCTAssertEqual(version.endpointVersion.rawValue, "v4")
        XCTAssertEqual(version.apiVersion.rawValue, 4)
    }

    func testEncode() throws {
        // Versionオブジェクト
        let version = Version(endpointVersion: .init(rawValue: "v4"), apiVersion: .init(rawValue: 4))
        // エンコード実施
        let jsonData = try JSONEncoder().encode(version)

        // Dataオブジェクトから文字列へ変換
        let json = String(data: jsonData, encoding: .utf8)!

        // 値チェック（ここではjson文字列のなかに、特定の文字列が含まれているかをチェックしている）
        XCTAssert(json.contains("\"apiVersion\":4"))
        XCTAssert(json.contains("\"endpointVersion\":\"v4\""))
    }

}
```

この時点で`Versionがない`とエラーになっておりテストは実行できません。

テストが書けたので実装を進めます。以下のファイルを作成します。Version EntityがEndpointVersion, ApiVersion Value Objectを持つ形です。

```
├── Domains
│   ├── Entities
│   │   └── Version.swift
│   └── ValueObjects
│       ├── ApiVersion.swift
│       └── EndpointVersion.swift
```

```swift:MovableTypeNetDataApiTest01/Domains/ValueObjects/ApiVersion.swift
struct ApiVersion: Codable, RawRepresentable {
    var rawValue: Int
}
```

```swift:MovableTypeNetDataApiTest01/Domains/ValueObjects/EndpointVersion.swift
struct EndpointVersion: Codable, RawRepresentable {
    var rawValue: String
}
```

```swift:MovableTypeNetDataApiTest01/Domains/Entities/Version.swift
struct Version: Codable {
    var endpointVersion: EndpointVersion
    var apiVersion: ApiVersion
}
```

上記の実装をしただけで、前述のテストが全て通ります。初めは`RawRepresentable`での実装方法が分からず試行錯誤しました（つまり本当は最初に書いたテストコードも初めの時点では、若干違う物になっていました）。

ではコードの解説です。まず`Codable`ですが、これは内部的に`Encodable & Decodable`のType Aliasとなっています。JSONとオブジェクト間で、それぞれ`Encodable`, `Decodable`が実装されているという宣言と思えば良いでしょう。`ApiVersion`, `EndpointVersion`, `Version`とも`Codable`で宣言されているため、自動的にJSONオブジェクト(JSON Data)との相互変換ができます。
次に`RawRepresentable`ですが、これは指定した構造体が`rawValue`を持っていて、それぞれのrawValueの型でデータのやりとりが出来る事を示しています。これを指定する事で型を持ったValue Objectを宣言しています。

## API通信部分の実装

さて、実はAPI通信部分をCombineで書いたことがまだありません。よってテストコードを書くのも初めてです。以下のページを参考にさせていただきました。

https://qiita.com/kamomeKUN/items/1e0ffd5bf2e7394d7133

https://qiita.com/dolfalf/items/fb4bd1d8d44ae7776fc3

では上記２記事を参考にテストコードを書いていきます。

```swift:MovableTypeNetDataApiTest01Tests/Infrastructures/DataApi/DataApiClientTests.swift
import XCTest
import Combine
@testable import MovableTypeNetDataApiTest01

final class DataApiClientTests: XCTestCase {
    var cancellable = Set<AnyCancellable>()
    
    func testDataApiClientGetVersion() throws {
        let expectation = expectation(description: "testDataApiClientGetVersion")
        stubEndpoint(path: "/.data-api/version", file: "version.json")
        
        DataApiClient.shared.getVersion()
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    case .finished:
                        break
                    case .failure(let e):
                        print(e.localizedDescription)
                        XCTFail()
                    }
                },
                receiveValue: { version in
                    print(version)
                    XCTAssert(true)
                    expectation.fulfill()
                }
            )
            .store(in: &cancellable)
        wait(for: [expectation], timeout: 10)
    }
}
```

```json:MovableTypeNetDataApiTest01Tests/Infrastructures/DataApi/version.json
{"endpointVersion":"v3","apiVersion":3}
```

```swift:MovableTypeNetDataApiTest01Tests/Extentions/XCTestCaseUtil.swift
import XCTest
import OHHTTPStubs
import OHHTTPStubsSwift

extension XCTestCase {
    // 参考 : https://qiita.com/dolfalf/items/fb4bd1d8d44ae7776fc3
    func stubEndpoint(
        path: String,
        file: String,
        status: Int32 = 200,
        headers: [String: String]? = ["Content-Type": "application/json"]
    ) {
        let stubResponseDelay = 0.5

        stub(condition: isPath(path)) { _ in
            let stubPath = OHPathForFile(file, type(of: self))
            return fixture(filePath: stubPath!, status: status, headers: headers)
                .requestTime(stubResponseDelay, responseTime:OHHTTPStubsDownloadSpeedWifi)
        }
    }
}
```

それでは実装です。後ほどモックするためにプロトコルを作っていますが、それ以外に特筆する箇所はないかと思います。

```swift:MovableTypeNetDataApiTest01/Infrastructures/DataApi/DataApiClient.swift
import Foundation
import Combine

protocol DataApiClientProtocol: AnyObject {
    func getVersion() -> AnyPublisher<Version, Error>
}

final class DataApiClient: DataApiClientProtocol {
    static let shared = DataApiClient()
    private init() {}
    
    func getVersion() -> AnyPublisher<Version, Error> {
        let url = URL(string: "https://movabletype.net/.data-api/version")!
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Version.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}
```

## ViewModelの実装

今回は`DataApiViewModel`という名前で`ViewModel`を実装していきます。

まずはテストコード。こちらも以下の記事を参考にさせて頂いています。

https://qiita.com/kamomeKUN/items/1e0ffd5bf2e7394d7133

```swift:
import XCTest
import Combine
@testable import MovableTypeNetDataApiTest01

final class DataApiClientMock: DataApiClientProtocol {
    let expectedItem: Version = Version.init(endpointVersion: .init(rawValue: "v3"), apiVersion: .init(rawValue: 3))
    
    func getVersion() -> AnyPublisher<Version, Error> {
        Just(expectedItem)
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
}

final class DataApiViewModelTests: XCTestCase {
    var cancellable = Set<AnyCancellable>()
    
    func testDataApiViewModelGetVersion() {
        let clientMock = DataApiClientMock()
        let viewModel = DataApiViewModel(dataApiClient: clientMock)
        viewModel.getVersion()
        
        XCTAssertEqual(clientMock.expectedItem, viewModel.version)
    }
}
```

ここで`Version`オブジェクトの比較をしているので、EntityとValue Objectにそれぞれ`Equatable`プロトコルを実装しておきます。

```swift:MovableTypeNetDataApiTest01/Domains/ValueObjects/ApiVersion.swift
struct ApiVersion: Codable, RawRepresentable, Equatable {
    var rawValue: Int
}
```

```swift:MovableTypeNetDataApiTest01/Domains/ValueObjects/EndpointVersion.swift
struct EndpointVersion: Codable, RawRepresentable, Equatable {
    var rawValue: String
}
```

```swift:MovableTypeNetDataApiTest01/Domains/Entities/Version.swift
struct Version: Codable, Equatable {
    var endpointVersion: EndpointVersion
    var apiVersion: ApiVersion
    
    static func == (lhs: Version, rhs: Version) -> Bool {
        return lhs.endpointVersion == rhs.endpointVersion && lhs.apiVersion == rhs.apiVersion
    }
}
```

念のため`VersionTests`をアップデートしてテストが通る事を確認しておきます。

```swift:MovableTypeNetDataApiTest01Tests/Domains/Entities/VersionTests.swift
import XCTest
@testable import MovableTypeNetDataApiTest01

final class VersionTests: XCTestCase {

    func testDecode() throws {
        // 元々のJSON文字列
        let json = """
                   {"endpointVersion":"v4","apiVersion":4}
                   """
        // JSON文字列から作製したDataオブジェクト
        let jsonData = json.data(using: .utf8)!

        // デコード実施
        let version = try JSONDecoder().decode(Version.self, from: jsonData)

        // 値チェック
        XCTAssertEqual(version.endpointVersion.rawValue, "v4")
        XCTAssertEqual(version.apiVersion.rawValue, 4)
        // ↓↓↓ この行を追加 ↓↓↓
        XCTAssertEqual(version, Version.init(endpointVersion: .init(rawValue: "v4"), apiVersion: .init(rawValue: 4)))
    }

    func testEncode() throws {
        // Versionオブジェクト
        let version = Version(endpointVersion: .init(rawValue: "v4"), apiVersion: .init(rawValue: 4))
        // エンコード実施
        let jsonData = try JSONEncoder().encode(version)

        // Dataオブジェクトから文字列へ変換
        let json = String(data: jsonData, encoding: .utf8)!

        // 値チェック（ここではjson文字列のなかに、特定の文字列が含まれているかをチェックしている）
        XCTAssert(json.contains("\"apiVersion\":4"))
        XCTAssert(json.contains("\"endpointVersion\":\"v4\""))
    }

}
```

準備ができたので実装です。こちらも特筆するものは無いかと思われます。DataApiClientをDIできるようにinit()を書いている部分くらいでしょうか？こちらは参考にさせて頂いた記事にあるとおりです。

```swift:MovableTypeNetDataApiTest01/ViewModels/DataApiViewModel.swift
import Foundation
import Combine

final class DataApiViewModel: ObservableObject {
    private let dataApiClient: DataApiClientProtocol
    private var cancellables = Set<AnyCancellable>()
    
    @Published var version: Version? = nil
    
    init(dataApiClient: DataApiClientProtocol = DataApiClient.shared) {
        self.dataApiClient = dataApiClient
    }
    
    func getVersion() {
        dataApiClient
            .getVersion()
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    case .finished:
                        break
                    case .failure(let e):
                        print(e.localizedDescription)
                        break
                    }
                }, receiveValue: { version in
                    self.version = version
                }
            )
            .store(in: &cancellables)
    }
}
```

# まとめ

今回はSwiftを使ったAPI通信の実装を手順を追って書いてきました。テストファーストで書かないと気が済まないのですが、まだまだハードルが高いのも確かです。今回得た色々な知識を業務にも反映できればと考えています。