LoginSignup
1
1

More than 1 year has passed since last update.

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

Posted at

この記事は?

最近業務の関係でFlutterからSwiftに乗り換えつつある uehatsu です。今日はSwiftを使ってAPI通信(JSON)を実装する手順について記述していきたいと思います。
Swiftを使い始めて1ヶ月ちょっと(実際には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

レスポンス:

{"endpointVersion":"v4","apiVersion":4}

# 実装の方針など

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

実装

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

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

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

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
MovableTypeNetDataApiTest01/Domains/ValueObjects/ApiVersion.swift
struct ApiVersion: Codable, RawRepresentable {
    var rawValue: Int
}
MovableTypeNetDataApiTest01/Domains/ValueObjects/EndpointVersion.swift
struct EndpointVersion: Codable, RawRepresentable {
    var rawValue: String
}
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で書いたことがまだありません。よってテストコードを書くのも初めてです。以下のページを参考にさせていただきました。

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

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)
    }
}
MovableTypeNetDataApiTest01Tests/Infrastructures/DataApi/version.json
{"endpointVersion":"v3","apiVersion":3}
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)
        }
    }
}

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

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を実装していきます。

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

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プロトコルを実装しておきます。

MovableTypeNetDataApiTest01/Domains/ValueObjects/ApiVersion.swift
struct ApiVersion: Codable, RawRepresentable, Equatable {
    var rawValue: Int
}
MovableTypeNetDataApiTest01/Domains/ValueObjects/EndpointVersion.swift
struct EndpointVersion: Codable, RawRepresentable, Equatable {
    var rawValue: String
}
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をアップデートしてテストが通る事を確認しておきます。

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()を書いている部分くらいでしょうか?こちらは参考にさせて頂いた記事にあるとおりです。

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1