Cet article est un article pour essayer de créer un client API de différentes manières. (Laissez le titre) En guise de motivation, j'ai voulu écrire sur l'existence d'un framework asynchrone et comment la différence entre Rx et Combine apparaît dans la partie communication. Alors, créez un client API minimum avec les 3 modèles suivants.
Les demandes sont courantes et utilisent la même chose. C'est un point de terminaison pour obtenir des informations utilisateur de l'API github.
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)
}
}
Passez la fermeture et exécutez après la communication. C'est un style d'écriture courant. Dans le cas d'un projet qui n'introduit pas de framework asynchrone tel que Combine, cela ressemble partout.
②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()
}
Lorsque vous recevez le résultat observable après la communication, transmettez la valeur à Behavior Relay. Vous pouvez écrire à peu près la même chose que l'implémentation qui passe la clôture de l'achèvement à la demande. Après cela, si vous observez le relais, il semble que vous puissiez facilement implémenter le rappel vers ViewModel et View. Sans Rx, la partie où le résultat est surveillé par didSet et la fermeture est implémentée par la fermeture est déclarative.
③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 est un éditeur qui ne publie des valeurs qu'une seule fois, et cela ressemble à celui de RxSwift. Référence: Remplacer RxSwift Single par Combine-Qiita
Combine vous permet de définir le type d'erreur dans les génériques afin que vous n'ayez pas à convertir l'erreur. De plus, Future peut prendre le type Result comme argument avec la fermeture de fin de .init.
public typealias Promise = (Result<Output, Failure>) -> Void
public init(_ attemptToFulfill: @escaping (@escaping Future<Output, Failure>.Promise) -> Void)
Donc, il est bon de pouvoir gérer simplement l'achèvement avec promise (.success (entity))
.
Les valeurs émises par Future sont reçues par think et gérées par recieveValue
et receiveCompletion
.
Personnellement, je me sens mal à l'aise que le traitement des valeurs et le traitement des résultats soient séparés.
(Utiliser Never = S'il n'y a pas d'échec, vous ne pouvez utiliser que receiveValue
think.)
Comme l'implémentation qui passe la fermeture du type de résultat à la fin, ce serait bien si la réponse pouvait également être reçue avec la valeur associée du cas de réussite, mais lors de l'utilisation de combine, cela semble être strict car il est nécessaire d'utiliser le think ci-dessus.
Quelle que soit la méthode d'écriture adoptée, le client API lui-même semblait similaire. Donc même si vous décidez d'introduire un framework asynchrone depuis le milieu, il semble que vous n'aurez pas peur de la partie communication. (Au contraire, il peut être nécessaire de passer activement d'un client API proche du modèle)
Recommended Posts