Created
November 16, 2017 21:16
-
-
Save matux/29208d18e365f0d856c9a45407583536 to your computer and use it in GitHub Desktop.
Network request deferring system that facilitates offline support while offering a simple interface.
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 | |
| import LambdaKit | |
| private let kDefaultIntervalRate: TimeInterval = 5.0 | |
| /// An interface defining a value that can be requested as a URL. | |
| public protocol Routable { | |
| /// The URL String for the route | |
| var urlString: String { get } | |
| /// Returns the extra HTTP headers needed for this Router. Authentication should be included here as well | |
| /// as API versioning. | |
| /// | |
| /// - returns: A dictionary of key, values to append to the HTTP request headers. | |
| func extraHTTPHeaders() -> [String: String] | |
| } | |
| extension Routable { | |
| public func extraHTTPHeaders() -> [String: String] { return [:] } | |
| } | |
| extension URL: Routable { | |
| public var urlString: String { | |
| return self.absoluteString | |
| } | |
| } | |
| /// Used to check if a request should be deferred or not based on its response. | |
| /// | |
| /// - parameter responseStatus: The response status type (see ResponseType for the list). | |
| /// | |
| /// - returns: A boolean indicating if the request should be deferred or not. | |
| public typealias ShouldDeferRequestClosureType = (_ responseStatus: ResponseType) -> Bool | |
| /// Describes a Request that can be deferred by the RequestDeferrer. | |
| public struct DeferrableRequest { | |
| typealias Persistable = [DeferrableRequest] | |
| /// API Route for this request. | |
| public let route: Routable | |
| /// HTTPMethod for this request. | |
| public let method: HTTPMethod | |
| /// Parameters for this request. | |
| public let parameters: [String: Any]? | |
| /// Closure to add extra parameters before sending the request. | |
| public let extraParameters: (() -> [String: Any]?)? | |
| /// Completion closure to be called when the request is sent. Note that this closure won't be called if | |
| /// the request gets deferred again due to an error matching what's specified in `shouldDeferRequest`. | |
| public let completion: HTTPCompletionClosureType? | |
| /// Creates a new DeferrableRequest. | |
| /// | |
| /// - parameter route: API Route for this request. | |
| /// - parameter method: HTTPMethod for this request. | |
| /// - parameter parameters: Parameters for this request. | |
| /// - parameter extraParameters: Closure to add extra parameters before sending the request. | |
| /// - parameter completion: Completion closure to be called when the request is sent. | |
| /// | |
| /// - returns: New instance of DeferrableRequest. | |
| public init(route: Routable, method: HTTPMethod, parameters: [String: Any]?, | |
| extraParameters: (() -> [String: Any]?)?, completion: HTTPCompletionClosureType?) | |
| { | |
| self.route = route | |
| self.method = method | |
| self.parameters = parameters | |
| self.extraParameters = extraParameters | |
| self.completion = completion | |
| } | |
| } | |
| /// Defers `HTTPRequests` to be sent at different intervals until certain conditions have been met. | |
| public final class RequestDeferrer: Persister { | |
| typealias Persistable = [DeferrableRequest] | |
| /// Singleton impl. RequestDeferrer will always be persistent throughout the entire app life. | |
| public static let sharedDeferrer = RequestDeferrer(identifier: "shared") | |
| fileprivate let identifier: String | |
| private var isConsumingQueue = false | |
| private var deferredRequests = [DeferrableRequest]() { | |
| didSet { self.store(self.deferredRequests) } | |
| } | |
| /// `HTTPClient` we want this `RequestDeferrer` dispatching requests to. | |
| public weak var client: HTTPClient? | |
| /// Interval rate at which we try to send the deferred requests in seconds. | |
| public var intervalRate: TimeInterval = kDefaultIntervalRate | |
| /// Whether or not the manager has deferred requests on its queue | |
| public var hasDeferredRequests: Bool { | |
| return !self.deferredRequests.isEmpty | |
| } | |
| /// Used to check if a request should be deferred or not based on its response. Defaults to `false`, | |
| /// ie. unless assigned, requests will never be deferred. | |
| public var shouldDeferRequest: ShouldDeferRequestClosureType = { _ in | |
| return false | |
| } | |
| /// Creates a new RequestDeferrer. | |
| /// | |
| /// - parameter identifier: An identifier is necessary to keep persisted requests collections from | |
| /// colliding. | |
| /// | |
| /// - returns: An instance of RequestDeferrer. | |
| public init(identifier: String) { | |
| self.identifier = identifier | |
| self.deferredRequests = self.fetch() | |
| } | |
| /// Defers a `DeferrableRequest` to be dispatched at a later point. | |
| /// | |
| /// - parameter request: Request you want to defer. | |
| public func deferRequest(_ request: DeferrableRequest) { | |
| self.deferredRequests.append(request) | |
| self.performNextRequest() | |
| } | |
| /// Start consuming requests from the deferred queue. | |
| public func start() { | |
| self.performNextRequest() | |
| } | |
| /// Clears the deferred request queue, cancels the queue interval timer and removes any persisted | |
| /// requests. | |
| public func purge() { | |
| self.deferredRequests = [] | |
| self.isConsumingQueue = false | |
| } | |
| // MARK: Private functions | |
| private func performNextRequest() { | |
| guard !self.isConsumingQueue, let request = self.deferredRequests.first else { | |
| return | |
| } | |
| let errorHandler: HTTPErrorClosureType = { [weak self] status in | |
| // Don't let the HTTPClient handle the error if we want to keep deferring based on the status. | |
| return self?.shouldDeferRequest(status) == true | |
| } | |
| let parameters = self.parameters(forRequest: request, extraParameters: request.extraParameters?()) | |
| self.isConsumingQueue = true | |
| self.client?.request(request.method, request.route, parameters: parameters, | |
| error: errorHandler, completion: { response, status in | |
| self.isConsumingQueue = false | |
| if self.shouldDeferRequest(status) { | |
| executeAfter(self.intervalRate, closure: self.performNextRequest) | |
| return | |
| } | |
| self.deferredRequests.removeFirst() | |
| request.completion?(response, status) | |
| if self.hasDeferredRequests { | |
| self.performNextRequest() | |
| } | |
| }) | |
| } | |
| fileprivate func parameters(forRequest request: DeferrableRequest, | |
| extraParameters: [String: Any]?) -> [String: Any]? | |
| { | |
| var parameters = request.parameters | |
| parameters?["deferred"] = true | |
| guard let extraParameters = extraParameters else { | |
| return parameters | |
| } | |
| return parameters?.updated(extraParameters) | |
| } | |
| } | |
| // MARK: - Persistence layer | |
| private protocol Persister { | |
| associatedtype Persistable | |
| var storageURL: URL? { get } | |
| func store(_ persistable: Persistable) | |
| func fetch() -> Persistable | |
| } | |
| private extension Persister where Self: RequestDeferrer { | |
| var storageURL: URL? { | |
| do { | |
| let documentURL = try FileManager.default.url(for: .documentDirectory, | |
| in: .userDomainMask, appropriateFor: nil, create: true) | |
| let subpath = "requests/\(self.identifier)_deferred.plist" | |
| return documentURL.appendingPathComponent(subpath) | |
| } catch { | |
| Logger.logError("Cannot fetch .DocumentDirectory: \(error)") | |
| return nil | |
| } | |
| } | |
| func store(_ persistable: [DeferrableRequest]) { | |
| guard let storageURL = self.storageURL else { | |
| return | |
| } | |
| let storagePath = storageURL.path | |
| do { | |
| let storageDirectory = storageURL.deletingLastPathComponent() | |
| try FileManager.default.createDirectory(at: storageDirectory, | |
| withIntermediateDirectories: true, attributes: nil) | |
| } catch { | |
| Logger.logError("Cannot create storage directory: \(error)") | |
| return | |
| } | |
| let requests = persistable.map { request in | |
| return [ | |
| "path": request.route.urlString, | |
| "method": request.method.rawValue, | |
| "parameters": request.parameters ?? [:], | |
| "extraHTTPHeaders": request.route.extraHTTPHeaders(), | |
| ] | |
| } | |
| NSKeyedArchiver.archiveRootObject(requests, toFile: storagePath) | |
| } | |
| func fetch() -> [DeferrableRequest] { | |
| let unarchivedObject = NSKeyedUnarchiver.unarchiveObject(withFile: self.storageURL?.path ?? "") | |
| guard let requestDictionaries = unarchivedObject as? [[String: Any]] else { | |
| return [] | |
| } | |
| return requestDictionaries.flatMap { self.deferrableRequest(fromDictionary: $0) } | |
| } | |
| // MARK: - Private functions | |
| private func deferrableRequest(fromDictionary request: [String: Any]) -> DeferrableRequest? { | |
| guard let path = request["path"] as? String, | |
| let method = (request["method"] as? String).flatMap({ HTTPMethod(rawValue: $0) }), | |
| let parameters = request["parameters"] as? [String: Any], | |
| let extraHTTPHeaders = request["extraHTTPHeaders"] as? [String: String] else | |
| { | |
| return nil | |
| } | |
| let route = URLRoute(urlString: path, additionalHTTPHeaders: extraHTTPHeaders) | |
| return DeferrableRequest(route: route, method: method, parameters: parameters, extraParameters: nil, | |
| completion: nil) | |
| } | |
| } | |
| private struct URLRoute: Routable { | |
| fileprivate let urlString: String | |
| fileprivate let additionalHTTPHeaders: [String: String] | |
| fileprivate func extraHTTPHeaders() -> [String: String] { | |
| return self.additionalHTTPHeaders | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment