This article is an article to try to make an API client in various ways. (Leave the title) As a motivation, I wanted to write about the existence of an asynchronous framework and how the difference between Rx and Combine appears in the communication part. So, make a minimum API client with the following 3 patterns.
Requests are common and use the same. It is an endpoint for getting user information of github API.
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)
}
}
Pass the closure and execute after communication. This is a common writing style. In the case of a project that does not introduce an asynchronous framework such as Combine, it looks like this everywhere.
②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()
}
When you receive the observable of the result after communication, pass the value to Behavior Relay. You can write almost the same as the implementation that passes the closure closure to the request. After that, if you observe the relay, it seems that you can easily implement the callback to ViewModel and View. Without Rx, it is declarative that the result is monitored by didSet and the closure is implemented by the closure.
③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 is a Publisher that publishes values only once, and it feels like a Single from RxSwift. Reference: Replace RxSwift Single with Combine-Qiita
Combine allows you to define the type of error in the generics so you don't have to cast the error. In addition, Future can take Result type as an argument with trailing closure of .init.
public typealias Promise = (Result<Output, Failure>) -> Void
public init(_ attemptToFulfill: @escaping (@escaping Future<Output, Failure>.Promise) -> Void)
So, it is good to be able to handle it at the time of completion simply with promise (.success (entity))
.
The value issued by Future is received by sink and handled by recieveValue
and receiveCompletion
.
Personally, I feel uncomfortable that the handling of values and the handling of results are separated.
(Using Never = If there is no failure, you can use a sink with only receiveValue
.)
Like the implementation that passes the Result type closure to completion, it would be nice if the response could also be received by the associated Value of the success case, but when using combine, it seems to be strict because it is necessary to use the above sink.
No matter which writing method was adopted, the API client itself seemed to be similar. So even if you decide to introduce an asynchronous framework in the middle, there seems to be no fear in the communication part. (Rather, it may be necessary to actively change from an API client that is close to the model)
Recommended Posts