LoginSignup
16
15

More than 3 years have passed since last update.

[swift5] いろんな作り方でAPIクライアントを作ってみる

Last updated at Posted at 2020-10-22

はじめに

本記事は色んな作り方でAPIクライアントを作ってみる記事です。(タイトルまま)
モチベーションとしては非同期フレームワークのありなしやRxとCombineの違いが通信部分でどう出てくるのか書いてみて試したかった感じです。
なので以下の3パターンでミニマムなAPIクライアントを作ります。

  1. URLSession + Codable
  2. URLSession + RxSwift + Codable
  3. 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で受け取り、recieveValuereceiveCompletionでハンドリングします。
個人的には値のハンドリングと結果のハンドリング分かれているのが気持ち悪いです。
(Neverを使っている=失敗はない場合はreceiveValueのみのsinkが使用できます。)

Result型のクロージャをcompletionに渡しあげる実装のように、successのcaseのassociatedValueでresponseも受け取れたらいいんですが、combineを使う場合は上記のsinkを利用する必要があるので厳しそうです。

まとめ

どの書き方を採用してもAPIクライアント自体は似たような作りになりそうでした。
なので途中から非同期フレームワークを導入することになっても通信部分で恐ることはなさそうです。(むしろモデルに近いAPIクライアントから積極的に変えていくべきかも)

16
15
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
16
15