はじめに
本記事は色んな作り方でAPIクライアントを作ってみる記事です。(タイトルまま)
モチベーションとしては非同期フレームワークのありなしやRxとCombineの違いが通信部分でどう出てくるのか書いてみて試したかった感じです。
なので以下の3パターンでミニマムなAPIクライアントを作ります。
- URLSession + Codable
- URLSession + RxSwift + Codable
- URLSession + Combine + Codable
実行環境
- Xcode 12
- Swift 5.3
リクエストは共通で同じものを使います。githubAPIのuser情報取得のエンドポイントです。
struct GetUserRequest: BaseRequest {
typealias Response = UserResponse
var path: String { "/users" + "/" + username}
var method: HttpMethod { .get }
let username: String
struct Request: Encodable {}
}
struct UserResponse: Decodable {
var login: String
var id: Int
}
①URLSession + Codable
クライアント本体
protocol BaseRequest {
associatedtype Request: Encodable
associatedtype Response: Decodable
var baseUrl: String { get }
var path: String { get }
var url: URL? { get }
var method: HttpMethod { get }
var headerFields: [String: String] { get }
var encoder: JSONEncoder { get }
var decoder: JSONDecoder { get }
func request(_ parameter: Request?, completionHandler: ((Result<Response, APIError>) -> Void)?)
}
extension BaseRequest {
var baseUrl: String { "https://api.github.com" }
var url: URL? { URL(string: baseUrl + path) }
var headerFields: [String: String] { [String: String]() }
var defaultHeaderFields: [String: String] { ["content-type": "application/json"] }
var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}
var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}
func request(_ parameter: Request? = nil, completionHandler: ((Result<Response, APIError>) -> Void)? = nil) {
do {
let data = parameter == nil ? nil : try encoder.encode(parameter)
request(data, completionHandler: completionHandler)
} catch {
completionHandler?(.failure(.request))
}
}
func request(_ data: Data?, completionHandler: ((Result<Response, APIError>) -> Void)? = nil) {
do {
guard let url = url, var urlRequest = try method.urlRequest(url: url, data: data) else { return }
urlRequest.allHTTPHeaderFields = defaultHeaderFields.merging(headerFields) { $1 }
urlRequest.timeoutInterval = 8
var dataTask: URLSessionTask!
dataTask = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
if let error = error {
completionHandler?(.failure(.responseError(nsError)))
return
}
guard let data = data, let response = response as? HTTPURLResponse else {
completionHandler?(.failure(.emptyResponse))
return
}
guard 200..<300 ~= response.statusCode else {
completionHandler?(.failure(.http(status: response.statusCode)))
return
}
do {
let entity = try self.decoder.decode(Response.self, from: data)
completionHandler?(.success(entity))
} catch {
completionHandler?(.failure(.decode))
}
}
dataTask.resume()
} catch {
completionHandler?(.failure(.request))
}
}
}
enum APIError: Error {
case request
case response(error: Error? = nil)
case emptyResponse
case decode(Error)
case http(status: Int, data: Data)
}
enum HttpMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
case patch = "PATCH"
func urlRequest(url: URL, data: Data?) throws -> URLRequest? {
var request = URLRequest(url: url)
switch self {
case .get:
guard let data = data else {
request.httpMethod = rawValue
return request
}
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return nil }
components.queryItems = dictionary.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
guard let getUrl = components.url else { return nil }
var request = URLRequest(url: getUrl)
request.httpMethod = rawValue
return request
case .post, .put, .delete, .patch:
request.httpMethod = rawValue
request.httpBody = data
return request
}
}
}
利用方法
GetUserRequest(username: "hoge").request(.init()) { [weak self] result in
switch result {
case .success(let response):
guard let self = self else { return }
self.response = response
case .failure(let error):
self.showAlert(message: error.localizedDescription)
}
}
感想
クロージャを渡しておいて通信後に実行します。よくみる書き方です。
Combineなどの非同期フレームワークを導入しないプロジェクトの場合、どこもこんな感じになりそうです。
②URLSession + RxSwift + Codable
クライアント本体
extension BaseRequest {
...
private func request(_ data: Data?) -> Single<Response> {
return Single.create(subscribe: { observer -> Disposable in
do {
guard let url = self.url, var urlRequest = try self.method.urlRequest(url: url, data: data) else {
return Disposables.create()
}
urlRequest.allHTTPHeaderFields = self.defaultHeaderFields.merging(self.headerFields) { $1 }
urlRequest.timeoutInterval = 8
let dataTask = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
if let error = error {
observer(.error(error))
}
guard let data = data, let response = response as? HTTPURLResponse else {
observer(.error(APIError.response))
return
}
guard 200..<300 ~= response.statusCode else {
observer(.error(APIError.http(status: response.statusCode, data: data)))
return
}
do {
let entity = try self.decoder.decode(Response.self, from: data)
observer(.success(entity))
} catch {
observer(.error(APIError.decode(error)))
}
}
dataTask.resume()
return Disposables.create()
} catch {
return Disposables.create()
}
})
}
}
利用方法
private let resultRelay: BehaviorRelay<Result<LoginResponse, Error>?> = BehaviorRelay(value: nil)
private let disposeBag: DisposeBag = DisposeBag()
GetUserRequest(username: "hoge").request(.init())
.subscribe(onSuccess: { response in
self.resultRelay.accept(.success(response))
}, onError: { error in
self.resultSubject.accept(.failure(error))
})
.disposed(by: disposeBag)
var result: Observable<Result<LoginResponse, Error>?> {
return resultRelay.asObservable()
}
感想
通信後の結果のobservableを受け取ったらBehaviorRelayに値を渡します。
ほぼcompletionのクロージャをリクエストに渡す実装と変わらずに書けますね。
あとはrelayをobserveしておけばViewModelやViewへのコールバックが楽に実装できそうです。
RxなしだとdidSetで結果を監視したりクロージャにクロージャが連続したりで実装する箇所が、宣言的になっています。
③URLSession + Combine + Codable
クライアント本体
extension BaseRequest {
...
private func request(_ data: Data?) -> Future<Response, APIError> {
return .init { promise in
do {
guard let url = self.url,
var urlRequest = try self.method.urlRequest(url: url, data: data) else {
return
}
urlRequest.allHTTPHeaderFields = self.defaultHeaderFields.merging(self.headerFields) { $1 }
urlRequest.timeoutInterval = 8
let dataTask = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
if let error = error {
promise(.failure(.response(error: error)))
}
guard let data = data, let response = response as? HTTPURLResponse else {
promise(.failure(.response()))
return
}
guard 200..<300 ~= response.statusCode else {
promise(.failure(.http(status: response.statusCode, data: data)))
return
}
do {
let entity = try self.decoder.decode(Response.self, from: data)
promise(.success(entity))
} catch {
promise(.failure(APIError.decode(error)))
}
}
dataTask.resume()
} catch {
promise(.failure(error))
}
}
}
}
利用方法
private var binding = Set<AnyCancellable>()
@Published private(set) var response: Response?
let exp = expectation(description: "Success")
GetUserRequest(username: "hoge").request(.init())
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
// do something when loading finished.
case .failure(let error):
// do something when the error occured.
}
}, receiveValue: { [weak self] response in
guard let self = self else { return }
self.response = response
}).store(in: &binding)
waitForExpectations(timeout: 20)
感想
Futureは一度だけ値を発行するPublisherで、RxSwiftのSingleみたいな感じです。
参考:Combine で RxSwift の Single を置きかえる - Qiita
Combineはジェネリクスでエラーのタイプを定義できるのでエラーをキャストする必要がありません。
また、Futureは.initのtrailing closureでResult型を引数にとれます。
public typealias Promise = (Result<Output, Failure>) -> Void
public init(_ attemptToFulfill: @escaping (@escaping Future<Output, Failure>.Promise) -> Void)
なので、promise(.success(entity))
でシンプルに完了時のハンドリングできるのが良いですね。
Futureから発行された値はsinkで受け取り、recieveValue
とreceiveCompletion
でハンドリングします。
個人的には値のハンドリングと結果のハンドリング分かれているのが気持ち悪いです。
(Neverを使っている=失敗はない場合はreceiveValue
のみのsinkが使用できます。)
Result型のクロージャをcompletionに渡しあげる実装のように、successのcaseのassociatedValueでresponseも受け取れたらいいんですが、combineを使う場合は上記のsinkを利用する必要があるので厳しそうです。
まとめ
どの書き方を採用してもAPIクライアント自体は似たような作りになりそうでした。
なので途中から非同期フレームワークを導入することになっても通信部分で恐ることはなさそうです。(むしろモデルに近いAPIクライアントから積極的に変えていくべきかも)