Last active
August 21, 2021 01:42
-
-
Save andreasmpet/ca943854b922f630a5fb4ce96f9d808f to your computer and use it in GitHub Desktop.
Networking layer example
This file contains 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
struct ExerciseRoutine: Codable { | |
let id: String | |
let templateId: String | |
let name: String | |
} | |
struct ExerciseTemplate: Codable { | |
let id: String | |
let name: String | |
} | |
struct TemplatesRequest: PidgeonRequest { | |
let relativeUrl: String = "/templates" | |
let httpMethod: PidgeonHTTPMethod = .get | |
} | |
struct UploadRequest: PidgeonRequest { | |
let relativeUrl: String = "/exerciseRoutines" | |
let httpMethod: PidgeonHTTPMethod = .post | |
let routine: ExerciseRoutine | |
var body: PidgeonBody? { | |
guard let data = try? JSONEncoder().encode(self.routine) else { | |
return nil | |
} | |
return PidgeonBody.jsonData(data) | |
} | |
} | |
struct ExampleFunctions { | |
let requestExecutor = DefaultRequestExecutor(DefaultPidgeonRequestFactory(baseUrl: URL("https://yourhost.com")!, defaultHeaders: nil)) | |
func fetchTemplates() -> Single<[ExerciseTemplate]> { | |
return self.requestExecutor.rx_perform(request: TemplatesRequest()).map({ (response) -> [ExerciseTemplate] in | |
return try response.decode([ExerciseTemplate].self) | |
}).asSingle() | |
} | |
func store(routine: ExerciseRoutine) -> Completable { | |
return self.requestExecutor.rx_perform(request: UploadRequest(routine: routine)) | |
.asCompletable() | |
} | |
} |
This file contains 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 UIKit | |
import KeychainAccess | |
class KeychainAuthStorage: PidgeonAuthTokenStorage { | |
let service: String | |
let authStorageKey: String | |
private let keychain: Keychain | |
init(service: String, authStorageKey: String) { | |
self.service = service | |
self.authStorageKey = authStorageKey | |
self.keychain = Keychain(service: self.service) | |
} | |
private var hasTriedToReadAuthResultFromKeychain: Bool = false | |
private var _authResult: PidgeonAuthResult? | |
var authResult: PidgeonAuthResult? { | |
set(value) { | |
self._authResult = authResult | |
if let value = value, let serialized = try? JSONEncoder().encode(value) { | |
try? self.keychain.set(serialized, key: self.authStorageKey) | |
} else { | |
try? self.keychain.remove(self.authStorageKey) | |
} | |
} | |
get { | |
if self._authResult != nil || self.hasTriedToReadAuthResultFromKeychain { | |
return self._authResult | |
} | |
guard let data: Data = (try? self.keychain.getData(self.authStorageKey)) ?? nil else { | |
return nil | |
} | |
_authResult = try? JSONDecoder().decode(PidgeonAuthResult.self, from: data) | |
self.hasTriedToReadAuthResultFromKeychain = true | |
return _authResult | |
} | |
} | |
func clearStorage() { | |
self.authResult = nil | |
} | |
} |
This file contains 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 | |
struct PidgeonConfig { | |
static var defaultDecoder: JSONDecoder = JSONDecoder() | |
static var defaultEncoder: JSONEncoder = JSONEncoder() | |
} | |
struct PidgeonResponse { | |
let response: URLResponse | |
let data: Data? | |
} | |
enum PidgeonError: Error { | |
case invalidHttpCode(code: Int) | |
case invalidRequest(request: PidgeonRequest) | |
case invalidResponse(response: URLResponse) | |
case internalInconsistency | |
case noDataToDecode | |
} | |
protocol PidgeonRequestFactory { | |
var baseUrl: URL { get } | |
var defaultHeaders: [String: String]? { get } | |
func createURLRequest(from request: PidgeonRequest) throws -> URLRequest | |
} | |
extension PidgeonRequestFactory { | |
var defaultHeaders: [String: String]? { | |
return nil | |
} | |
func createURLRequest(from request: PidgeonRequest) throws -> URLRequest { | |
guard let url = URL(string: request.relativeUrl, relativeTo: self.baseUrl), | |
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { | |
fatalError("Invalid request URL") | |
} | |
urlComponents.queryItems = request.parameters?.map({ (k,v) in | |
return URLQueryItem(name: k, value: v) | |
}) | |
let urlWithParameters = try! urlComponents.asURL() | |
var urlRequest = URLRequest(url: urlWithParameters, cachePolicy: request.cachePolicy, timeoutInterval: request.timeoutInterval) | |
self.defaultHeaders?.forEach({ (key, value) in | |
urlRequest.setValue(value, forHTTPHeaderField: key) | |
}) | |
urlRequest.httpMethod = request.httpMethod.rawValue | |
// Additional headers take precedence | |
request.additionalHeaders?.forEach({ (key,value) in | |
urlRequest.setValue(value, forHTTPHeaderField: key) | |
}) | |
let body = request.body | |
urlRequest.httpBody = try body?.data() | |
if let contentType = body?.contentType { | |
urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type") | |
} | |
return urlRequest | |
} | |
} | |
struct DefaultPidgeonRequestFactory: PidgeonRequestFactory { | |
let baseUrl: URL | |
let defaultHeaders: [String: String]? | |
} | |
enum PidgeonHTTPMethod: String { | |
case options = "OPTIONS" | |
case get = "GET" | |
case head = "HEAD" | |
case post = "POST" | |
case put = "PUT" | |
case patch = "PATCH" | |
case delete = "DELETE" | |
case trace = "TRACE" | |
case connect = "CONNECT" | |
} | |
typealias PidgeonRequestErrorTransformer = (_ data: Data?, _ error: Error) -> Error? | |
enum PidgeonBody { | |
case jsonData(Data) | |
case json([String: Any]) | |
case data(Data) | |
func data() throws -> Data? { | |
switch self { | |
case .jsonData(let jsonData): | |
return jsonData | |
case .json(let json): | |
return try JSONSerialization.data(withJSONObject: json, options: []) | |
case .data(let data): | |
return data | |
} | |
} | |
var contentType: String? { | |
switch self { | |
case .json(_), .jsonData(_): | |
return "application/json" | |
case .data(_): | |
return "application/octet-stream" | |
} | |
} | |
} | |
protocol PidgeonRequest { | |
var relativeUrl: String { get } | |
var httpMethod: PidgeonHTTPMethod { get } | |
var cachePolicy: URLRequest.CachePolicy { get } | |
var additionalHeaders: [String: String]? { get } | |
var parameters: [String:String]? { get } | |
var body: PidgeonBody? { get } | |
var timeoutInterval: TimeInterval { get } | |
var errorTransformer: PidgeonRequestErrorTransformer? { get } | |
} | |
extension PidgeonRequest { | |
var cachePolicy: URLRequest.CachePolicy { | |
return URLRequest.CachePolicy.useProtocolCachePolicy | |
} | |
var additionalHeaders: [String: String]? { return nil } | |
var parameters: [String:String]? { return nil } | |
var body: PidgeonBody? { return nil } | |
var timeoutInterval: TimeInterval { return 30 } | |
var errorTransformer: PidgeonRequestErrorTransformer? { return nil } | |
} | |
protocol PidgeonRequestTask { | |
func cancelTask() | |
} | |
typealias RequestExecutorPerformCallback = (_ result: PidgeonResult) -> Void | |
protocol PidgeonRequestExecutor { | |
var requestFactory: PidgeonRequestFactory { get } | |
@discardableResult | |
func perform(request: PidgeonRequest, completion: @escaping RequestExecutorPerformCallback) -> PidgeonRequestTask? | |
} | |
enum PidgeonResult { | |
case result(Data?, URLResponse) | |
case error(Error) | |
} | |
extension PidgeonResult { | |
func decode<T: Decodable>(_ type: T.Type, | |
injecting injectedJSON: [String: Any]? = nil, | |
decoder: JSONDecoder = PidgeonConfig.defaultDecoder) throws -> T { | |
switch self { | |
case .error(let error): | |
throw error | |
case .result(let data, _): | |
return try self.decodeData(data: data, type: type, injecting: injectedJSON, decoder: decoder) | |
} | |
} | |
private func decodeData<T: Decodable>(data: Data?, | |
type: T.Type, | |
injecting injectedJSON: [String: Any]?, | |
decoder: JSONDecoder) throws -> T { | |
guard let data = data else { | |
throw PidgeonError.noDataToDecode | |
} | |
guard let injectedJSON = injectedJSON else { | |
return try decoder.decode(type, from: data) | |
} | |
let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) | |
let modifiedJSON: Any = { | |
// Inject injected data into json object | |
if var json = jsonObject as? [String: Any] { | |
injectedJSON.forEach { k in | |
json[k.key] = k.value | |
} | |
return json | |
} else if var jsonArray = jsonObject as? [[String: Any]] { | |
// Inject injected data into every object in the json array | |
jsonArray.enumerated().forEach { i, jsonObj in | |
var mutableJsonObj = jsonObj | |
injectedJSON.forEach { k in | |
mutableJsonObj[k.key] = k.value | |
} | |
jsonArray[i] = mutableJsonObj | |
} | |
return jsonArray | |
} | |
return [:] | |
}() | |
let modifiedData = try JSONSerialization.data(withJSONObject: modifiedJSON, options: []) | |
return try decoder.decode(type, from: modifiedData) | |
} | |
func toJson() throws -> [String: Any]? { | |
switch self { | |
case .error(let error): | |
throw error | |
case .result(let data, _): | |
guard let data = data else { return nil } | |
return try JSONSerialization.jsonObject(with: data) as? [String: Any] | |
} | |
} | |
} | |
extension PidgeonResult { | |
var error: Error? { | |
switch self { | |
case .error(let error): | |
return error | |
default: | |
return nil | |
} | |
} | |
var data: Data? { | |
switch self { | |
case .result(let data, _): | |
return data | |
default: | |
return nil | |
} | |
} | |
} | |
typealias PidgeonAuthTokenProviderFetchCallback = (_ result: PidgeonAuthResult?, _ error: Error?) -> Void | |
protocol PidgeonAuthTokenStorage { | |
var authResult: PidgeonAuthResult? { get set } | |
func clearStorage() | |
} | |
protocol PidgeonAuthTokenProvider { | |
func reauthorize(completion: @escaping PidgeonAuthTokenProviderFetchCallback) | |
func login(withRequest: PidgeonRequest, completion: @escaping PidgeonAuthTokenProviderFetchCallback) | |
var authStorage: PidgeonAuthTokenStorage { get } | |
} | |
typealias PidgeonTokenFetcherBlock = (_ isReauthorizing: Bool, (PidgeonResult) -> Void) -> Void | |
struct PidgeonAuthResult: Codable, Equatable { | |
let timestamp: TimeInterval | |
let accessToken: String | |
let tokenType: String | |
let refreshToken: String | |
let expiresInMsec: Int | |
} | |
extension PidgeonAuthResult { | |
var hasExpired: Bool { | |
let expiryTime = self.timestamp + Double(self.expiresInMsec) / 1000 | |
return Date().timeIntervalSinceNow >= expiryTime | |
} | |
} | |
protocol PidgeonTokenFetcher { | |
func fetchToken(isReauthorizing: Bool, completion: @escaping PidgeonAuthTokenProviderFetchCallback) | |
} | |
typealias AuthRequestFactory = (_ authStorage: PidgeonAuthTokenStorage) -> PidgeonRequest? | |
extension URLSessionDataTask: PidgeonRequestTask { | |
func cancelTask() { | |
self.cancel() | |
} | |
} | |
struct DefaultRequestExecutor: PidgeonRequestExecutor { | |
let requestFactory: PidgeonRequestFactory | |
let urlSession: URLSession | |
init(requestFactory: PidgeonRequestFactory, urlSession: URLSession = URLSession.shared) { | |
self.requestFactory = requestFactory | |
self.urlSession = urlSession | |
} | |
func perform(request: PidgeonRequest, completion: @escaping RequestExecutorPerformCallback) -> PidgeonRequestTask? { | |
let attemptedRequest: URLRequest? = { | |
do { | |
return try self.requestFactory.createURLRequest(from: request) | |
} catch let error { | |
completion(.error(error)) | |
return nil | |
} | |
}() | |
guard let urlRequest = attemptedRequest else { | |
return nil | |
} | |
let task = self.urlSession.dataTask(with: urlRequest) { (data, response, error) in | |
if let error = error { | |
let transformedError = request.errorTransformer?(data, error) ?? error | |
DispatchQueue.main.async { | |
completion(.error(transformedError)) | |
} | |
return | |
} | |
guard let httpResponse = response as? HTTPURLResponse else { | |
DispatchQueue.main.async { | |
completion(.error(PidgeonError.internalInconsistency)) | |
} | |
return | |
} | |
let isValidHttpCode = (200..<300).contains(httpResponse.statusCode) | |
if !isValidHttpCode { | |
DispatchQueue.main.async { | |
completion(.error(PidgeonError.invalidHttpCode(code: httpResponse.statusCode))) | |
} | |
return | |
} | |
DispatchQueue.main.async { | |
completion(.result(data, httpResponse)) | |
} | |
} | |
task.resume() | |
return task | |
} | |
} | |
class DefaultPidgeonAuthTokenProvider: PidgeonAuthTokenProvider { | |
enum ProviderError: Error { | |
case tokenFetchError(Error) | |
case couldNotCreateReauthRequest | |
case parseResponseError(Error) | |
} | |
private(set) var authStorage: PidgeonAuthTokenStorage | |
let reauthRequestFactory: AuthRequestFactory | |
private let authRequestExecutor: PidgeonRequestExecutor | |
private var pendingReauthRequests: [PidgeonAuthTokenProviderFetchCallback] = [] | |
init(authRequestExecutor: PidgeonRequestExecutor, | |
authStorage: PidgeonAuthTokenStorage, | |
reauthRequestFactory: @escaping AuthRequestFactory) { | |
self.authRequestExecutor = authRequestExecutor | |
self.authStorage = authStorage | |
self.reauthRequestFactory = reauthRequestFactory | |
} | |
func login(withRequest request: PidgeonRequest, completion: @escaping PidgeonAuthTokenProviderFetchCallback) { | |
self.fetchAndStoreAuthToken(withRequest: request, completion: completion) | |
} | |
func reauthorize(completion: @escaping PidgeonAuthTokenProviderFetchCallback) { | |
guard let tokenRequest = self.reauthRequestFactory(self.authStorage) else { | |
completion(nil, ProviderError.couldNotCreateReauthRequest) | |
return | |
} | |
self.pendingReauthRequests.append(completion) | |
if self.pendingReauthRequests.count == 1 { | |
self.fetchAndStoreAuthToken(withRequest: tokenRequest, completion: { [weak self] authResult, error in | |
guard let self = self else { return } | |
let pendingRequests = self.pendingReauthRequests | |
self.pendingReauthRequests.removeAll() | |
pendingRequests.forEach({ (callback) in | |
callback(authResult, error) | |
}) | |
}) | |
} | |
} | |
func fetchAndStoreAuthToken(withRequest request: PidgeonRequest, completion: @escaping PidgeonAuthTokenProviderFetchCallback) { | |
self.authRequestExecutor.perform(request: request) { [weak self] (result) in | |
guard let self = self else { return } | |
do { | |
if let error = result.error { | |
completion(nil, error) | |
return | |
} | |
let authResult = try result.decode(PidgeonAuthResult.self, injecting: [ | |
"timestamp": Date().timeIntervalSince1970 | |
]) | |
self.authStorage.authResult = authResult | |
completion(authResult, nil) | |
} catch let error { | |
completion(nil, error) | |
} | |
} | |
} | |
} |
This file contains 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 PidgeonRequestExecutor { | |
func rx_perform(request: PidgeonRequest, shouldCancelOnDispose: Bool = false) -> Single<PidgeonResult> { | |
let factory = { | |
return Single<PidgeonResult>.create(subscribe: { (single) -> Disposable in | |
let task = self.perform(request: request, completion: { result in | |
single(.success(result)) | |
}) | |
return Disposables.create { | |
if shouldCancelOnDispose { | |
task?.cancelTask() | |
} | |
} | |
}) | |
} | |
return Single.deferred(factory) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment