この記事は?
最近業務の関係で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}
# 実装の方針など
- レスポンスをValueObjectとEntityで表現
- 実装はCodableプロトコルを利用
- URLSessionとCombineを使ってAPI通信を実装
- テスト用のStubにはOHHTTPStubsを利用
実装
Version Entity, EndpointVersion Value Object, ApiVersion Value Object の実装
レスポンスを見ると、endpointVersion
とapiVersion
で構成されているので、Version
Entityは、EndpointVersion
Value Objectと、ApiVersion
Value Objectで構成されるものとします。
テストファーストで進めたいので、まずはテストを書きます。
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
struct ApiVersion: Codable, RawRepresentable {
var rawValue: Int
}
struct EndpointVersion: Codable, RawRepresentable {
var rawValue: String
}
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記事を参考にテストコードを書いていきます。
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)
}
}
{"endpointVersion":"v3","apiVersion":3}
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)
}
}
}
それでは実装です。後ほどモックするためにプロトコルを作っていますが、それ以外に特筆する箇所はないかと思います。
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
プロトコルを実装しておきます。
struct ApiVersion: Codable, RawRepresentable, Equatable {
var rawValue: Int
}
struct EndpointVersion: Codable, RawRepresentable, Equatable {
var rawValue: String
}
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
をアップデートしてテストが通る事を確認しておきます。
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()を書いている部分くらいでしょうか?こちらは参考にさせて頂いた記事にあるとおりです。
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通信の実装を手順を追って書いてきました。テストファーストで書かないと気が済まないのですが、まだまだハードルが高いのも確かです。今回得た色々な知識を業務にも反映できればと考えています。