Last active
April 30, 2020 23:32
-
-
Save DanielCardonaRojas/2e0eccf1027d82c9f0273e6cd7b896d1 to your computer and use it in GitHub Desktop.
Custom API Client Abstractions inspired by http://kean.github.io/post/api-client but with no third party dependencies. As a lib over here -> https://github.com/DanielCardonaRojas/APIClient
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import PromiseKit | |
extension APIClient { | |
func request<Response, T>(_ requestConvertible: T, | |
additionalHeaders headers: [String: String]? = nil, | |
additionalQuery queryParameters: [String: String]? = nil, | |
baseUrl: URL? = nil) -> Promise<T.Result> | |
where T: URLResponseCapable, T: URLRequestConvertible, T.Result == Response { | |
return Promise { seal in | |
self.request(requestConvertible, additionalHeaders: headers, additionalQuery: queryParameters, success: { response in | |
seal.fulfill(response) | |
}, fail: { error in | |
seal.reject(error) | |
}) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import RxSwift | |
extension APIClient { | |
func request<Response, T>(_ requestConvertible: T, | |
additionalHeaders headers: [String: String]? = nil, | |
additionalQuery queryParameters: [String: String]? = nil, | |
baseUrl: URL? = nil) -> Observable<T.Result> | |
where T: URLResponseCapable, T: URLRequestConvertible, T.Result == Response { | |
return Observable.create({ observer in | |
let dataTask = self.request(requestConvertible, additionalHeaders: headers, additionalQuery: queryParameters, baseUrl: baseUrl, success: { response in | |
observer.onNext(response) | |
observer.onCompleted() | |
}, fail: {error in | |
observer.onError(error) | |
}) | |
return Disposables.create { | |
dataTask?.cancel() | |
} | |
}) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
protocol URLRequestConvertible { | |
func asURLRequest(baseURL: URL) throws -> URLRequest | |
} | |
protocol URLResponseCapable { | |
associatedtype Result | |
func handle(data: Data) throws -> Result | |
} | |
class APIClient { | |
private var baseURL: URL? | |
lazy var session: URLSession = { | |
return URLSession(configuration: .default) | |
}() | |
init(baseURL: String, configuration: URLSessionConfiguration? = nil) { | |
if let config = configuration { | |
self.session = URLSession(configuration: config) | |
} | |
self.baseURL = URL(string: baseURL) | |
} | |
@discardableResult | |
func request<Response, T>(_ requestConvertible: T, | |
additionalHeaders headers: [String: String]? = nil, | |
additionalQuery queryParameters: [String: String]? = nil, | |
baseUrl: URL? = nil, | |
success: @escaping (Response) -> Void, | |
fail: @escaping (Error) -> Void) -> URLSessionDataTask? | |
where T: URLResponseCapable, T: URLRequestConvertible, T.Result == Response { | |
guard let base = baseUrl ?? self.baseURL else { | |
return nil | |
} | |
do { | |
var httpRequest = try requestConvertible.asURLRequest(baseURL: base) | |
let additionalQueryItems = queryParameters?.map({ (k, v) in URLQueryItem(name: k, value: v) }) ?? [] | |
httpRequest.allHTTPHeaderFields = headers | |
httpRequest.addQueryItems(additionalQueryItems) | |
let task: URLSessionDataTask = session.dataTask(with: httpRequest) { (data: Data?, response: URLResponse?, error: Error?) in | |
if let data = data { | |
do { | |
let parsedResponse = try requestConvertible.handle(data: data) | |
success(parsedResponse) | |
} catch (let parsingError) { | |
fail(parsingError) | |
} | |
} else if let error = error { | |
fail(error) | |
} | |
} | |
task.resume() | |
return task | |
} catch(let encodingError) { | |
fail(encodingError) | |
} | |
return nil | |
} | |
} | |
extension URLRequest { | |
mutating func addQueryItems(_ items: [URLQueryItem]) { | |
guard let url = self.url, items.count > 0 else { | |
return | |
} | |
var cmps = URLComponents(string: url.absoluteString) | |
let currentItems = cmps?.queryItems ?? [] | |
cmps?.queryItems = currentItems + items | |
self.url = cmps?.url | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
final class Endpoint<Response>: CustomStringConvertible, CustomDebugStringConvertible { | |
let method: Method | |
let path: Path | |
private (set) var parameters: MixedLocationParams = [:] | |
let decode: (Data) throws -> Response | |
let encoding: ParameterEncoding | |
var description: String { | |
return "Endpoint \(method.rawValue) \(path) expecting: \(Response.self)" | |
} | |
var debugDescription: String { | |
let params = parameters.map({ (k, v) in "\(k.rawValue): \(v)" }).joined(separator: "|") | |
return self.description + " \(params)" | |
} | |
init(method: Method = .get, | |
path: Path, | |
parameters: MixedLocationParams, | |
encoding: ParameterEncoding = .methodDependent, | |
decode: @escaping (Data) throws -> Response) { | |
self.method = method | |
self.path = path.hasPrefix("/") ? path : "/" + path | |
self.parameters = parameters | |
self.decode = decode | |
self.encoding = encoding | |
} | |
init(method: Method = .get, | |
path: Path, | |
parameters: Parameters? = nil, | |
encoding: ParameterEncoding = .methodDependent, | |
decode: @escaping (Data) throws -> Response) { | |
self.method = method | |
self.path = path.hasPrefix("/") ? path : "/" + path | |
self.decode = decode | |
self.encoding = encoding | |
if let params = parameters { | |
self.addParameters(params) | |
} | |
} | |
func addParameters(_ params: Parameters, location: ParameterEncoding.Location? = nil) { | |
let loc = location ?? ParameterEncoding.Location.defaultLocation(for: self.method) | |
if let currentParams = parameters[loc] { | |
let updated = currentParams.merging(params, uniquingKeysWith: { (k1, k2) in k1 }) | |
self.parameters[loc] = updated | |
} else { | |
self.parameters[loc] = params | |
} | |
} | |
func map<N>(_ f: @escaping ((Response) throws -> N)) -> Endpoint<N> { | |
let newDecodingFuntion: (Data) throws -> N = { data in | |
return try f(self.decode(data)) | |
} | |
return Endpoint<N>(method: self.method, path: self.path, parameters: self.parameters, encoding: self.encoding, decode: newDecodingFuntion) | |
} | |
} | |
// MARK: - URLRequestConvertible | |
extension Endpoint: URLResponseCapable { | |
typealias Result = Response | |
func handle(data: Data) throws -> Response { | |
return try self.decode(data) | |
} | |
} | |
extension Endpoint: URLRequestConvertible { | |
func asURLRequest(baseURL: URL) throws -> URLRequest { | |
var urlComponents = URLComponents(string: baseURL.absoluteString) | |
let path = urlComponents.map { $0.path + self.path } ?? self.path | |
urlComponents?.path = path | |
let bodyEncoding = encoding.bodyEncoding | |
let bodyParameters = parameters[.httpBody] | |
let queryParameters = parameters[.queryString] | |
if let queryParams = queryParameters as? [String: String] { | |
let queryItems = queryParams.map({ (k, v) in URLQueryItem(name: k, value: v) }) | |
urlComponents?.queryItems = queryItems | |
} | |
var request = URLRequest(url: urlComponents!.url!) | |
request.httpMethod = method.rawValue | |
if let contentType = bodyEncoding.contentType { | |
request.setValue(contentType, forHTTPHeaderField: "Content-Type") | |
} | |
if let params = bodyParameters, bodyEncoding == .jsonEncoded { | |
let data = try JSONSerialization.data(withJSONObject: params as Any, options: []) | |
request.httpBody = data | |
} else if let params = bodyParameters as? [String: String], bodyEncoding == .formUrlEncoded { | |
let formUrlData: String? = params.map { (k, v) in | |
let escapedKey = k.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? k | |
let escapedValue = v.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? v | |
return "\(escapedKey)=\(escapedValue)" | |
}.joined(separator: "&") | |
request.httpBody = formUrlData?.data(using: .utf8) | |
} | |
return request | |
} | |
} | |
// MARK: - Conviniences | |
extension Endpoint where Response: Swift.Decodable { | |
convenience init(method: Method = .get, path: Path, parameters: Parameters? = nil, encoding: ParameterEncoding = .methodDependent) { | |
self.init(method: method, path: path, parameters: parameters) { | |
try JSONDecoder().decode(Response.self, from: $0) | |
} | |
} | |
} | |
extension Endpoint where Response == Void { | |
convenience init(method: Method = .get, path: Path, parameters: Parameters? = nil, encoding: ParameterEncoding = .methodDependent) { | |
self.init( method: method, path: path, parameters: parameters, decode: { _ in () }) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
typealias Parameters = [String: Any] | |
typealias MixedLocationParams = [ParameterEncoding.Location: Parameters] | |
typealias Path = String | |
enum Method: String { | |
case get = "GET", post = "POST", put = "PUT", patch = "PATCH", delete = "DELETE" | |
} | |
struct ParameterEncoding { | |
enum Location: String { | |
case queryString, httpBody | |
static func defaultLocation(for method: Method) -> Location { | |
switch method { | |
case .get: | |
return .queryString | |
default: | |
return .httpBody | |
} | |
} | |
} | |
enum BodyEncoding { | |
case formUrlEncoded, jsonEncoded | |
var contentType: String? { | |
switch self { | |
case .formUrlEncoded: | |
return "application/x-www-form-urlencoded; charset=utf-8" | |
case .jsonEncoded: | |
return "application/json; charset=UTF-8" | |
} | |
} | |
} | |
let location: Location? | |
let bodyEncoding: BodyEncoding | |
init(preferredBodyEncoding: BodyEncoding = .jsonEncoded, location: Location? = nil) { | |
self.location = location | |
self.bodyEncoding = preferredBodyEncoding | |
} | |
static func preferredBodyEncoding(_ encoding: BodyEncoding) -> ParameterEncoding { | |
return ParameterEncoding(preferredBodyEncoding: encoding, location: nil) | |
} | |
static let methodDependent = ParameterEncoding(preferredBodyEncoding: .jsonEncoded, location: nil) | |
} |
Thanks @overlord21,
I'm actually not sure how to implement this but it does sound like a very interesting feature. I suppose one approach would be to use the facilities of either PromiseKit or RxSwift to chain a an additional request.
But I'm not sure how to go about the retrier. Doing a quick search I found this which could be useful:
https://medium.com/ios-os-x-development/retry-in-the-wild-8154042ae207
By the way, I've actually put all the code above under a small framework.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hi,
I found this to be really helpful. The one thing I could not get my hands on when trying to avoid adding dependencies like Alamofire was the the request retrier and global assignment/refresh of token header in all requests with only NSURLSession.
Would you have some example or source for such use case?