[swift5] Try to make an API client with various methods

Introduction

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.

  1. URLSession + Codable

Execution environment

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

Client body

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
        }
    }
}

How to Use

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)
    }
}

Impressions

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

Client body


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()
            }
        })
    }
}

How to Use

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()
}

Impressions

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

Client body


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))
            }
        }
    }
}

How to Use

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)

Impressions

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.

Summary

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

[swift5] Try to make an API client with various methods
An example of Moya + RxSwift API client (Swift5)
Try to get redmine API key with ruby
NLP4J [006-034b] Try to make an Annotator of 100 language processing knock # 34 "A's B" with NLP4J
How to make an almost static page with rails
Try to make an addition program in several languages
I tried to make an introduction to PHP + MySQL with Docker
Let's write how to make API with SpringBoot + Docker from 0
[Beginner] Try to make a simple RPG game with Java ①
How to make an app using Tensorflow with Android Studio
I tried to make an Android application with MVC now (Java)
Make an FPS counter in Swift
Try to make a simple callback
Make a Christmas tree with swift
Try to imitate marshmallows with MiniMagick
CompletableFuture Getting Started 2 (Try to make CompletableFuture)
Try to make a peepable iterator
I tried to make an automatic backup with pleasanter + PostgreSQL + SSL + docker
[iOS] I tried to make a processing application like Instagram with Swift
I tried to make a Web API that connects to DB with Quarkus
How to convert an array of Strings to an array of objects with the Stream API
How to make an app with a plugin mechanism [C # and Java]
Feign, which implements an API client with just an interface, is very convenient!
How to crop an image with libGDX
Make Ruby Kurosawa with Ruby (Ruby + LINE Messaging API)
I want to make an ios.android app
Try to implement login function with Spring-Boot
Let's make an error screen with Rails
Try creating an iOS library with CocoaPods
Rails6 I want to make an array of values with a check box
Try to make a timeline report of method execution time using JFR API