From 70eeae1345872e2c8db824e3e36f63ebd53b302e Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sat, 2 Nov 2019 11:25:44 -0400 Subject: [PATCH 01/31] Ensure authorization callback only after response --- Sources/ZamzamLocation/LocationWorker.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/ZamzamLocation/LocationWorker.swift b/Sources/ZamzamLocation/LocationWorker.swift index aab80ef5..a1db218a 100644 --- a/Sources/ZamzamLocation/LocationWorker.swift +++ b/Sources/ZamzamLocation/LocationWorker.swift @@ -250,6 +250,8 @@ public extension LocationWorker { extension LocationWorker: CLLocationManagerDelegate { public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + guard status != .notDetermined else { return } + // Trigger and empty queues let recurringHandlers = self.didChangeAuthorizationHandlers.value recurringHandlers.forEach { task in From 1a793f92778c7ea0e2c5b91fd990e3db9ffd6f8e Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Mon, 4 Nov 2019 21:51:44 -0500 Subject: [PATCH 02/31] Add none log level --- Sources/ZamzamCore/Logging/LogAPI.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/ZamzamCore/Logging/LogAPI.swift b/Sources/ZamzamCore/Logging/LogAPI.swift index 3e9ccee9..d4c2cca9 100644 --- a/Sources/ZamzamCore/Logging/LogAPI.swift +++ b/Sources/ZamzamCore/Logging/LogAPI.swift @@ -109,6 +109,9 @@ public extension LogAPI { case warning case error + /// Disables a log store when used as minimum level + case none = 99 + public static func < (lhs: Level, rhs: Level) -> Bool { lhs.rawValue < rhs.rawValue } From 1dbe4ef76169f2bd6bbadbbe58465fcfcb2c04a2 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sat, 9 Nov 2019 19:57:44 -0500 Subject: [PATCH 03/31] Refactor logger --- Sources/ZamzamCore/Logging/LogAPI.swift | 155 +++++++++++------- Sources/ZamzamCore/Logging/LogWorker.swift | 29 ++-- .../Logging/Stores/LogConsoleStore.swift | 52 +++--- .../Logging/Stores/LogOSStore.swift | 46 +++--- 4 files changed, 150 insertions(+), 132 deletions(-) diff --git a/Sources/ZamzamCore/Logging/LogAPI.swift b/Sources/ZamzamCore/Logging/LogAPI.swift index d4c2cca9..f5887281 100644 --- a/Sources/ZamzamCore/Logging/LogAPI.swift +++ b/Sources/ZamzamCore/Logging/LogAPI.swift @@ -13,88 +13,131 @@ public enum LogAPI {} public protocol LogStore: AppInfo { - /** - Log something generally unimportant (lowest priority; not written to file) - - - parameter message: Description of the log. - - parameter includeMeta: If true, will append the meta data to the log. - - parameter path: Path of the caller. - - parameter function: Function of the caller. - - parameter line: Line of the caller. - */ + /// The minimum level required to create log entries. + var minLevel: LogAPI.Level { get } + + /// Log an entry to the destination. + /// - Parameters: + /// - level: The current level of the log entry. + /// - message: Description of the log. + /// - path: Path of the caller. + /// - function: Function of the caller. + /// - line: Line of the caller. + /// - context: Additional meta data. + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) + + /// Returns if the logger should process the entry for the specified log level. + func canWrite(for level: LogAPI.Level) -> Bool + + /// The output of the message and supporting information. + /// - Parameters: + /// - message: Description of the log. + /// - path: Path of the caller. + /// - function: Function of the caller. + /// - line: Line of the caller. + /// - context: Additional meta data. + func format(_ message: String, _ path: String, _ function: String, _ line: Int, _ context: [String: Any]?) -> String +} + +public extension LogStore { + + func canWrite(for level: LogAPI.Level) -> Bool { + minLevel <= level + } + + func format(_ message: String, _ path: String, _ function: String, _ line: Int, _ context: [String: Any]?) -> String { + "\(URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent).\(function):\(line) - \(message)" + } +} + +public protocol LogWorkerType { + + /// Log something generally unimportant (lowest priority; not written to file) + /// - Parameters: + /// - level: The current level of the log entry. + /// - message: Description of the log. + /// - path: Path of the caller. + /// - function: Function of the caller. + /// - line: Line of the caller. + /// - context: Additional meta data. func verbose(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) - /** - Log something which help during debugging (low priority; not written to file) - - - parameter message: Description of the log. - - parameter includeMeta: If true, will append the meta data to the log. - - parameter path: Path of the caller. - - parameter function: Function of the caller. - - parameter line: Line of the caller. - */ + /// Log something which help during debugging (low priority; not written to file) + /// - Parameters: + /// - level: The current level of the log entry. + /// - message: Description of the log. + /// - path: Path of the caller. + /// - function: Function of the caller. + /// - line: Line of the caller. + /// - context: Additional meta data. func debug(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) - /** - Log something which you are really interested but which is not an issue or error (normal priority) - - - parameter message: Description of the log. - - parameter includeMeta: If true, will append the meta data to the log. - - parameter path: Path of the caller. - - parameter function: Function of the caller. - - parameter line: Line of the caller. - */ + /// Log something which you are really interested but which is not an issue or error (normal priority) + /// - Parameters: + /// - level: The current level of the log entry. + /// - message: Description of the log. + /// - path: Path of the caller. + /// - function: Function of the caller. + /// - line: Line of the caller. + /// - context: Additional meta data. func info(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) - /** - Log something which may cause big trouble soon (high priority) - - - parameter message: Description of the log. - - parameter includeMeta: If true, will append the meta data to the log. - - parameter path: Path of the caller. - - parameter function: Function of the caller. - - parameter line: Line of the caller. - */ + /// Log something which may cause big trouble soon (high priority) + /// - Parameters: + /// - level: The current level of the log entry. + /// - message: Description of the log. + /// - path: Path of the caller. + /// - function: Function of the caller. + /// - line: Line of the caller. + /// - context: Additional meta data. func warning(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) - /** - Log something which will keep you awake at night (highest priority) - - - parameter message: Description of the log. - - parameter includeMeta: If true, will append the meta data to the log. - - parameter path: Path of the caller. - - parameter function: Function of the caller. - - parameter line: Line of the caller. - */ + /// Log something which will keep you awake at night (highest priority) + /// - Parameters: + /// - level: The current level of the log entry. + /// - message: Description of the log. + /// - path: Path of the caller. + /// - function: Function of the caller. + /// - line: Line of the caller. + /// - context: Additional meta data. func error(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) + + /// Log an entry to the destination. + /// - Parameters: + /// - level: The current level of the log entry. + /// - message: Description of the log. + /// - path: Path of the caller. + /// - function: Function of the caller. + /// - line: Line of the caller. + /// - context: Additional meta data. + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) } -public protocol LogWorkerType: LogStore {} public extension LogWorkerType { /// Log something generally unimportant (lowest priority; not written to file) - func verbose(_ message: String, path: String = #file, function: String = #function, line: Int = #line) { - verbose(message, path: path, function: function, line: line, context: nil) + func verbose(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { + write(.verbose, with: message, path: path, function: function, line: line, context: context) } /// Log something which help during debugging (low priority; not written to file) - func debug(_ message: String, path: String = #file, function: String = #function, line: Int = #line) { - debug(message, path: path, function: function, line: line, context: nil) + func debug(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { + write(.debug, with: message, path: path, function: function, line: line, context: context) } /// Log something which you are really interested but which is not an issue or error (normal priority) - func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line) { - info(message, path: path, function: function, line: line, context: nil) + func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { + write(.info, with: message, path: path, function: function, line: line, context: context) } /// Log something which may cause big trouble soon (high priority) - func warn(_ message: String, path: String = #file, function: String = #function, line: Int = #line) { - warning(message, path: path, function: function, line: line, context: nil) + func warning(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { + write(.warning, with: message, path: path, function: function, line: line, context: context) } /// Log something which will keep you awake at night (highest priority) - func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line) { - error(message, path: path, function: function, line: line, context: nil) + func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { + write(.error, with: message, path: path, function: function, line: line, context: context) } } diff --git a/Sources/ZamzamCore/Logging/LogWorker.swift b/Sources/ZamzamCore/Logging/LogWorker.swift index db61034c..a46d1847 100644 --- a/Sources/ZamzamCore/Logging/LogWorker.swift +++ b/Sources/ZamzamCore/Logging/LogWorker.swift @@ -10,6 +10,7 @@ import Foundation public struct LogWorker: LogWorkerType { private let stores: [LogStore] + private let queue = DispatchQueue(label: "io.zamzam.LogWorker", qos: .utility) public init(stores: [LogStore]) { self.stores = stores @@ -18,23 +19,15 @@ public struct LogWorker: LogWorkerType { public extension LogWorker { - func verbose(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) { - stores.forEach { $0.verbose(message, path: path, function: function, line: line, context: context) } - } - - func debug(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) { - stores.forEach { $0.debug(message, path: path, function: function, line: line, context: context) } - } - - func info(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) { - stores.forEach { $0.info(message, path: path, function: function, line: line, context: context) } - } - - func warning(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) { - stores.forEach { $0.warning(message, path: path, function: function, line: line, context: context) } - } - - func error(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) { - stores.forEach { $0.error(message, path: path, function: function, line: line, context: context) } + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) { + // Skip if does not meet minimum log level + let destinations = stores.filter { $0.canWrite(for: level) } + guard !destinations.isEmpty else { return } + + queue.async { + destinations.forEach { + $0.write(level, with: message, path: path, function: function, line: line, context: context) + } + } } } diff --git a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift index 96ea1a41..5bdaf218 100644 --- a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift +++ b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift @@ -9,8 +9,7 @@ import Foundation /// Sends a message to the IDE console. public struct LogConsoleStore: LogStore { - private let minLevel: LogAPI.Level - private let queue = DispatchQueue(label: "io.zamzam.LogConsoleStore", qos: .utility) + public let minLevel: LogAPI.Level public init(minLevel: LogAPI.Level) { self.minLevel = minLevel @@ -19,35 +18,24 @@ public struct LogConsoleStore: LogStore { public extension LogConsoleStore { - func verbose(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .verbose else { return } - queue.async { print("💜 VERBOSE \(self.output(message, path, function, line, context))") } - } - - func debug(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .debug else { return } - queue.async { print("💚 DEBUG \(self.output(message, path, function, line, context))") } - } - - func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .info else { return } - queue.async { print("💙 INFO \(self.output(message, path, function, line, context))") } - } - - func warning(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .warning else { return } - queue.async { print("💛 WARNING \(self.output(message, path, function, line, context))") } - } - - func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .error else { return } - queue.async { print("❤️ ERROR \(self.output(message, path, function, line, context))") } - } -} - -private extension LogConsoleStore { - - func output(_ message: String, _ path: String, _ function: String, _ line: Int, _ context: [String: Any]?) -> String { - "\(URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent).\(function):\(line) - \(message)" + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) { + let prefix: String + + switch level { + case .verbose: + prefix = "💜 VERBOSE" + case .debug: + prefix = "💚 DEBUG" + case .info: + prefix = "💙 INFO" + case .warning: + prefix = "💛 WARNING" + case .error: + prefix = "❤️ ERROR" + case .none: + return + } + + print("\(prefix) \(format(message, path, function, line, context))") } } diff --git a/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift b/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift index c59bf7c8..8de1b36c 100644 --- a/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift +++ b/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift @@ -10,13 +10,11 @@ import os /// Sends a message to the logging system, optionally specifying a custom log object, log level, and any message format arguments. public struct LogOSStore: LogStore { - private let minLevel: LogAPI.Level + public let minLevel: LogAPI.Level private let subsystem: String private let category: String private let log: OSLog - private let queue = DispatchQueue(label: "io.zamzam.LogOSStore", qos: .utility) - public init(minLevel: LogAPI.Level, subsystem: String, category: String) { self.minLevel = minLevel self.subsystem = subsystem @@ -27,28 +25,24 @@ public struct LogOSStore: LogStore { public extension LogOSStore { - func verbose(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .verbose else { return } - queue.async { os_log("%@", log: self.log, type: .debug, message) } - } - - func debug(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .debug else { return } - queue.async { os_log("%@", log: self.log, type: .debug, message) } - } - - func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .info else { return } - queue.async { os_log("%@", log: self.log, type: .info, message) } - } - - func warning(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .warning else { return } - queue.async { os_log("%@", log: self.log, type: .default, message) } - } - - func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - guard minLevel <= .error else { return } - queue.async { os_log("%@", log: self.log, type: .error, message) } + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) { + let type: OSLogType + + switch level { + case .verbose: + type = .debug + case .debug: + type = .debug + case .info: + type = .info + case .warning: + type = .default + case .error: + type = .error + case .none: + return + } + + os_log("%@", log: log, type: type, message) } } From 642e09a21495b14af8aae7b678399150f132a316 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 10 Nov 2019 08:57:42 -0500 Subject: [PATCH 04/31] Add logger tests --- Sources/ZamzamCore/Logging/LogAPI.swift | 56 +++--- Sources/ZamzamCore/Logging/LogWorker.swift | 18 +- .../Logging/Stores/LogConsoleStore.swift | 4 +- .../Logging/Stores/LogOSStore.swift | 4 +- Tests/ZamzamCoreTests/LoggingTests.swift | 162 ++++++++++++++++++ 5 files changed, 211 insertions(+), 33 deletions(-) create mode 100644 Tests/ZamzamCoreTests/LoggingTests.swift diff --git a/Sources/ZamzamCore/Logging/LogAPI.swift b/Sources/ZamzamCore/Logging/LogAPI.swift index f5887281..f0dbfd4e 100644 --- a/Sources/ZamzamCore/Logging/LogAPI.swift +++ b/Sources/ZamzamCore/Logging/LogAPI.swift @@ -1,5 +1,5 @@ // -// Loggable.swift +// LogAPI.swift // ZamzamCore // // Created by Basem Emara on 2019-06-11. @@ -42,7 +42,7 @@ public protocol LogStore: AppInfo { public extension LogStore { func canWrite(for level: LogAPI.Level) -> Bool { - minLevel <= level + minLevel <= level && level != .none } func format(_ message: String, _ path: String, _ function: String, _ line: Int, _ context: [String: Any]?) -> String { @@ -52,7 +52,7 @@ public extension LogStore { public protocol LogWorkerType { - /// Log something generally unimportant (lowest priority; not written to file) + /// Log an entry to the destination. /// - Parameters: /// - level: The current level of the log entry. /// - message: Description of the log. @@ -60,9 +60,10 @@ public protocol LogWorkerType { /// - function: Function of the caller. /// - line: Line of the caller. /// - context: Additional meta data. - func verbose(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) + /// - completion: The block to call when log entries sent. + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) - /// Log something which help during debugging (low priority; not written to file) + /// Log something generally unimportant (lowest priority; not written to file) /// - Parameters: /// - level: The current level of the log entry. /// - message: Description of the log. @@ -70,9 +71,10 @@ public protocol LogWorkerType { /// - function: Function of the caller. /// - line: Line of the caller. /// - context: Additional meta data. - func debug(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) + /// - completion: The block to call when log entries sent. + func verbose(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) - /// Log something which you are really interested but which is not an issue or error (normal priority) + /// Log something which help during debugging (low priority; not written to file) /// - Parameters: /// - level: The current level of the log entry. /// - message: Description of the log. @@ -80,9 +82,10 @@ public protocol LogWorkerType { /// - function: Function of the caller. /// - line: Line of the caller. /// - context: Additional meta data. - func info(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) + /// - completion: The block to call when log entries sent. + func debug(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) - /// Log something which may cause big trouble soon (high priority) + /// Log something which you are really interested but which is not an issue or error (normal priority) /// - Parameters: /// - level: The current level of the log entry. /// - message: Description of the log. @@ -90,9 +93,10 @@ public protocol LogWorkerType { /// - function: Function of the caller. /// - line: Line of the caller. /// - context: Additional meta data. - func warning(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) + /// - completion: The block to call when log entries sent. + func info(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) - /// Log something which will keep you awake at night (highest priority) + /// Log something which may cause big trouble soon (high priority) /// - Parameters: /// - level: The current level of the log entry. /// - message: Description of the log. @@ -100,9 +104,10 @@ public protocol LogWorkerType { /// - function: Function of the caller. /// - line: Line of the caller. /// - context: Additional meta data. - func error(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) + /// - completion: The block to call when log entries sent. + func warning(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) - /// Log an entry to the destination. + /// Log something which will keep you awake at night (highest priority) /// - Parameters: /// - level: The current level of the log entry. /// - message: Description of the log. @@ -110,34 +115,35 @@ public protocol LogWorkerType { /// - function: Function of the caller. /// - line: Line of the caller. /// - context: Additional meta data. - func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) + /// - completion: The block to call when log entries sent. + func error(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) } public extension LogWorkerType { /// Log something generally unimportant (lowest priority; not written to file) - func verbose(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - write(.verbose, with: message, path: path, function: function, line: line, context: context) + func verbose(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + write(.verbose, with: message, path: path, function: function, line: line, context: context, completion: completion) } /// Log something which help during debugging (low priority; not written to file) - func debug(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - write(.debug, with: message, path: path, function: function, line: line, context: context) + func debug(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + write(.debug, with: message, path: path, function: function, line: line, context: context, completion: completion) } /// Log something which you are really interested but which is not an issue or error (normal priority) - func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - write(.info, with: message, path: path, function: function, line: line, context: context) + func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + write(.info, with: message, path: path, function: function, line: line, context: context, completion: completion) } /// Log something which may cause big trouble soon (high priority) - func warning(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - write(.warning, with: message, path: path, function: function, line: line, context: context) + func warning(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + write(.warning, with: message, path: path, function: function, line: line, context: context, completion: completion) } /// Log something which will keep you awake at night (highest priority) - func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil) { - write(.error, with: message, path: path, function: function, line: line, context: context) + func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + write(.error, with: message, path: path, function: function, line: line, context: context, completion: completion) } } @@ -145,7 +151,7 @@ public extension LogWorkerType { public extension LogAPI { - enum Level: Int, Comparable { + enum Level: Int, Comparable, CaseIterable { case verbose case debug case info diff --git a/Sources/ZamzamCore/Logging/LogWorker.swift b/Sources/ZamzamCore/Logging/LogWorker.swift index a46d1847..3ee17d3f 100644 --- a/Sources/ZamzamCore/Logging/LogWorker.swift +++ b/Sources/ZamzamCore/Logging/LogWorker.swift @@ -1,5 +1,5 @@ // -// Logger.swift +// LogWorker.swift // ZamzamCore // // Created by Basem Emara on 2019-06-11. @@ -10,7 +10,6 @@ import Foundation public struct LogWorker: LogWorkerType { private let stores: [LogStore] - private let queue = DispatchQueue(label: "io.zamzam.LogWorker", qos: .utility) public init(stores: [LogStore]) { self.stores = stores @@ -18,16 +17,23 @@ public struct LogWorker: LogWorkerType { } public extension LogWorker { + private static let queue = DispatchQueue(label: "io.zamzam.LogWorker", qos: .utility) - func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) { - // Skip if does not meet minimum log level + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) { let destinations = stores.filter { $0.canWrite(for: level) } - guard !destinations.isEmpty else { return } - queue.async { + // Skip if does not meet minimum log level + guard !destinations.isEmpty else { + completion?() + return + } + + Self.queue.async { destinations.forEach { $0.write(level, with: message, path: path, function: function, line: line, context: context) } + + completion?() } } } diff --git a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift index 5bdaf218..a6a9c791 100644 --- a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift +++ b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift @@ -1,8 +1,10 @@ // -// File.swift +// LogConsoleStore.swift +// ZamzamCore // // // Created by Basem Emara on 2019-10-28. +// Copyright © 2019 Zamzam Inc. All rights reserved. // import Foundation diff --git a/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift b/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift index 8de1b36c..0e621644 100644 --- a/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift +++ b/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift @@ -1,8 +1,10 @@ // -// File.swift +// LogOSStore.swift +// ZamzamCore // // // Created by Basem Emara on 2019-11-01. +// Copyright © 2019 Zamzam Inc. All rights reserved. // import Foundation diff --git a/Tests/ZamzamCoreTests/LoggingTests.swift b/Tests/ZamzamCoreTests/LoggingTests.swift new file mode 100644 index 00000000..689ab579 --- /dev/null +++ b/Tests/ZamzamCoreTests/LoggingTests.swift @@ -0,0 +1,162 @@ +// +// LoggingTests.swift +// ZamzamCore +// +// +// Created by Basem Emara on 2019-11-10. +// Copyright © 2019 Zamzam Inc. All rights reserved. +// + +import XCTest +import ZamzamCore + +final class LoggingTests: XCTestCase { + +} + +extension LoggingTests { + + func testEntriesAreWritten() { + // Given + let promise = expectation(description: "testEntriesAreWritten") + let logStore = LogTestStore(minLevel: .verbose) + let log: LogWorkerType = LogWorker(stores: [logStore]) + let group = DispatchGroup() + + // When + LogAPI.Level.allCases.forEach { + group.enter() + + log.write($0, with: "\($0) test", path: #file, function: #function, line: #line, context: nil) { + group.leave() + } + } + + // Then + group.notify(queue: .global()) { + XCTAssertEqual(logStore.entries[.verbose], ["\(LogAPI.Level.verbose) test"]) + XCTAssertEqual(logStore.entries[.debug], ["\(LogAPI.Level.debug) test"]) + XCTAssertEqual(logStore.entries[.info], ["\(LogAPI.Level.info) test"]) + XCTAssertEqual(logStore.entries[.warning], ["\(LogAPI.Level.warning) test"]) + XCTAssertEqual(logStore.entries[.error], ["\(LogAPI.Level.error) test"]) + XCTAssertEqual(logStore.entries[.none], []) + + promise.fulfill() + } + + wait(for: [promise], timeout: 10) + } +} + +extension LoggingTests { + + func testMinLevelsObeyed() { + // Given + let verboseStore = LogTestStore(minLevel: .verbose) + let debugStore = LogTestStore(minLevel: .debug) + let infoStore = LogTestStore(minLevel: .info) + let warningStore = LogTestStore(minLevel: .warning) + let errorStore = LogTestStore(minLevel: .error) + let noneStore = LogTestStore(minLevel: .none) + + // Then + XCTAssert(verboseStore.canWrite(for: .verbose)) + XCTAssert(verboseStore.canWrite(for: .debug)) + XCTAssert(verboseStore.canWrite(for: .info)) + XCTAssert(verboseStore.canWrite(for: .warning)) + XCTAssert(verboseStore.canWrite(for: .error)) + XCTAssertFalse(verboseStore.canWrite(for: .none)) + + XCTAssertFalse(debugStore.canWrite(for: .verbose)) + XCTAssert(debugStore.canWrite(for: .debug)) + XCTAssert(debugStore.canWrite(for: .info)) + XCTAssert(debugStore.canWrite(for: .warning)) + XCTAssert(debugStore.canWrite(for: .error)) + XCTAssertFalse(debugStore.canWrite(for: .none)) + + XCTAssertFalse(infoStore.canWrite(for: .verbose)) + XCTAssertFalse(infoStore.canWrite(for: .debug)) + XCTAssert(infoStore.canWrite(for: .info)) + XCTAssert(infoStore.canWrite(for: .warning)) + XCTAssert(infoStore.canWrite(for: .error)) + XCTAssertFalse(infoStore.canWrite(for: .none)) + + XCTAssertFalse(warningStore.canWrite(for: .verbose)) + XCTAssertFalse(warningStore.canWrite(for: .debug)) + XCTAssertFalse(warningStore.canWrite(for: .info)) + XCTAssert(warningStore.canWrite(for: .warning)) + XCTAssert(warningStore.canWrite(for: .error)) + XCTAssertFalse(warningStore.canWrite(for: .none)) + + XCTAssertFalse(errorStore.canWrite(for: .verbose)) + XCTAssertFalse(errorStore.canWrite(for: .debug)) + XCTAssertFalse(errorStore.canWrite(for: .info)) + XCTAssertFalse(errorStore.canWrite(for: .warning)) + XCTAssert(errorStore.canWrite(for: .error)) + XCTAssertFalse(errorStore.canWrite(for: .none)) + + XCTAssertFalse(noneStore.canWrite(for: .verbose)) + XCTAssertFalse(noneStore.canWrite(for: .debug)) + XCTAssertFalse(noneStore.canWrite(for: .info)) + XCTAssertFalse(noneStore.canWrite(for: .warning)) + XCTAssertFalse(noneStore.canWrite(for: .error)) + XCTAssertFalse(noneStore.canWrite(for: .none)) + } +} + +extension LoggingTests { + + func testThreadSafety() { + // Given + let promise = expectation(description: "testThreadSafety") + let logStore = LogTestStore(minLevel: .verbose) + let log: LogWorkerType = LogWorker(stores: [logStore]) + let group = DispatchGroup() + let iterations = 1_000 // 10_000 + + // When + DispatchQueue.concurrentPerform(iterations: iterations) { iteration in + LogAPI.Level.allCases.forEach { + group.enter() + + log.write($0, with: "\($0) test \(iteration)", path: #file, function: #function, line: #line, context: nil) { + group.leave() + } + } + } + + // Then + group.notify(queue: .global()) { + XCTAssertEqual(logStore.entries[.verbose]?.count, iterations) + XCTAssertEqual(logStore.entries[.debug]?.count, iterations) + XCTAssertEqual(logStore.entries[.info]?.count, iterations) + XCTAssertEqual(logStore.entries[.warning]?.count, iterations) + XCTAssertEqual(logStore.entries[.error]?.count, iterations) + XCTAssert(logStore.entries[.none]?.isEmpty == true) + + promise.fulfill() + } + + wait(for: [promise], timeout: 30) + } +} + +private extension LoggingTests { + + class LogTestStore: LogStore { + let minLevel: LogAPI.Level + + init(minLevel: LogAPI.Level) { + self.minLevel = minLevel + } + + // Spy + var entries = Dictionary( + uniqueKeysWithValues: LogAPI.Level.allCases.map { ($0, [String]()) } + ) + + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) { + entries.updateValue(entries[level, default: []] + [message], forKey: level) + } + } +} From 0798f1a8ee2806ad9cf456244ae8f08f240fac52 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 10 Nov 2019 09:01:02 -0500 Subject: [PATCH 05/31] Remove app info coupling to logger protocol --- Sources/ZamzamCore/Logging/LogAPI.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ZamzamCore/Logging/LogAPI.swift b/Sources/ZamzamCore/Logging/LogAPI.swift index f0dbfd4e..d717e438 100644 --- a/Sources/ZamzamCore/Logging/LogAPI.swift +++ b/Sources/ZamzamCore/Logging/LogAPI.swift @@ -11,7 +11,7 @@ import Foundation // Namespace public enum LogAPI {} -public protocol LogStore: AppInfo { +public protocol LogStore { /// The minimum level required to create log entries. var minLevel: LogAPI.Level { get } From 2ac9ff98e63e4977d98e27ed2129af5f72f60b2b Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 10 Nov 2019 09:04:08 -0500 Subject: [PATCH 06/31] FIx comments --- Sources/ZamzamCore/Logging/LogAPI.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/ZamzamCore/Logging/LogAPI.swift b/Sources/ZamzamCore/Logging/LogAPI.swift index d717e438..5bdc3a29 100644 --- a/Sources/ZamzamCore/Logging/LogAPI.swift +++ b/Sources/ZamzamCore/Logging/LogAPI.swift @@ -121,27 +121,22 @@ public protocol LogWorkerType { public extension LogWorkerType { - /// Log something generally unimportant (lowest priority; not written to file) func verbose(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { write(.verbose, with: message, path: path, function: function, line: line, context: context, completion: completion) } - /// Log something which help during debugging (low priority; not written to file) func debug(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { write(.debug, with: message, path: path, function: function, line: line, context: context, completion: completion) } - /// Log something which you are really interested but which is not an issue or error (normal priority) func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { write(.info, with: message, path: path, function: function, line: line, context: context, completion: completion) } - /// Log something which may cause big trouble soon (high priority) func warning(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { write(.warning, with: message, path: path, function: function, line: line, context: context, completion: completion) } - /// Log something which will keep you awake at night (highest priority) func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { write(.error, with: message, path: path, function: function, line: line, context: context, completion: completion) } From c516b817d2a1837a0b5751d5b41eca8542d41dfa Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Mon, 18 Nov 2019 20:10:40 -0500 Subject: [PATCH 07/31] Minor updates to logger --- Sources/ZamzamCore/Extensions/DispatchQueue.swift | 2 +- Sources/ZamzamCore/Logging/LogWorker.swift | 3 +-- Sources/ZamzamCore/Logging/Stores/LogOSStore.swift | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Sources/ZamzamCore/Extensions/DispatchQueue.swift b/Sources/ZamzamCore/Extensions/DispatchQueue.swift index baa822d1..cdca2618 100644 --- a/Sources/ZamzamCore/Extensions/DispatchQueue.swift +++ b/Sources/ZamzamCore/Extensions/DispatchQueue.swift @@ -21,5 +21,5 @@ public extension DispatchQueue { static let transform = DispatchQueue(label: "\(DispatchQueue.labelPrefix).transform", qos: .userInitiated) /// A configured queue for executing logger related work items. - static let logger = DispatchQueue(label: "\(DispatchQueue.labelPrefix).logger", qos: .background) + static let logger = DispatchQueue(label: "\(DispatchQueue.labelPrefix).logger", qos: .utility) } diff --git a/Sources/ZamzamCore/Logging/LogWorker.swift b/Sources/ZamzamCore/Logging/LogWorker.swift index 3ee17d3f..1127796f 100644 --- a/Sources/ZamzamCore/Logging/LogWorker.swift +++ b/Sources/ZamzamCore/Logging/LogWorker.swift @@ -17,7 +17,6 @@ public struct LogWorker: LogWorkerType { } public extension LogWorker { - private static let queue = DispatchQueue(label: "io.zamzam.LogWorker", qos: .utility) func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) { let destinations = stores.filter { $0.canWrite(for: level) } @@ -28,7 +27,7 @@ public extension LogWorker { return } - Self.queue.async { + DispatchQueue.logger.async { destinations.forEach { $0.write(level, with: message, path: path, function: function, line: line, context: context) } diff --git a/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift b/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift index 0e621644..d1b43453 100644 --- a/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift +++ b/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift @@ -45,6 +45,6 @@ public extension LogOSStore { return } - os_log("%@", log: log, type: type, message) + os_log("%@", log: log, type: type, format(message, path, function, line, context)) } } From 421d8557cead7185d9771fc7318809aa23a8b7b1 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 24 Nov 2019 15:09:14 -0500 Subject: [PATCH 08/31] Add JSON decoder extensions --- .../ZamzamCore/Extensions/JSONDecoder.swift | 53 +++++++++++++ Tests/ZamzamCoreTests/DecodableTests.swift | 78 ++++++++++++++++--- 2 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 Sources/ZamzamCore/Extensions/JSONDecoder.swift diff --git a/Sources/ZamzamCore/Extensions/JSONDecoder.swift b/Sources/ZamzamCore/Extensions/JSONDecoder.swift new file mode 100644 index 00000000..b03bf9da --- /dev/null +++ b/Sources/ZamzamCore/Extensions/JSONDecoder.swift @@ -0,0 +1,53 @@ +// +// File.swift +// +// +// Created by Basem Emara on 2019-11-18. +// + +import Foundation + +public extension JSONDecoder { + + /// Decodes an instance of the indicated type. + /// - Parameters: + /// - type: The type to decode. + /// - string: The string representation of the JSON object. + func decode(_ type: T.Type, from string: String) throws -> T { + guard let data = string.data(using: .utf8) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: [], + debugDescription: "Could not encode data from string." + )) + } + + return try decode(type, from: data) + } +} + +public extension JSONDecoder { + + /// Returns a value of the type you specify, decoded from a JSON object. + + + /// Decodes an instance of the indicated type. + /// - Parameters: + /// - type: The type to decode. + /// - name: The name of the embedded resource. + /// - bundle: The bundle of the embedded resource. + func decode(_ type: T.Type, forResource name: String?, inBundle bundle: Bundle) throws -> T where T: Decodable { + guard let url = bundle.url(forResource: name, withExtension: nil) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: [], + debugDescription: "Could not find the resource." + )) + } + + do { + let data = try Data(contentsOf: url, options: .mappedIfSafe) + return try decode(type, from: data) + } catch { + throw error + } + } +} diff --git a/Tests/ZamzamCoreTests/DecodableTests.swift b/Tests/ZamzamCoreTests/DecodableTests.swift index 815602e5..1126598d 100644 --- a/Tests/ZamzamCoreTests/DecodableTests.swift +++ b/Tests/ZamzamCoreTests/DecodableTests.swift @@ -10,8 +10,69 @@ import XCTest import ZamzamCore final class DecodableTests: XCTestCase { + private let jsonDecoder = JSONDecoder() + private lazy var bundle = Bundle(for: type(of: self)) +} + +extension DecodableTests { - func testErrorParsing() { + func testFromString() { + // Given + struct TestModel: Decodable { + let string: String + let integer: Int + } + + let jsonString = """ + { + "string": "Abc", + "integer": 123, + } + """ + + // When + do { + let model = try jsonDecoder.decode(TestModel.self, from: jsonString) + + // Then + XCTAssertEqual(model.string, "Abc") + XCTAssertEqual(model.integer, 123) + } catch { + XCTFail("Could not parse JSON string: \(error)") + } + } +} + +extension DecodableTests { + + func testInBundle() { + // Given + struct TestModel: Decodable { + let string: String + let integer: Int + } + + // When + do { + let model = try jsonDecoder.decode( + TestModel.self, + forResource: "TestModel.json", + inBundle: bundle + ) + + // Then + XCTAssertEqual(model.string, "Abc") + XCTAssertEqual(model.integer, 123) + } catch { + XCTFail("Could not parse JSON resource: \(error)") + } + } +} + +extension DecodableTests { + + func testErrorParsing() { + // Given let jsonString = """ { "code": "post_does_not_exist", @@ -44,8 +105,14 @@ final class DecodableTests: XCTestCase { let data: [String: AnyDecodable]? } - let payload = try JSONDecoder.test.decode(ServerResponse.self, from: data) + let decoder = JSONDecoder().with { + $0.dateDecodingStrategy = .formatted(.init(iso8601Format: "yyyy-MM-dd'T'HH:mm:ssZ")) + } + + // When + let payload = try decoder.decode(ServerResponse.self, from: data) + // Then XCTAssertEqual((payload.data?["boolean"])?.value as! Bool, true) XCTAssertEqual((payload.data?["integer"])?.value as! Int, 1) XCTAssertEqual((payload.data?["double"])?.value as! Double, 3.14159265358979323846, accuracy: 0.001) @@ -58,10 +125,3 @@ final class DecodableTests: XCTestCase { } } } - -private extension JSONDecoder { - - static let test = JSONDecoder().with { - $0.dateDecodingStrategy = .formatted(.init(iso8601Format: "yyyy-MM-dd'T'HH:mm:ssZ")) - } -} From 2eca78622e002152242583b2d31526b9b31393f2 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 24 Nov 2019 15:09:33 -0500 Subject: [PATCH 09/31] Add extension to JSON encoder --- Sources/ZamzamCore/Utilities/With.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/ZamzamCore/Utilities/With.swift b/Sources/ZamzamCore/Utilities/With.swift index 65e4cc0c..c5afd582 100644 --- a/Sources/ZamzamCore/Utilities/With.swift +++ b/Sources/ZamzamCore/Utilities/With.swift @@ -29,3 +29,4 @@ public extension With where Self: Any { extension NSObject: With {} extension JSONDecoder: With {} +extension JSONEncoder: With {} From 12ddff3c4b804f79294819c6b7fd4218db2df08a Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 24 Nov 2019 15:10:25 -0500 Subject: [PATCH 10/31] Add user activity to pluggable app and scene --- .../Application/ApplicationPluggableDelegate.swift | 12 +++++++++++- .../Application/ScenePluggableDelegate.swift | 13 ++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift b/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift index fd6dcba1..dfcbebde 100644 --- a/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift +++ b/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift @@ -80,6 +80,14 @@ extension ApplicationPluggableDelegate { $0 && $1.application(application, didFinishLaunchingWithOptions: launchOptions) } } + + open func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Ensure all delegates called even if condition fails early + //swiftlint:disable reduce_boolean + pluginInstances.reduce(false) { + $0 || $1.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + } } extension ApplicationPluggableDelegate { @@ -131,10 +139,11 @@ extension ApplicationPluggableDelegate { } } -/// Conforming to an app module and added to `AppDelegate.application()` will trigger events. +/// Conforming to an app plugin and added to `AppDelegate.application()` will trigger events. public protocol ApplicationPlugin { func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool func applicationProtectedDataWillBecomeUnavailable(_ application: UIApplication) func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) @@ -148,6 +157,7 @@ public protocol ApplicationPlugin { public extension ApplicationPlugin { func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { return false } func applicationProtectedDataWillBecomeUnavailable(_ application: UIApplication) {} func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) {} diff --git a/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift b/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift index 5e80b57c..1fb3d37c 100644 --- a/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift +++ b/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift @@ -58,6 +58,10 @@ extension ScenePluggableDelegate { pluginInstances.forEach { $0.scene(scene, willConnectTo: session, options: connectionOptions) } } + open func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + pluginInstances.forEach { $0.scene(scene, continue: userActivity) } + } + open func sceneWillEnterForeground(_ scene: UIScene) { pluginInstances.forEach { $0.sceneWillEnterForeground() } } @@ -79,7 +83,7 @@ extension ScenePluggableDelegate { } } -/// Conforming to an scene module and added to `SceneDelegate.plugins()` will trigger events. +/// Conforming to an scene plugin and added to `SceneDelegate.plugins()` will trigger events. public protocol ScenePlugin { /// Tells the delegate that the scene is about to begin running in the foreground and become visible to the user. @@ -100,6 +104,10 @@ public protocol ScenePlugin { /// Tells the delegate about the addition of a scene to the app. @available(iOS 13.0, *) func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) + + /// Tells the delegate to handle the specified Handoff-related activity. + @available(iOS 13.0, *) + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) } // MARK: - Optionals @@ -113,6 +121,9 @@ public extension ScenePlugin { @available(iOS 13.0, *) func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {} + + @available(iOS 13.0, *) + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {} } public protocol WindowDelegate: class { From 17291e4a32ee5a7df1c55d664bb09aefbca36ec7 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 1 Dec 2019 12:19:16 -0500 Subject: [PATCH 11/31] Rename worker to provider for naming convention --- README.md | 24 ++--- Sources/ZamzamCore/Logging/LogAPI.swift | 32 +++--- .../{LogWorker.swift => LogProvider.swift} | 8 +- .../Logging/Stores/LogConsoleStore.swift | 2 +- .../Logging/Stores/LogOSStore.swift | 2 +- Sources/ZamzamLocation/LocationAPI.swift | 102 +++++++++++++++++- ...ionWorker.swift => LocationProvider.swift} | 14 +-- .../ZamzamLocation/LocationWorkerType.swift | 98 ----------------- Tests/ZamzamCoreTests/DependencyTests.swift | 14 +-- Tests/ZamzamCoreTests/LoggingTests.swift | 6 +- 10 files changed, 152 insertions(+), 150 deletions(-) rename Sources/ZamzamCore/Logging/{LogWorker.swift => LogProvider.swift} (79%) rename Sources/ZamzamLocation/{LocationWorker.swift => LocationProvider.swift} (96%) delete mode 100644 Sources/ZamzamLocation/LocationWorkerType.swift diff --git a/README.md b/README.md index 4b0e78bd..5fac5aa7 100644 --- a/README.md +++ b/README.md @@ -785,9 +785,9 @@ myLabel3.text = .localized(.next)
Logger -> Create loggers that conform to `LogStore` and add to `LogWorker` (console and `os_log` are included): +> Create loggers that conform to `LogStore` and add to `LogProvider` (console and `os_log` are included): ```swift -let log: LogWorkerType = LogWorker( +let log: LogProviderType = LogProvider( stores: [ LogConsoleStore(minLevel: .debug), LogOSStore( @@ -954,7 +954,7 @@ test = value ??+ "Rst" ## ZamzamLocation
-LocationsWorker +LocationsProvider > Location worker that offers easy authorization and observable closures ([read more](https://basememara.com/swifty-locations-observables/)): ```swift @@ -962,7 +962,7 @@ class LocationViewController: UIViewController { @IBOutlet weak var outputLabel: UILabel! - var locationsWorker: LocationsWorkerType = LocationsWorker( + var locationsProvider: LocationsProviderType = LocationsProvider( desiredAccuracy: kCLLocationAccuracyThreeKilometers, distanceFilter: 1000 ) @@ -970,38 +970,38 @@ class LocationViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - locationsWorker.addObserver(locationObserver) - locationsWorker.addObserver(headingObserver) + locationsProvider.addObserver(locationObserver) + locationsProvider.addObserver(headingObserver) - locationsWorker.requestAuthorization( + locationsProvider.requestAuthorization( for: .whenInUse, startUpdatingLocation: true, completion: { granted in guard granted else { return } - self.locationsWorker.startUpdatingHeading() + self.locationsProvider.startUpdatingHeading() } ) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - locationsWorker.removeObservers() + locationsProvider.removeObservers() } deinit { - locationsWorker.removeObservers() + locationsProvider.removeObservers() } } extension LocationViewController { - var locationObserver: Observer { + var locationObserver: Observer { return Observer { [weak self] in self?.outputLabel.text = $0.description } } - var headingObserver: Observer { + var headingObserver: Observer { return Observer { print($0.description) } diff --git a/Sources/ZamzamCore/Logging/LogAPI.swift b/Sources/ZamzamCore/Logging/LogAPI.swift index 5bdc3a29..1eb46cb7 100644 --- a/Sources/ZamzamCore/Logging/LogAPI.swift +++ b/Sources/ZamzamCore/Logging/LogAPI.swift @@ -24,7 +24,7 @@ public protocol LogStore { /// - function: Function of the caller. /// - line: Line of the caller. /// - context: Additional meta data. - func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?) /// Returns if the logger should process the entry for the specified log level. func canWrite(for level: LogAPI.Level) -> Bool @@ -36,7 +36,7 @@ public protocol LogStore { /// - function: Function of the caller. /// - line: Line of the caller. /// - context: Additional meta data. - func format(_ message: String, _ path: String, _ function: String, _ line: Int, _ context: [String: Any]?) -> String + func format(_ message: String, _ path: String, _ function: String, _ line: Int, _ context: [String: CustomStringConvertible]?) -> String } public extension LogStore { @@ -45,12 +45,12 @@ public extension LogStore { minLevel <= level && level != .none } - func format(_ message: String, _ path: String, _ function: String, _ line: Int, _ context: [String: Any]?) -> String { + func format(_ message: String, _ path: String, _ function: String, _ line: Int, _ context: [String: CustomStringConvertible]?) -> String { "\(URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent).\(function):\(line) - \(message)" } } -public protocol LogWorkerType { +public protocol LogProviderType { /// Log an entry to the destination. /// - Parameters: @@ -61,7 +61,7 @@ public protocol LogWorkerType { /// - line: Line of the caller. /// - context: Additional meta data. /// - completion: The block to call when log entries sent. - func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?, completion: (() -> Void)?) /// Log something generally unimportant (lowest priority; not written to file) /// - Parameters: @@ -72,7 +72,7 @@ public protocol LogWorkerType { /// - line: Line of the caller. /// - context: Additional meta data. /// - completion: The block to call when log entries sent. - func verbose(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) + func verbose(_ message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?, completion: (() -> Void)?) /// Log something which help during debugging (low priority; not written to file) /// - Parameters: @@ -83,7 +83,7 @@ public protocol LogWorkerType { /// - line: Line of the caller. /// - context: Additional meta data. /// - completion: The block to call when log entries sent. - func debug(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) + func debug(_ message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?, completion: (() -> Void)?) /// Log something which you are really interested but which is not an issue or error (normal priority) /// - Parameters: @@ -94,7 +94,7 @@ public protocol LogWorkerType { /// - line: Line of the caller. /// - context: Additional meta data. /// - completion: The block to call when log entries sent. - func info(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) + func info(_ message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?, completion: (() -> Void)?) /// Log something which may cause big trouble soon (high priority) /// - Parameters: @@ -105,7 +105,7 @@ public protocol LogWorkerType { /// - line: Line of the caller. /// - context: Additional meta data. /// - completion: The block to call when log entries sent. - func warning(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) + func warning(_ message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?, completion: (() -> Void)?) /// Log something which will keep you awake at night (highest priority) /// - Parameters: @@ -116,28 +116,28 @@ public protocol LogWorkerType { /// - line: Line of the caller. /// - context: Additional meta data. /// - completion: The block to call when log entries sent. - func error(_ message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) + func error(_ message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?, completion: (() -> Void)?) } -public extension LogWorkerType { +public extension LogProviderType { - func verbose(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + func verbose(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: CustomStringConvertible]? = nil, completion: (() -> Void)? = nil) { write(.verbose, with: message, path: path, function: function, line: line, context: context, completion: completion) } - func debug(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + func debug(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: CustomStringConvertible]? = nil, completion: (() -> Void)? = nil) { write(.debug, with: message, path: path, function: function, line: line, context: context, completion: completion) } - func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: CustomStringConvertible]? = nil, completion: (() -> Void)? = nil) { write(.info, with: message, path: path, function: function, line: line, context: context, completion: completion) } - func warning(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + func warning(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: CustomStringConvertible]? = nil, completion: (() -> Void)? = nil) { write(.warning, with: message, path: path, function: function, line: line, context: context, completion: completion) } - func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: Any]? = nil, completion: (() -> Void)? = nil) { + func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line, context: [String: CustomStringConvertible]? = nil, completion: (() -> Void)? = nil) { write(.error, with: message, path: path, function: function, line: line, context: context, completion: completion) } } diff --git a/Sources/ZamzamCore/Logging/LogWorker.swift b/Sources/ZamzamCore/Logging/LogProvider.swift similarity index 79% rename from Sources/ZamzamCore/Logging/LogWorker.swift rename to Sources/ZamzamCore/Logging/LogProvider.swift index 1127796f..4cdbc8ab 100644 --- a/Sources/ZamzamCore/Logging/LogWorker.swift +++ b/Sources/ZamzamCore/Logging/LogProvider.swift @@ -1,5 +1,5 @@ // -// LogWorker.swift +// LogProvider.swift // ZamzamCore // // Created by Basem Emara on 2019-06-11. @@ -8,7 +8,7 @@ import Foundation -public struct LogWorker: LogWorkerType { +public struct LogProvider: LogProviderType { private let stores: [LogStore] public init(stores: [LogStore]) { @@ -16,9 +16,9 @@ public struct LogWorker: LogWorkerType { } } -public extension LogWorker { +public extension LogProvider { - func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?, completion: (() -> Void)?) { + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?, completion: (() -> Void)?) { let destinations = stores.filter { $0.canWrite(for: level) } // Skip if does not meet minimum log level diff --git a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift index a6a9c791..eee7bbac 100644 --- a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift +++ b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift @@ -20,7 +20,7 @@ public struct LogConsoleStore: LogStore { public extension LogConsoleStore { - func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) { + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?) { let prefix: String switch level { diff --git a/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift b/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift index d1b43453..ccfeeda6 100644 --- a/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift +++ b/Sources/ZamzamCore/Logging/Stores/LogOSStore.swift @@ -27,7 +27,7 @@ public struct LogOSStore: LogStore { public extension LogOSStore { - func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) { + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?) { let type: OSLogType switch level { diff --git a/Sources/ZamzamLocation/LocationAPI.swift b/Sources/ZamzamLocation/LocationAPI.swift index 5f313903..8caf0226 100644 --- a/Sources/ZamzamLocation/LocationAPI.swift +++ b/Sources/ZamzamLocation/LocationAPI.swift @@ -5,10 +5,102 @@ // Created by Basem Emara on 2019-08-25. // Copyright © 2019 Zamzam Inc. All rights reserved. // +import CoreLocation +import ZamzamCore -/// Namespaec for location +/// Namespace for location public enum LocationAPI {} +public protocol LocationProviderType { + typealias LocationHandler = (CLLocation) -> Void + typealias AuthorizationHandler = (Bool) -> Void + + // MARK: - Authorization + + /// Determines if location services is enabled and authorized for always or when in use. + var isAuthorized: Bool { get } + + /// Determines if location services is enabled and authorized for the specified authorization type. + func isAuthorized(for type: LocationAPI.AuthorizationType) -> Bool + + /// Requests permission to use location services. + /// + /// - Parameters: + /// - type: Type of permission required, whether in the foreground (.whenInUse) or while running (.always). + /// - startUpdatingLocation: Starts the generation of updates that report the user’s current location. + /// - completion: True if the authorization succeeded for the authorization type, false otherwise. + func requestAuthorization(for type: LocationAPI.AuthorizationType, startUpdatingLocation: Bool, completion: AuthorizationHandler?) + + // MARK: - Coordinates + + /// The most recently retrieved user location. + var location: CLLocation? { get } + + /// Request the one-time delivery of the user’s current location. + /// + /// - Parameter completion: The completion with the location object. + func requestLocation(completion: @escaping LocationHandler) + + /// Starts the generation of updates that report the user’s current location. + func startUpdatingLocation(enableBackground: Bool) + + /// Stops the generation of location updates. + func stopUpdatingLocation() + + #if os(iOS) + /// Starts the generation of updates based on significant location changes. + func startMonitoringSignificantLocationChanges() + + /// Stops the delivery of location events based on significant location changes. + func stopMonitoringSignificantLocationChanges() + + typealias HeadingHandler = (CLHeading) -> Void + + /// The most recently reported heading. + var heading: CLHeading? { get } + + /// Starts the generation of updates that report the user’s current heading. + func startUpdatingHeading() + + /// Stops the generation of heading updates. + func stopUpdatingHeading() + + func addObserver(_ observer: Observer) + func removeObserver(_ observer: Observer) + #endif + + // MARK: - Observers + + func addObserver(_ observer: Observer) + func removeObserver(_ observer: Observer) + + func addObserver(_ observer: Observer) + func removeObserver(_ observer: Observer) + + func removeObservers(with prefix: String) +} + +public extension LocationProviderType { + + func requestAuthorization(for type: LocationAPI.AuthorizationType) { + requestAuthorization(for: type, startUpdatingLocation: false, completion: nil) + } + + func requestAuthorization(for type: LocationAPI.AuthorizationType = .whenInUse, startUpdatingLocation: Bool = false, completion: AuthorizationHandler?) { + requestAuthorization(for: type, startUpdatingLocation: startUpdatingLocation, completion: completion) + } + + func startUpdatingLocation() { + startUpdatingLocation(enableBackground: false) + } + + func removeObservers(from file: String = #file) { + removeObservers(with: file) + } +} + +// MARK: - Subtypes + public extension LocationAPI { /// Permission types to use location services. @@ -19,3 +111,11 @@ public extension LocationAPI { case whenInUse, always } } + +// MARK: - Deprecated + +@available(*, deprecated, renamed: "LocationProviderType") +public protocol LocationWorkerType: LocationProviderType {} + +@available(*, deprecated, renamed: "LocationProvider") +public class LocationWorker: LocationProvider {} diff --git a/Sources/ZamzamLocation/LocationWorker.swift b/Sources/ZamzamLocation/LocationProvider.swift similarity index 96% rename from Sources/ZamzamLocation/LocationWorker.swift rename to Sources/ZamzamLocation/LocationProvider.swift index a1db218a..3ce5ec1d 100644 --- a/Sources/ZamzamLocation/LocationWorker.swift +++ b/Sources/ZamzamLocation/LocationProvider.swift @@ -10,7 +10,7 @@ import CoreLocation import ZamzamCore /// A `LocationManager` wrapper with extensions. -public class LocationWorker: NSObject, LocationWorkerType { +public class LocationProvider: NSObject, LocationProviderType { private let desiredAccuracy: CLLocationAccuracy? private let distanceFilter: Double? private let activityType: CLActivityType? @@ -65,7 +65,7 @@ public class LocationWorker: NSObject, LocationWorkerType { // MARK: - Authorization -public extension LocationWorker { +public extension LocationProvider { var isAuthorized: Bool { CLLocationManager.isAuthorized } @@ -139,7 +139,7 @@ public extension LocationWorker { // MARK: - Coordinates -public extension LocationWorker { +public extension LocationProvider { var location: CLLocation? { manager.location } @@ -169,7 +169,7 @@ public extension LocationWorker { } #if os(iOS) -public extension LocationWorker { +public extension LocationProvider { func startMonitoringSignificantLocationChanges() { manager.startMonitoringSignificantLocationChanges() @@ -180,7 +180,7 @@ public extension LocationWorker { } } -public extension LocationWorker { +public extension LocationProvider { var heading: CLHeading? { manager.heading } @@ -205,7 +205,7 @@ public extension LocationWorker { // MARK: - Observers -public extension LocationWorker { +public extension LocationProvider { func addObserver(_ observer: Observer) { didChangeAuthorizationHandlers.value { $0.append(observer) } @@ -247,7 +247,7 @@ public extension LocationWorker { // MARK: - Delegates -extension LocationWorker: CLLocationManagerDelegate { +extension LocationProvider: CLLocationManagerDelegate { public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { guard status != .notDetermined else { return } diff --git a/Sources/ZamzamLocation/LocationWorkerType.swift b/Sources/ZamzamLocation/LocationWorkerType.swift deleted file mode 100644 index 5984cd5f..00000000 --- a/Sources/ZamzamLocation/LocationWorkerType.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// LocationsWorkerType.swift -// ZamzamKit -// -// Created by Basem Emara on 2018-09-07. -// Copyright © 2018 Zamzam Inc. All rights reserved. -// - -import CoreLocation -import ZamzamCore - -public protocol LocationWorkerType { - typealias LocationHandler = (CLLocation) -> Void - typealias AuthorizationHandler = (Bool) -> Void - - // MARK: - Authorization - - /// Determines if location services is enabled and authorized for always or when in use. - var isAuthorized: Bool { get } - - /// Determines if location services is enabled and authorized for the specified authorization type. - func isAuthorized(for type: LocationAPI.AuthorizationType) -> Bool - - /// Requests permission to use location services. - /// - /// - Parameters: - /// - type: Type of permission required, whether in the foreground (.whenInUse) or while running (.always). - /// - startUpdatingLocation: Starts the generation of updates that report the user’s current location. - /// - completion: True if the authorization succeeded for the authorization type, false otherwise. - func requestAuthorization(for type: LocationAPI.AuthorizationType, startUpdatingLocation: Bool, completion: AuthorizationHandler?) - - // MARK: - Coordinates - - /// The most recently retrieved user location. - var location: CLLocation? { get } - - /// Request the one-time delivery of the user’s current location. - /// - /// - Parameter completion: The completion with the location object. - func requestLocation(completion: @escaping LocationHandler) - - /// Starts the generation of updates that report the user’s current location. - func startUpdatingLocation(enableBackground: Bool) - - /// Stops the generation of location updates. - func stopUpdatingLocation() - - #if os(iOS) - /// Starts the generation of updates based on significant location changes. - func startMonitoringSignificantLocationChanges() - - /// Stops the delivery of location events based on significant location changes. - func stopMonitoringSignificantLocationChanges() - - typealias HeadingHandler = (CLHeading) -> Void - - /// The most recently reported heading. - var heading: CLHeading? { get } - - /// Starts the generation of updates that report the user’s current heading. - func startUpdatingHeading() - - /// Stops the generation of heading updates. - func stopUpdatingHeading() - - func addObserver(_ observer: Observer) - func removeObserver(_ observer: Observer) - #endif - - // MARK: - Observers - - func addObserver(_ observer: Observer) - func removeObserver(_ observer: Observer) - - func addObserver(_ observer: Observer) - func removeObserver(_ observer: Observer) - - func removeObservers(with prefix: String) -} - -public extension LocationWorkerType { - - func requestAuthorization(for type: LocationAPI.AuthorizationType) { - requestAuthorization(for: type, startUpdatingLocation: false, completion: nil) - } - - func requestAuthorization(for type: LocationAPI.AuthorizationType = .whenInUse, startUpdatingLocation: Bool = false, completion: AuthorizationHandler?) { - requestAuthorization(for: type, startUpdatingLocation: startUpdatingLocation, completion: completion) - } - - func startUpdatingLocation() { - startUpdatingLocation(enableBackground: false) - } - - func removeObservers(from file: String = #file) { - removeObservers(with: file) - } -} diff --git a/Tests/ZamzamCoreTests/DependencyTests.swift b/Tests/ZamzamCoreTests/DependencyTests.swift index c1a3a286..503d11a3 100644 --- a/Tests/ZamzamCoreTests/DependencyTests.swift +++ b/Tests/ZamzamCoreTests/DependencyTests.swift @@ -22,7 +22,7 @@ final class DependencyTests: XCTestCase { @Inject("abc") private var sampleModule2: SampleModuleType @Inject private var someClass: SomeClassType - private lazy var widgetWorker: WidgetWorkerType = widgetModule.component() + private lazy var widgetProvider: WidgetProviderType = widgetModule.component() private lazy var someObject: SomeObjectType = sampleModule.component() private lazy var anotherObject: AnotherObjectType = sampleModule.component() private lazy var viewModelObject: ViewModelObjectType = sampleModule.component() @@ -43,7 +43,7 @@ extension DependencyTests { let widgetModuleResult = widgetModule.test() let sampleModuleResult = sampleModule.test() let sampleModule2Result = sampleModule2.test() - let widgetResult = widgetWorker.fetch(id: 3) + let widgetResult = widgetProvider.fetch(id: 3) let someResult = someObject.testAbc() let anotherResult = anotherObject.testXyz() let viewModelResult = viewModelObject.testLmn() @@ -80,8 +80,8 @@ extension DependencyTests { struct WidgetModule: WidgetModuleType { - func component() -> WidgetWorkerType { - WidgetWorker( + func component() -> WidgetProviderType { + WidgetProvider( store: component(), remote: component() ) @@ -194,7 +194,7 @@ extension DependencyTests { } } - struct WidgetWorker: WidgetWorkerType { + struct WidgetProvider: WidgetProviderType { private let store: WidgetStore private let remote: WidgetRemote @@ -247,7 +247,7 @@ extension DependencyTests { // MARK: API protocol WidgetModuleType { - func component() -> WidgetWorkerType + func component() -> WidgetProviderType func component() -> WidgetRemote func component() -> WidgetStore func component() -> HTTPServiceType @@ -293,7 +293,7 @@ protocol WidgetRemote { func fetch(id: Int) -> String } -protocol WidgetWorkerType { +protocol WidgetProviderType { func fetch(id: Int) -> String } diff --git a/Tests/ZamzamCoreTests/LoggingTests.swift b/Tests/ZamzamCoreTests/LoggingTests.swift index 689ab579..2403f0fd 100644 --- a/Tests/ZamzamCoreTests/LoggingTests.swift +++ b/Tests/ZamzamCoreTests/LoggingTests.swift @@ -20,7 +20,7 @@ extension LoggingTests { // Given let promise = expectation(description: "testEntriesAreWritten") let logStore = LogTestStore(minLevel: .verbose) - let log: LogWorkerType = LogWorker(stores: [logStore]) + let log: LogProviderType = LogProvider(stores: [logStore]) let group = DispatchGroup() // When @@ -110,7 +110,7 @@ extension LoggingTests { // Given let promise = expectation(description: "testThreadSafety") let logStore = LogTestStore(minLevel: .verbose) - let log: LogWorkerType = LogWorker(stores: [logStore]) + let log: LogProviderType = LogProvider(stores: [logStore]) let group = DispatchGroup() let iterations = 1_000 // 10_000 @@ -155,7 +155,7 @@ private extension LoggingTests { uniqueKeysWithValues: LogAPI.Level.allCases.map { ($0, [String]()) } ) - func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: Any]?) { + func write(_ level: LogAPI.Level, with message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?) { entries.updateValue(entries[level, default: []] + [message], forKey: level) } } From 745a16bcf6ab7c7efef7942eb05d1c9dcebc9815 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 1 Dec 2019 12:29:12 -0500 Subject: [PATCH 12/31] Add timestamp to log console --- .../ZamzamCore/Extensions/DateFormatter.swift | 19 +++++++++++++++++++ .../Logging/Stores/LogConsoleStore.swift | 10 +++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Sources/ZamzamCore/Extensions/DateFormatter.swift b/Sources/ZamzamCore/Extensions/DateFormatter.swift index c219b7a2..df65267e 100644 --- a/Sources/ZamzamCore/Extensions/DateFormatter.swift +++ b/Sources/ZamzamCore/Extensions/DateFormatter.swift @@ -82,3 +82,22 @@ public extension DateFormatter { } } } + +public extension String.StringInterpolation { + + private static let formatter = DateFormatter().with { + $0.calendar = Calendar(identifier: .iso8601) + $0.locale = .posix + $0.timeZone = .posix + $0.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + } + + /// Appends a date formatted timestamp to the interpolation. + /// + /// print("Console log at \(timestamp: Date())") + /// + /// - Parameter timestamp: The date to format. + mutating func appendInterpolation(timestamp: Date) { + appendLiteral(Self.formatter.string(from: timestamp)) + } +} diff --git a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift index eee7bbac..087f273f 100644 --- a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift +++ b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift @@ -25,15 +25,15 @@ public extension LogConsoleStore { switch level { case .verbose: - prefix = "💜 VERBOSE" + prefix = "💜 \(timestamp: Date()) VERBOSE" case .debug: - prefix = "💚 DEBUG" + prefix = "💚 \(timestamp: Date()) DEBUG" case .info: - prefix = "💙 INFO" + prefix = "💙 \(timestamp: Date()) INFO" case .warning: - prefix = "💛 WARNING" + prefix = "💛 \(timestamp: Date()) WARNING" case .error: - prefix = "❤️ ERROR" + prefix = "❤️ \(timestamp: Date()) ERROR" case .none: return } From 854a89d08c4b1b105e2bdb8b440612edfd854b2f Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Mon, 2 Dec 2019 13:39:54 -0500 Subject: [PATCH 13/31] Minor fixes and updates --- .../Application/ApplicationPluggableDelegate.swift | 10 +++++----- .../Application/ExtensionPluggableDelegate.swift | 10 +++++----- .../Application/ScenePluggableDelegate.swift | 8 ++------ Sources/ZamzamCore/Extensions/DateFormatter.swift | 9 +++------ 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift b/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift index dfcbebde..05bb6e75 100644 --- a/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift +++ b/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift @@ -12,7 +12,7 @@ import UIKit /// Subclassed by the `AppDelegate` to pass lifecycle events to loaded plugins. /// -/// The application plugins will be processed in sequence after calling `application() -> [ApplicationPlugin]`. +/// The application plugins will be processed in sequence after calling `plugins() -> [ApplicationPlugin]`. /// /// @UIApplicationMain /// class AppDelegate: ApplicationPluggableDelegate { @@ -25,7 +25,7 @@ import UIKit /// /// Each application plugin has access to the `AppDelegate` lifecycle events: /// -/// final class LoggerPlugin: ApplicationPlugin { +/// struct LoggerPlugin: ApplicationPlugin { /// private let log = Logger() /// /// func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { @@ -39,14 +39,14 @@ import UIKit /// } /// /// func applicationDidReceiveMemoryWarning(_ application: UIApplication) { -/// log.warn("App did receive memory warning.") +/// log.warning("App did receive memory warning.") /// } /// /// func applicationWillTerminate(_ application: UIApplication) { -/// log.warn("App will terminate.") +/// log.warning("App will terminate.") /// } /// } -open class ApplicationPluggableDelegate: UIResponder, UIApplicationDelegate, WindowDelegate { +open class ApplicationPluggableDelegate: UIResponder, UIApplicationDelegate { public var window: UIWindow? /// List of application plugins for binding to `AppDelegate` events diff --git a/Sources/ZamzamCore/Application/ExtensionPluggableDelegate.swift b/Sources/ZamzamCore/Application/ExtensionPluggableDelegate.swift index d5cecaf1..4873c018 100644 --- a/Sources/ZamzamCore/Application/ExtensionPluggableDelegate.swift +++ b/Sources/ZamzamCore/Application/ExtensionPluggableDelegate.swift @@ -11,7 +11,7 @@ import WatchKit /// Subclassed by the `ExtensionDelegate` to pass lifecycle events to loaded plugins. /// -/// The application plugins will be processed in sequence after calling `application() -> [ExtensionPlugin]`. +/// The application plugins will be processed in sequence after calling `plugins() -> [ExtensionPlugin]`. /// /// class ExtensionDelegate: ExtensionPluggableDelegate { /// @@ -23,7 +23,7 @@ import WatchKit /// /// Each application module has access to the `ExtensionDelegate` lifecycle events: /// -/// final class LoggerPlugin: ExtensionPlugin { +/// struct LoggerPlugin: ExtensionPlugin { /// private let log = Logger() /// /// func applicationDidFinishLaunching(_ application: WKExtension) { @@ -35,15 +35,15 @@ import WatchKit /// } /// /// func applicationWillResignActive(_ application: WKExtension) { -/// log.warn("App will resign active.") +/// log.warning("App will resign active.") /// } /// /// func applicationWillEnterForeground(_ application: WKExtension) { -/// log.warn("App will enter foreground.") +/// log.warning("App will enter foreground.") /// } /// /// func applicationDidEnterBackground(_ application: WKExtension) { -/// log.warn("App did enter background.") +/// log.warning("App did enter background.") /// } /// } open class ExtensionPluggableDelegate: NSObject, WKExtensionDelegate { diff --git a/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift b/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift index 1fb3d37c..2febbaf5 100644 --- a/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift +++ b/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift @@ -22,7 +22,7 @@ import UIKit /// /// Each scene plugin has access to the `SceneDelegate` lifecycle events: /// -/// final class LoggerPlugin: ScenePlugin { +/// struct LoggerPlugin: ScenePlugin { /// private let log = Logger() /// /// func sceneWillEnterForeground() { @@ -34,7 +34,7 @@ import UIKit /// } /// } @available(iOS 13.0, *) -open class ScenePluggableDelegate: UIResponder, UIWindowSceneDelegate, WindowDelegate { +open class ScenePluggableDelegate: UIResponder, UIWindowSceneDelegate { public var window: UIWindow? /// List of scene plugins for binding to `SceneDelegate` events @@ -125,8 +125,4 @@ public extension ScenePlugin { @available(iOS 13.0, *) func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {} } - -public protocol WindowDelegate: class { - var window: UIWindow? { get set } -} #endif diff --git a/Sources/ZamzamCore/Extensions/DateFormatter.swift b/Sources/ZamzamCore/Extensions/DateFormatter.swift index df65267e..bffb10bc 100644 --- a/Sources/ZamzamCore/Extensions/DateFormatter.swift +++ b/Sources/ZamzamCore/Extensions/DateFormatter.swift @@ -85,12 +85,9 @@ public extension DateFormatter { public extension String.StringInterpolation { - private static let formatter = DateFormatter().with { - $0.calendar = Calendar(identifier: .iso8601) - $0.locale = .posix - $0.timeZone = .posix - $0.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - } + private static let formatter = DateFormatter( + iso8601Format: "yyyy-MM-dd HH:mm:ss.SSS" + ) /// Appends a date formatted timestamp to the interpolation. /// From ad4ce7ce59a7d06946ee15e04b4b78d21c4123a1 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Mon, 2 Dec 2019 16:50:47 -0500 Subject: [PATCH 14/31] Minor comments fixes --- .../ZamzamCore/Application/ExtensionPluggableDelegate.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ZamzamCore/Application/ExtensionPluggableDelegate.swift b/Sources/ZamzamCore/Application/ExtensionPluggableDelegate.swift index 4873c018..db6772f9 100644 --- a/Sources/ZamzamCore/Application/ExtensionPluggableDelegate.swift +++ b/Sources/ZamzamCore/Application/ExtensionPluggableDelegate.swift @@ -21,7 +21,7 @@ import WatchKit /// ]} /// } /// -/// Each application module has access to the `ExtensionDelegate` lifecycle events: +/// Each application plugin has access to the `ExtensionDelegate` lifecycle events: /// /// struct LoggerPlugin: ExtensionPlugin { /// private let log = Logger() @@ -88,7 +88,7 @@ public extension ExtensionPluggableDelegate { } } -/// Conforming to an app module and added to `ExtensionDelegate.application()` will trigger events. +/// Conforming to an app plugin and added to `ExtensionDelegate.application()` will trigger events. public protocol ExtensionPlugin { func applicationDidFinishLaunching(_ application: WKExtension) From ba6a60abb6f88f2cf8536627b3ca68b87498a149 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Thu, 5 Dec 2019 22:38:14 -0500 Subject: [PATCH 15/31] Minor code convention and comments fixes --- .../ZamzamCore/Extensions/FileManager.swift | 36 ++++++++++--------- .../Controls/Protocols/CellIdentifiable.swift | 8 ++--- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Sources/ZamzamCore/Extensions/FileManager.swift b/Sources/ZamzamCore/Extensions/FileManager.swift index 53f248d9..182699ad 100644 --- a/Sources/ZamzamCore/Extensions/FileManager.swift +++ b/Sources/ZamzamCore/Extensions/FileManager.swift @@ -80,24 +80,26 @@ public extension FileManager { func download(from url: String, completion: @escaping (URL?, URLResponse?, Error?) -> Void) { guard let nsURL = URL(string: url) else { return completion(nil, nil, ZamzamError.invalidData) } - URLSession.shared.downloadTask(with: nsURL) { location, response, error in - guard let location = location, error == nil else { return completion(nil, nil, error) } - - // Construct file destination - let destination = self.url(of: nsURL.lastPathComponent, from: .cachesDirectory) - - // Delete local file if it exists to overwrite - try? self.removeItem(at: destination) - - // Store remote file locally - do { - try self.moveItem(at: location, to: destination) - } catch { - return completion(nil, nil, error) + URLSession.shared + .downloadTask(with: nsURL) { location, response, error in + guard let location = location, error == nil else { return completion(nil, nil, error) } + + // Construct file destination + let destination = self.url(of: nsURL.lastPathComponent, from: .cachesDirectory) + + // Delete local file if it exists to overwrite + try? self.removeItem(at: destination) + + // Store remote file locally + do { + try self.moveItem(at: location, to: destination) + } catch { + return completion(nil, nil, error) + } + + completion(destination, response, error) } - - completion(destination, response, error) - }.resume() + .resume() } } #endif diff --git a/Sources/ZamzamUI/Views/UIKit/Controls/Protocols/CellIdentifiable.swift b/Sources/ZamzamUI/Views/UIKit/Controls/Protocols/CellIdentifiable.swift index 45c34fe5..35d1380e 100644 --- a/Sources/ZamzamUI/Views/UIKit/Controls/Protocols/CellIdentifiable.swift +++ b/Sources/ZamzamUI/Views/UIKit/Controls/Protocols/CellIdentifiable.swift @@ -39,15 +39,15 @@ import UIKit /// /// switch identifier { /// case .about: -/// router.showAbout() +/// render.showAbout() /// case .subscribe: -/// router.showSubscribe() +/// render.showSubscribe() /// case .feedback: -/// router.sendFeedback( +/// render.sendFeedback( /// subject: .localizedFormat(.emailFeedbackSubject, constants.appDisplayName!) /// ) /// case .tutorial: -/// router.startTutorial() +/// render.startTutorial() /// } /// } /// } From 2597785ea085a82097408c8bd59558e1ba8540a3 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 8 Dec 2019 22:23:41 -0500 Subject: [PATCH 16/31] Deprecated dependency injection in favor of constructor injection in iOS 13+ --- .../Dependencies.swift => Deprecated.swift} | 68 +--- Tests/ZamzamCoreTests/DependencyTests.swift | 303 ------------------ 2 files changed, 14 insertions(+), 357 deletions(-) rename Sources/ZamzamCore/{Utilities/Dependencies.swift => Deprecated.swift} (56%) delete mode 100644 Tests/ZamzamCoreTests/DependencyTests.swift diff --git a/Sources/ZamzamCore/Utilities/Dependencies.swift b/Sources/ZamzamCore/Deprecated.swift similarity index 56% rename from Sources/ZamzamCore/Utilities/Dependencies.swift rename to Sources/ZamzamCore/Deprecated.swift index adf11c72..d689af8b 100644 --- a/Sources/ZamzamCore/Utilities/Dependencies.swift +++ b/Sources/ZamzamCore/Deprecated.swift @@ -1,65 +1,29 @@ // -// A Swift micro-library that provides lightweight dependency injection. +// File.swift +// // -// Inspired by: -// https://dagger.dev -// https://github.com/hmlongco/Resolver -// https://github.com/InsertKoinIO/koin -// -// Created by Basem Emara on 2019-09-06. -// Copyright © 2019 Zamzam Inc. All rights reserved. +// Created by Basem Emara on 2019-12-08. // import Foundation -/// A dependency collection that resolves object instances through the `@Inject` property wrapper. -/// -/// class AppDelegate: UIResponder, UIApplicationDelegate { -/// -/// private let dependencies = Dependencies { -/// Module { WidgetModule() as WidgetModuleType } -/// Module { SampleModule() as SampleModuleType } -/// } -/// -/// override init() { -/// super.init() -/// dependencies.build() -/// } -/// } -/// -/// // Some time later... -/// -/// class ViewController: UIViewController { -/// -/// @Inject private var widgetService: WidgetServiceType -/// @Inject private var sampleService: SampleServiceType -/// -/// override func viewDidLoad() { -/// super.viewDidLoad() -/// -/// print(widgetService.test()) -/// print(sampleService.test()) -/// } -/// } +@available(*, deprecated, message: "Use constructor injection instead.") public class Dependencies { /// Stored object instance factories. private var modules: [String: Module] = [:] private init() {} deinit { modules.removeAll() } -} - -private extension Dependencies { /// Registers a specific type and its instantiating factory. - func add(module: Module) { + private func add(module: Module) { modules[module.name] = module } - + /// Resolves through inference and returns an instance of the given type from the current default container. /// /// If the dependency is not found, an exception will occur. - func resolve(for name: String? = nil) -> T { + private func resolve(for name: String? = nil) -> T { let name = name ?? String(describing: T.self) guard let component: T = modules[name]?.resolve() as? T else { @@ -68,40 +32,36 @@ private extension Dependencies { return component } -} - -// MARK: - Public API - -public extension Dependencies { + /// Composition root container of dependencies. fileprivate static var root = Dependencies() /// Construct dependency resolutions. - convenience init(@ModuleBuilder _ modules: () -> [Module]) { + public convenience init(@ModuleBuilder _ modules: () -> [Module]) { self.init() modules().forEach { add(module: $0) } } /// Construct dependency resolution. - convenience init(@ModuleBuilder _ module: () -> Module) { + public convenience init(@ModuleBuilder _ module: () -> Module) { self.init() add(module: module()) } /// Assigns the current container to the composition root. - func build() { + public func build() { // Used later in property wrapper Self.root = self } /// DSL for declaring modules within the container dependency initializer. - @_functionBuilder struct ModuleBuilder { + public @_functionBuilder struct ModuleBuilder { public static func buildBlock(_ modules: Module...) -> [Module] { modules } public static func buildBlock(_ module: Module) -> Module { module } } } -/// A type that contributes to the object graph. +@available(*, deprecated, message: "Use constructor injection instead.") public struct Module { fileprivate let name: String fileprivate let resolve: () -> Any @@ -112,7 +72,7 @@ public struct Module { } } -/// Resolves an instance from the dependency injection container. +@available(*, deprecated, message: "Use constructor injection instead.") @propertyWrapper public class Inject { private let name: String? diff --git a/Tests/ZamzamCoreTests/DependencyTests.swift b/Tests/ZamzamCoreTests/DependencyTests.swift deleted file mode 100644 index 503d11a3..00000000 --- a/Tests/ZamzamCoreTests/DependencyTests.swift +++ /dev/null @@ -1,303 +0,0 @@ -// -// File.swift -// -// -// Created by Basem Emara on 2019-09-27. -// - -import XCTest -import ZamzamCore - -final class DependencyTests: XCTestCase { - - private static let dependencies = Dependencies { - Module { WidgetModule() as WidgetModuleType } - Module { SampleModule() as SampleModuleType } - Module("abc") { SampleModule(value: "123") as SampleModuleType } - Module { SomeClass() as SomeClassType } - } - - @Inject private var widgetModule: WidgetModuleType - @Inject private var sampleModule: SampleModuleType - @Inject("abc") private var sampleModule2: SampleModuleType - @Inject private var someClass: SomeClassType - - private lazy var widgetProvider: WidgetProviderType = widgetModule.component() - private lazy var someObject: SomeObjectType = sampleModule.component() - private lazy var anotherObject: AnotherObjectType = sampleModule.component() - private lazy var viewModelObject: ViewModelObjectType = sampleModule.component() - private lazy var viewControllerObject: ViewControllerObjectType = sampleModule.component() - - override class func setUp() { - super.setUp() - dependencies.build() - } -} - -// MARK: - Test Cases - -extension DependencyTests { - - func testResolver() { - // Given - let widgetModuleResult = widgetModule.test() - let sampleModuleResult = sampleModule.test() - let sampleModule2Result = sampleModule2.test() - let widgetResult = widgetProvider.fetch(id: 3) - let someResult = someObject.testAbc() - let anotherResult = anotherObject.testXyz() - let viewModelResult = viewModelObject.testLmn() - let viewModelNestedResult = viewModelObject.testLmnNested() - let viewControllerResult = viewControllerObject.testRst() - let viewControllerNestedResult = viewControllerObject.testRstNested() - - // Then - XCTAssertEqual(widgetModuleResult, "WidgetModule.test()") - XCTAssertEqual(sampleModuleResult, "SampleModule.test()") - XCTAssertEqual(sampleModule2Result, "SampleModule.test()123") - XCTAssertEqual(widgetResult, "|MediaRealmStore.3||MediaNetworkRemote.3|") - XCTAssertEqual(someResult, "SomeObject.testAbc") - XCTAssertEqual(anotherResult, "AnotherObject.testXyz|SomeObject.testAbc") - XCTAssertEqual(viewModelResult, "SomeViewModel.testLmn|SomeObject.testAbc") - XCTAssertEqual(viewModelNestedResult, "SomeViewModel.testLmnNested|AnotherObject.testXyz|SomeObject.testAbc") - XCTAssertEqual(viewControllerResult, "SomeViewController.testRst|SomeObject.testAbc") - XCTAssertEqual(viewControllerNestedResult, "SomeViewController.testRstNested|AnotherObject.testXyz|SomeObject.testAbc") - } -} - -extension DependencyTests { - - func testNumberOfInstances() { - let instance1 = someClass - let instance2 = someClass - XCTAssertEqual(instance1.id, instance2.id) - } -} - -// MARK: - Subtypes - -extension DependencyTests { - - struct WidgetModule: WidgetModuleType { - - func component() -> WidgetProviderType { - WidgetProvider( - store: component(), - remote: component() - ) - } - - func component() -> WidgetRemote { - WidgetNetworkRemote(httpService: component()) - } - - func component() -> WidgetStore { - WidgetRealmStore() - } - - func component() -> HTTPServiceType { - HTTPService() - } - - func test() -> String { - "WidgetModule.test()" - } - } - - struct SampleModule: SampleModuleType { - let value: String? - - init(value: String? = nil) { - self.value = value - } - - func component() -> SomeObjectType { - SomeObject() - } - - func component() -> AnotherObjectType { - AnotherObject(someObject: component()) - } - - func component() -> ViewModelObjectType { - SomeViewModel( - someObject: component(), - anotherObject: component() - ) - } - - func component() -> ViewControllerObjectType { - SomeViewController() - } - - func test() -> String { - "SampleModule.test()\(value ?? "")" - } - } - - struct SomeObject: SomeObjectType { - func testAbc() -> String { - "SomeObject.testAbc" - } - } - - class SomeClass: SomeClassType { - let id: String - - init() { - self.id = UUID().uuidString - } - } - - struct AnotherObject: AnotherObjectType { - private let someObject: SomeObjectType - - init(someObject: SomeObjectType) { - self.someObject = someObject - } - - func testXyz() -> String { - "AnotherObject.testXyz|" + someObject.testAbc() - } - } - - struct SomeViewModel: ViewModelObjectType { - private let someObject: SomeObjectType - private let anotherObject: AnotherObjectType - - init(someObject: SomeObjectType, anotherObject: AnotherObjectType) { - self.someObject = someObject - self.anotherObject = anotherObject - } - - func testLmn() -> String { - "SomeViewModel.testLmn|" + someObject.testAbc() - } - - func testLmnNested() -> String { - "SomeViewModel.testLmnNested|" + anotherObject.testXyz() - } - } - - class SomeViewController: ViewControllerObjectType { - @Inject private var module: SampleModuleType - - private lazy var someObject: SomeObjectType = module.component() - private lazy var anotherObject: AnotherObjectType = module.component() - - func testRst() -> String { - "SomeViewController.testRst|" + someObject.testAbc() - } - - func testRstNested() -> String { - "SomeViewController.testRstNested|" + anotherObject.testXyz() - } - } - - struct WidgetProvider: WidgetProviderType { - private let store: WidgetStore - private let remote: WidgetRemote - - init(store: WidgetStore, remote: WidgetRemote) { - self.store = store - self.remote = remote - } - - func fetch(id: Int) -> String { - store.fetch(id: id) - + remote.fetch(id: id) - } - } - - struct WidgetNetworkRemote: WidgetRemote { - private let httpService: HTTPServiceType - - init(httpService: HTTPServiceType) { - self.httpService = httpService - } - - func fetch(id: Int) -> String { - "|MediaNetworkRemote.\(id)|" - } - } - - struct WidgetRealmStore: WidgetStore { - - func fetch(id: Int) -> String { - "|MediaRealmStore.\(id)|" - } - - func createOrUpdate(_ request: String) -> String { - "MediaRealmStore.createOrUpdate\(request)" - } - } - - struct HTTPService: HTTPServiceType { - - func get(url: String) -> String { - "HTTPService.get" - } - - func post(url: String) -> String { - "HTTPService.post" - } - } -} - -// MARK: API - -protocol WidgetModuleType { - func component() -> WidgetProviderType - func component() -> WidgetRemote - func component() -> WidgetStore - func component() -> HTTPServiceType - func test() -> String -} - -protocol SampleModuleType { - func component() -> SomeObjectType - func component() -> AnotherObjectType - func component() -> ViewModelObjectType - func component() -> ViewControllerObjectType - func test() -> String -} - -protocol SomeObjectType { - func testAbc() -> String -} - -protocol SomeClassType { - var id: String { get } -} - -protocol AnotherObjectType { - func testXyz() -> String -} - -protocol ViewModelObjectType { - func testLmn() -> String - func testLmnNested() -> String -} - -protocol ViewControllerObjectType { - func testRst() -> String - func testRstNested() -> String -} - -protocol WidgetStore { - func fetch(id: Int) -> String - func createOrUpdate(_ request: String) -> String -} - -protocol WidgetRemote { - func fetch(id: Int) -> String -} - -protocol WidgetProviderType { - func fetch(id: Int) -> String -} - -protocol HTTPServiceType { - func get(url: String) -> String - func post(url: String) -> String -} From 163d6715bb87989a97edc59b8c7378c4c09f7899 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 8 Dec 2019 22:25:21 -0500 Subject: [PATCH 17/31] Remove deprecated --- README.md | 35 ----------- Sources/ZamzamCore/Deprecated.swift | 96 ----------------------------- 2 files changed, 131 deletions(-) delete mode 100644 Sources/ZamzamCore/Deprecated.swift diff --git a/README.md b/README.md index 5fac5aa7..f74e4bab 100644 --- a/README.md +++ b/README.md @@ -728,41 +728,6 @@ BackgroundTask.run(for: application) { task in ### Utilities -
-Dependencies - -> Lightweight dependency injection via property wrapper ([read more](https://basememara.com/swift-dependency-injection-via-property-wrapper/)): -```swift -class AppDelegate: UIResponder, UIApplicationDelegate { - - private let dependencies = Dependencies { - Module { WidgetModule() as WidgetModuleType } - Module { SampleModule() as SampleModuleType } - } - - override init() { - super.init() - dependencies.build() - } -} - -// Some time later... - -class ViewController: UIViewController { - - @Inject private var widgetService: WidgetServiceType - @Inject private var sampleService: SampleServiceType - - override func viewDidLoad() { - super.viewDidLoad() - - print(widgetService.test()) - print(sampleService.test()) - } -} -``` -
-
Localization diff --git a/Sources/ZamzamCore/Deprecated.swift b/Sources/ZamzamCore/Deprecated.swift deleted file mode 100644 index d689af8b..00000000 --- a/Sources/ZamzamCore/Deprecated.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// File.swift -// -// -// Created by Basem Emara on 2019-12-08. -// - -import Foundation - -@available(*, deprecated, message: "Use constructor injection instead.") -public class Dependencies { - /// Stored object instance factories. - private var modules: [String: Module] = [:] - - private init() {} - deinit { modules.removeAll() } - - /// Registers a specific type and its instantiating factory. - private func add(module: Module) { - modules[module.name] = module - } - - /// Resolves through inference and returns an instance of the given type from the current default container. - /// - /// If the dependency is not found, an exception will occur. - private func resolve(for name: String? = nil) -> T { - let name = name ?? String(describing: T.self) - - guard let component: T = modules[name]?.resolve() as? T else { - fatalError("Dependency '\(T.self)' not resolved!") - } - - return component - } - - /// Composition root container of dependencies. - fileprivate static var root = Dependencies() - - /// Construct dependency resolutions. - public convenience init(@ModuleBuilder _ modules: () -> [Module]) { - self.init() - modules().forEach { add(module: $0) } - } - - /// Construct dependency resolution. - public convenience init(@ModuleBuilder _ module: () -> Module) { - self.init() - add(module: module()) - } - - /// Assigns the current container to the composition root. - public func build() { - // Used later in property wrapper - Self.root = self - } - - /// DSL for declaring modules within the container dependency initializer. - public @_functionBuilder struct ModuleBuilder { - public static func buildBlock(_ modules: Module...) -> [Module] { modules } - public static func buildBlock(_ module: Module) -> Module { module } - } -} - -@available(*, deprecated, message: "Use constructor injection instead.") -public struct Module { - fileprivate let name: String - fileprivate let resolve: () -> Any - - public init(_ name: String? = nil, _ resolve: @escaping () -> T) { - self.name = name ?? String(describing: T.self) - self.resolve = resolve - } -} - -@available(*, deprecated, message: "Use constructor injection instead.") -@propertyWrapper -public class Inject { - private let name: String? - private var storage: Value? - - public var wrappedValue: Value { - storage ?? { - let value: Value = Dependencies.root.resolve(for: name) - storage = value // Reuse instance for later - return value - }() - } - - public init() { - self.name = nil - } - - public init(_ name: String) { - self.name = name - } -} From 6a527a58e04e913161c0e9388692c0c6e0421e0d Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Tue, 17 Dec 2019 10:01:37 -0500 Subject: [PATCH 18/31] Remove unnecessary protocols the consumers should create themselves --- Sources/ZamzamUI/Scene/AppAPI.swift | 21 --------------------- Sources/ZamzamUI/Scene/AppActionable.swift | 10 ---------- Sources/ZamzamUI/Scene/AppDisplayable.swift | 19 ------------------- Sources/ZamzamUI/Scene/AppPresentable.swift | 10 ---------- Sources/ZamzamUI/Scene/AppRoutable.swift | 21 --------------------- 5 files changed, 81 deletions(-) delete mode 100644 Sources/ZamzamUI/Scene/AppAPI.swift delete mode 100644 Sources/ZamzamUI/Scene/AppActionable.swift delete mode 100644 Sources/ZamzamUI/Scene/AppDisplayable.swift delete mode 100644 Sources/ZamzamUI/Scene/AppPresentable.swift delete mode 100644 Sources/ZamzamUI/Scene/AppRoutable.swift diff --git a/Sources/ZamzamUI/Scene/AppAPI.swift b/Sources/ZamzamUI/Scene/AppAPI.swift deleted file mode 100644 index 97383558..00000000 --- a/Sources/ZamzamUI/Scene/AppAPI.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// AppAPI.swift -// ZamzamKit iOS -// -// Created by Basem Emara on 2018-02-04. -// Copyright © 2018 Zamzam Inc. All rights reserved. -// - -/// App model continer for implementing global models. -public enum AppAPI { - - public struct Error { - public let title: String? - public let message: String? - - public init(title: String? = nil, message: String? = nil) { - self.title = title - self.message = message - } - } -} diff --git a/Sources/ZamzamUI/Scene/AppActionable.swift b/Sources/ZamzamUI/Scene/AppActionable.swift deleted file mode 100644 index 36f533b9..00000000 --- a/Sources/ZamzamUI/Scene/AppActionable.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// AppActionable.swift -// ZamzamKit -// -// Created by Basem Emara on 2019-05-08. -// Copyright © 2019 Zamzam Inc. All rights reserved. -// - -/// Super action for implementing global extensions. -public protocol AppActionable {} diff --git a/Sources/ZamzamUI/Scene/AppDisplayable.swift b/Sources/ZamzamUI/Scene/AppDisplayable.swift deleted file mode 100644 index aef4053d..00000000 --- a/Sources/ZamzamUI/Scene/AppDisplayable.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// AppDisplayable.swift -// ZamzamKit iOS -// -// Created by Basem Emara on 2018-02-04. -// Copyright © 2018 Zamzam Inc. All rights reserved. -// - -/// Super displayer for implementing global extensions. -public protocol AppDisplayable { - - /// Display error details. - /// - /// - Parameter error: The error details to present. - func display(error: AppAPI.Error) - - /// Hides spinners, loaders, and anything else - func endRefreshing() -} diff --git a/Sources/ZamzamUI/Scene/AppPresentable.swift b/Sources/ZamzamUI/Scene/AppPresentable.swift deleted file mode 100644 index b89dbac7..00000000 --- a/Sources/ZamzamUI/Scene/AppPresentable.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// AppPresentable.swift -// ZamzamKit -// -// Created by Basem Emara on 2019-05-08. -// Copyright © 2019 Zamzam Inc. All rights reserved. -// - -/// Super presenter for implementing global extensions. -public protocol AppPresentable {} diff --git a/Sources/ZamzamUI/Scene/AppRoutable.swift b/Sources/ZamzamUI/Scene/AppRoutable.swift deleted file mode 100644 index 2f399de5..00000000 --- a/Sources/ZamzamUI/Scene/AppRoutable.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// AppRoutable.swift -// ZamzamKit iOS -// -// Created by Basem Emara on 2018-02-04. -// Copyright © 2018 Zamzam Inc. All rights reserved. -// - -#if os(iOS) -import UIKit -#elseif os(watchOS) -import WatchKit -#endif - -public protocol AppRoutable { - #if os(iOS) - var viewController: UIViewController? { get set } - #elseif os(watchOS) - var viewController: WKInterfaceController? { get set } - #endif -} From 3ca809a322ff1d7fdcc24727ba33f2cb00dc08ef Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Tue, 17 Dec 2019 10:02:42 -0500 Subject: [PATCH 19/31] Add notification center for auto unregistering block-based observers --- .../Extensions/NotificationCenter.swift | 53 ++++++ Tests/ZamzamCoreTests/NotificationTests.swift | 164 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 Tests/ZamzamCoreTests/NotificationTests.swift diff --git a/Sources/ZamzamCore/Extensions/NotificationCenter.swift b/Sources/ZamzamCore/Extensions/NotificationCenter.swift index f2dd7d8e..9439d99c 100644 --- a/Sources/ZamzamCore/Extensions/NotificationCenter.swift +++ b/Sources/ZamzamCore/Extensions/NotificationCenter.swift @@ -43,3 +43,56 @@ public extension NotificationCenter { removeObserver(observer, name: name, object: object) } } + +public extension NotificationCenter { + + /// Wraps the observer token received from `addObserver` and automatically unregisters from the notification center on deinit. + final class Token: NSObject { + // https://oleb.net/blog/2018/01/notificationcenter-removeobserver/ + private let notificationCenter: NotificationCenter + private let token: Any + + public init(notificationCenter: NotificationCenter = .default, token: Any) { + self.notificationCenter = notificationCenter + self.token = token + } + + deinit { + notificationCenter.removeObserver(token) + } + } + + /// Adds an entry to the notification center's dispatch table that includes a notification queue and a block to add to the queue, and an optional notification name and sender. + /// + /// class MyObserver: NSObject { + /// // Auto-released in deinit + /// var token: NotificationCenter.Token? + /// + /// func setup() { + /// NotificationCenter.default.addObserver(for: .SomeName, in: &token) { + /// print("test") + /// } + /// } + /// } + /// + /// The observation is automatically released on deinit within the token wrapper; there is no need to manually unregister. + /// + /// - Parameters: + /// - name: The name of the notification for which to register the observer; that is, only notifications with this name are delivered to the observer. + /// - object: The object whose notifications the observer wants to receive; that is, only notifications sent by this sender are delivered to the observer. + /// - queue: The operation queue to which block should be added. If you pass nil, the block is run synchronously on the posting thread. + /// - token: An opaque object to act as the observer and will manage its auto release. + /// - block: The block to be executed when the notification is received. + func addObserver( + for name: NSNotification.Name, + object: Any? = nil, + queue: OperationQueue? = nil, + in token: inout Token?, + using block: @escaping (Notification) -> Void + ) { + token = Token( + notificationCenter: self, + token: addObserver(forName: name, object: object, queue: queue, using: block) + ) + } +} diff --git a/Tests/ZamzamCoreTests/NotificationTests.swift b/Tests/ZamzamCoreTests/NotificationTests.swift new file mode 100644 index 00000000..56add313 --- /dev/null +++ b/Tests/ZamzamCoreTests/NotificationTests.swift @@ -0,0 +1,164 @@ +// +// File.swift +// +// +// Created by Basem Emara on 2019-12-17. +// + +import XCTest +import ZamzamCore + +final class NotificationTests: XCTestCase { + // Test management of notification center memory and auto token release + // https://github.com/ole/NotificationUnregistering + + private static let testNotificationName = Notification.Name(rawValue: "NotificationTests.testNotificationName") + private let notificationCenter: NotificationCenter = .default +} + +extension NotificationTests { + + func testUnregisteringEndsObservation() { + var counter = 0 + var token: Any? + + // Subscribe + token = notificationCenter.addObserver(forName: Self.testNotificationName, object: nil, queue: nil) { _ in + counter += 1 + } + + // Posting notification increments counter (as expected) + notificationCenter.post(name: Self.testNotificationName, object: nil) + XCTAssertEqual(counter, 1) + + // Unregister + token.map { notificationCenter.removeObserver($0) } + token = nil + + // Post notification again + notificationCenter.post(name: Self.testNotificationName, object: nil) + XCTAssertEqual(counter, 1, "Observer block should not executed again") + } +} + +extension NotificationTests { + + func testFailingToUnregisterCausesBlockToStayAliveEvenAfterTokenIsReleased() { + var counter = 0 + var token: Any? + + // Subscribe + token = notificationCenter.addObserver(forName: Self.testNotificationName, object: nil, queue: nil) { _ in + counter += 1 + } + + // Posting notification increments counter (as expected) + notificationCenter.post(name: Self.testNotificationName) + XCTAssertEqual(counter, 1) + + // Release observation token + if token != nil { + token = nil + } + + // Post notification again + notificationCenter.post(name: Self.testNotificationName) + XCTAssertEqual(counter, 2, "Attempted released observer still incrementing counter") + } +} + +extension NotificationTests { + + func testForgettingToUnregisterCausesBlockToStayAliveEvenAfterObjectIsReleased() { + var externalCounter = 0 + + class TestObserver { + var token: Any? + + init(observerBlock: @escaping () -> ()) { + // Subscribe but never unregisters in deinit + token = NotificationCenter.default.addObserver(forName: NotificationTests.testNotificationName, object: nil, queue: nil) { _ in + observerBlock() + } + } + } + + var observer: TestObserver? = TestObserver { + externalCounter += 1 + } + + // Posting notification increments counter (as expected) + notificationCenter.post(name: Self.testNotificationName) + XCTAssertEqual(externalCounter, 1) + + // Release observer + if observer != nil { + observer = nil + } + + // Post notification again + notificationCenter.post(name: Self.testNotificationName) + XCTAssertEqual(externalCounter, 2, "Attempted released observer still incrementing counter") + } +} + +extension NotificationTests { + + func testTokenWrapperAutomaticallyUnregistersOnNil() { + var counter = 0 + var token: NotificationCenter.Token? + + // Subscribe + notificationCenter.addObserver(for: Self.testNotificationName, in: &token) { _ in + counter += 1 + } + + // Posting notification increments counter (as expected) + notificationCenter.post(name: Self.testNotificationName) + XCTAssertEqual(counter, 1) + + // Destroy observation token + if token != nil { + token = nil + } + + // Post notification again + notificationCenter.post(name: Self.testNotificationName) + XCTAssertEqual(counter, 1, "Observer block should not executed again") + } +} + +extension NotificationTests { + + func testTokenWrapperAutomaticallyUnregistersOnDeinit() { + var externalCounter = 0 + + class TestObserver { + var token: NotificationCenter.Token? + + init(observerBlock: @escaping () -> ()) { + // Subscribe but no need for deinit registration + NotificationCenter.default.addObserver(for: NotificationTests.testNotificationName, in: &token) { _ in + observerBlock() + } + } + } + + var observer: TestObserver? = TestObserver { + externalCounter += 1 + } + + // Posting notification increments counter (as expected) + notificationCenter.post(name: Self.testNotificationName) + XCTAssertEqual(externalCounter, 1) + + // Release observer + if observer != nil { + observer = nil + } + + // Post notification again + notificationCenter.post(name: Self.testNotificationName) + XCTAssertEqual(externalCounter, 1, "Observer block should not executed again") + } +} From 8fe8be152fea332ad46ef43d6dc0f1fb10113eb0 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 1 Mar 2020 08:07:28 -0500 Subject: [PATCH 20/31] Add SHA256 encryption since Swift Crypto > iOS12 --- Sources/ZamzamCore/Extensions/Data.swift | 9 ++++++ .../ZamzamCore/Extensions/String+Crypto.swift | 29 +++++++++++++++++++ Tests/ZamzamCoreTests/DataTests.swift | 7 +++++ Tests/ZamzamCoreTests/StringTests.swift | 10 +++++++ 4 files changed, 55 insertions(+) create mode 100644 Sources/ZamzamCore/Extensions/String+Crypto.swift diff --git a/Sources/ZamzamCore/Extensions/Data.swift b/Sources/ZamzamCore/Extensions/Data.swift index 75320358..78e32ef4 100644 --- a/Sources/ZamzamCore/Extensions/Data.swift +++ b/Sources/ZamzamCore/Extensions/Data.swift @@ -21,3 +21,12 @@ public extension Data { String(data: self, encoding: encoding) } } + +public extension Data { + + /// Returns a hex string representation of the data. + var hexString: String { + // https://stackoverflow.com/a/55523487/235334 + reduce("", { $0 + String(format: "%02x", $1) }) + } +} diff --git a/Sources/ZamzamCore/Extensions/String+Crypto.swift b/Sources/ZamzamCore/Extensions/String+Crypto.swift new file mode 100644 index 00000000..24b9ae1e --- /dev/null +++ b/Sources/ZamzamCore/Extensions/String+Crypto.swift @@ -0,0 +1,29 @@ +// +// String+Crypto.swift +// ZamzamKit +// +// Created by Basem Emara on 2/17/16. +// Copyright © 2016 Zamzam Inc. All rights reserved. +// + +import Foundation +import CommonCrypto + +public extension String { + + /// Returns an encrypted version of the string in hex format + func sha256() -> String? { + // https://www.agnosticdev.com/content/how-use-commoncrypto-apis-swift-5 + guard let data = data(using: .utf8) else { return nil } + + /// Creates an array of unsigned 8 bit integers that contains 32 zeros + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + + /// Performs digest calculation and places the result in the caller-supplied buffer for digest + _ = data.withUnsafeBytes { + CC_SHA256($0.baseAddress, CC_LONG(data.count), &digest) + } + + return Data(digest).hexString + } +} diff --git a/Tests/ZamzamCoreTests/DataTests.swift b/Tests/ZamzamCoreTests/DataTests.swift index 35604426..a5669fc6 100644 --- a/Tests/ZamzamCoreTests/DataTests.swift +++ b/Tests/ZamzamCoreTests/DataTests.swift @@ -17,4 +17,11 @@ final class DataTests: XCTestCase { XCTAssertNotNil(dataFromString?.string(encoding: .utf8)) XCTAssertEqual(dataFromString?.string(encoding: .utf8), "hello") } + + func testHexString() { + XCTAssertEqual( + "hbjJBJjhbjhad f7s7dtf7 sugyo87T^IT*iyug".data(using: .utf8)?.hexString, + "68626a4a424a6a68626a68616420663773376474663720737567796f3837545e49542a69797567" + ) + } } diff --git a/Tests/ZamzamCoreTests/StringTests.swift b/Tests/ZamzamCoreTests/StringTests.swift index f59136ff..784fcc5b 100644 --- a/Tests/ZamzamCoreTests/StringTests.swift +++ b/Tests/ZamzamCoreTests/StringTests.swift @@ -151,6 +151,16 @@ extension StringTests { } } +extension StringTests { + + func testSHA256() { + XCTAssertEqual( + "JYGK Udsf6ITR^%$#UTY6GI7UGdsf gdsfgSDKHkjb768stb&(&T* &".sha256(), + "71e80ab896673f757d3e378d9191d8432346d961cb59e224de31977bc23def76" + ) + } +} + extension StringTests { func testHTMLStripped() { From 0e9b2fb4a48a1d846f9d998c991d91ec6698aefd Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 1 Mar 2020 08:10:10 -0500 Subject: [PATCH 21/31] Code refinement and improvements --- README.md | 8 +- Sources/ZamzamCore/Application/AppInfo.swift | 5 +- .../ZamzamCore/Enums/DateTimeInterval.swift | 31 ---- .../Extensions/Date+Calculation.swift | 167 +++++++++++++++++ Sources/ZamzamCore/Extensions/Date.swift | 173 +----------------- .../ZamzamCore/Extensions/String+Keys.swift | 48 +++++ .../ZamzamCore/Extensions/String+Web.swift | 103 +++++++++++ Sources/ZamzamCore/Extensions/String.swift | 133 +------------- .../ZamzamCore/Preferences/Preferences.swift | 2 +- .../Stores/PreferencesDefaultsStore.swift | 2 +- Tests/ZamzamCoreTests/CollectionTests.swift | 2 +- 11 files changed, 329 insertions(+), 345 deletions(-) delete mode 100644 Sources/ZamzamCore/Enums/DateTimeInterval.swift create mode 100644 Sources/ZamzamCore/Extensions/Date+Calculation.swift create mode 100644 Sources/ZamzamCore/Extensions/String+Keys.swift create mode 100644 Sources/ZamzamCore/Extensions/String+Web.swift diff --git a/README.md b/README.md index f74e4bab..aa175917 100644 --- a/README.md +++ b/README.md @@ -320,16 +320,12 @@ extension String.Keys { static let testArray = String.Key<[Int]?>("testArray") } -// Create method or subscript for any type +// Create method or subscript for generic types using the keys extension UserDefaults { subscript(key: String.Key) -> T? { get { object(forKey: key.name) as? T } - - set { - guard let value = newValue else { return remove(key) } - set(value, forKey: key.name) - } + set { set(value, forKey: key.name) } } } diff --git a/Sources/ZamzamCore/Application/AppInfo.swift b/Sources/ZamzamCore/Application/AppInfo.swift index 1e3a0c00..831e9819 100644 --- a/Sources/ZamzamCore/Application/AppInfo.swift +++ b/Sources/ZamzamCore/Application/AppInfo.swift @@ -48,10 +48,9 @@ public extension AppInfo { var isRunningOnSimulator: Bool { // http://stackoverflow.com/questions/24869481/detect-if-app-is-being-built-for-device-or-simulator-in-swift #if targetEnvironment(simulator) - return true + return true #else - return false + return false #endif } - } diff --git a/Sources/ZamzamCore/Enums/DateTimeInterval.swift b/Sources/ZamzamCore/Enums/DateTimeInterval.swift deleted file mode 100644 index 095ea2fd..00000000 --- a/Sources/ZamzamCore/Enums/DateTimeInterval.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// DateTimeInterval.swift -// ZamzamKit -// -// Created by Basem Emara on 2018-11-22. -// Copyright © 2018 Zamzam Inc. All rights reserved. -// - -import Foundation - -/// Represents a specified number of a calendar component unit. You use `DateTimeInterval` values to do date calculations. -public enum DateTimeInterval { - case seconds(Int) - case minutes(Int) - case hours(Int) - case days(Int) - case weeks(Int) - case months(Int) - case years(Int) -} - -/// Represents a specified number of a calendar component unit for a calendar. You use `DateTimeIntervalWithCalendar` values to do date calculations. -public enum DateTimeIntervalWithCalendar { - case seconds(Int, Calendar) - case minutes(Int, Calendar) - case hours(Int, Calendar) - case days(Int, Calendar) - case weeks(Int, Calendar) - case months(Int, Calendar) - case years(Int, Calendar) -} diff --git a/Sources/ZamzamCore/Extensions/Date+Calculation.swift b/Sources/ZamzamCore/Extensions/Date+Calculation.swift new file mode 100644 index 00000000..2b900d64 --- /dev/null +++ b/Sources/ZamzamCore/Extensions/Date+Calculation.swift @@ -0,0 +1,167 @@ +// +// Date.swift +// ZamzamKit +// +// Created by Basem Emara on 2/17/16. +// Copyright © 2016 Zamzam Inc. All rights reserved. +// + +import Foundation + +/// Represents a specified number of a calendar component unit. +/// +/// You use `DateTimeInterval` values to do date calculations. +public enum DateTimeInterval { + case seconds(Int) + case minutes(Int) + case hours(Int) + case days(Int) + case weeks(Int) + case months(Int) + case years(Int) +} + +/// Represents a specified number of a calendar component unit for a calendar. +/// +/// You use `DateTimeIntervalWithCalendar` values to do date calculations. +public enum DateTimeIntervalWithCalendar { + case seconds(Int, Calendar) + case minutes(Int, Calendar) + case hours(Int, Calendar) + case days(Int, Calendar) + case weeks(Int, Calendar) + case months(Int, Calendar) + case years(Int, Calendar) +} + +public extension Date { + + /// Adds time interval components to a date for the calendar. + /// + /// Date(fromString: "1440/02/30 18:31", calendar) + .days(1, calendar) + /// + /// - Parameters: + /// - left: The date to calculate from. + /// - right: The time interval component with calendar to add to the date. + static func + (left: Date, right: DateTimeIntervalWithCalendar) -> Date { + let calendar: Calendar + let component: Calendar.Component + let value: Int + + switch right { + case .seconds(let addValue, let toCalendar): + calendar = toCalendar + component = .second + value = addValue + case .minutes(let addValue, let toCalendar): + calendar = toCalendar + component = .minute + value = addValue + case .hours(let addValue, let toCalendar): + calendar = toCalendar + component = .hour + value = addValue + case .days(let addValue, let toCalendar): + calendar = toCalendar + component = .day + value = addValue + case .weeks(let addValue, let toCalendar): + calendar = toCalendar + component = .day + value = addValue * 7 // All calendars have 7 days in a week + case .months(let addValue, let toCalendar): + calendar = toCalendar + component = .month + value = addValue + case .years(let addValue, let toCalendar): + calendar = toCalendar + component = .year + value = addValue + } + + guard value != 0 else { return left } + + return calendar.date( + byAdding: component, + value: value, + to: left + ) ?? left + } + + /// Adds time interval components to a date. + /// + /// Date(fromString: "2015/09/18 18:31") + .days(1) + /// + /// - Parameters: + /// - left: The date to calculate from. + /// - right: The time interval component to add to the date. + static func + (left: Date, right: DateTimeInterval) -> Date { + let calendar: Calendar = .current + let newRight: DateTimeIntervalWithCalendar + + switch right { + case .seconds(let value): + newRight = .seconds(value, calendar) + case .minutes(let value): + newRight = .minutes(value, calendar) + case .hours(let value): + newRight = .hours(value, calendar) + case .days(let value): + newRight = .days(value, calendar) + case .weeks(let value): + newRight = .weeks(value, calendar) + case .months(let value): + newRight = .months(value, calendar) + case .years(let value): + newRight = .years(value, calendar) + } + + return left + newRight + } + + static func - (left: Date, right: DateTimeInterval) -> Date { + let minusRight: DateTimeInterval + + switch right { + case .seconds(let value): + minusRight = .seconds(-value) + case .minutes(let value): + minusRight = .minutes(-value) + case .hours(let value): + minusRight = .hours(-value) + case .days(let value): + minusRight = .days(-value) + case .weeks(let value): + minusRight = .weeks(-value) + case .months(let value): + minusRight = .months(-value) + case .years(let value): + minusRight = .years(-value) + } + + return left + minusRight + } + + static func - (left: Date, right: DateTimeIntervalWithCalendar) -> Date { + let minusRight: DateTimeIntervalWithCalendar + + switch right { + case .seconds(let value, let calendar): + minusRight = .seconds(-value, calendar) + case .minutes(let value, let calendar): + minusRight = .minutes(-value, calendar) + case .hours(let value, let calendar): + minusRight = .hours(-value, calendar) + case .days(let value, let calendar): + minusRight = .days(-value, calendar) + case .weeks(let value, let calendar): + minusRight = .weeks(-value, calendar) + case .months(let value, let calendar): + minusRight = .months(-value, calendar) + case .years(let value, let calendar): + minusRight = .years(-value, calendar) + } + + return left + minusRight + } +} diff --git a/Sources/ZamzamCore/Extensions/Date.swift b/Sources/ZamzamCore/Extensions/Date.swift index 6885ea5e..3c757696 100644 --- a/Sources/ZamzamCore/Extensions/Date.swift +++ b/Sources/ZamzamCore/Extensions/Date.swift @@ -1,5 +1,5 @@ // -// NSDateExtension.swift +// Date.swift // ZamzamKit // // Created by Basem Emara on 2/17/16. @@ -593,177 +593,6 @@ public extension Date { } } -// MARK: - Calculations - -public extension Date { - - static func + (left: Date, right: DateTimeInterval) -> Date { - let calendar: Calendar = .current - let component: Calendar.Component - let value: Int - - switch right { - case .seconds(let addValue): - component = .second - value = addValue - case .minutes(let addValue): - component = .minute - value = addValue - case .hours(let addValue): - component = .hour - value = addValue - case .days(let addValue): - component = .day - value = addValue - case .weeks(let addValue): - component = .day - value = addValue * 7 // All calendars have 7 days in a week - case .months(let addValue): - component = .month - value = addValue - case .years(let addValue): - component = .year - value = addValue - } - - guard value != 0 else { return left } - - return calendar.date( - byAdding: component, - value: value, - to: left - ) ?? left - } - - static func - (left: Date, right: DateTimeInterval) -> Date { - let calendar: Calendar = .current - let component: Calendar.Component - let value: Int - - switch right { - case .seconds(let minusValue): - component = .second - value = minusValue - case .minutes(let minusValue): - component = .minute - value = minusValue - case .hours(let minusValue): - component = .hour - value = minusValue - case .days(let minusValue): - component = .day - value = minusValue - case .weeks(let minusValue): - component = .day - value = minusValue * 7 // All calendars have 7 days in a week - case .months(let minusValue): - component = .month - value = minusValue - case .years(let minusValue): - component = .year - value = minusValue - } - - guard value != 0 else { return left } - - return calendar.date( - byAdding: component, - value: -value, - to: left - ) ?? left - } - - static func + (left: Date, right: DateTimeIntervalWithCalendar) -> Date { - let calendar: Calendar - let component: Calendar.Component - let value: Int - - switch right { - case .seconds(let addValue, let toCalendar): - calendar = toCalendar - component = .second - value = addValue - case .minutes(let addValue, let toCalendar): - calendar = toCalendar - component = .minute - value = addValue - case .hours(let addValue, let toCalendar): - calendar = toCalendar - component = .hour - value = addValue - case .days(let addValue, let toCalendar): - calendar = toCalendar - component = .day - value = addValue - case .weeks(let addValue, let toCalendar): - calendar = toCalendar - component = .day - value = addValue * 7 // All calendars have 7 days in a week - case .months(let addValue, let toCalendar): - calendar = toCalendar - component = .month - value = addValue - case .years(let addValue, let toCalendar): - calendar = toCalendar - component = .year - value = addValue - } - - guard value != 0 else { return left } - - return calendar.date( - byAdding: component, - value: value, - to: left - ) ?? left - } - - static func - (left: Date, right: DateTimeIntervalWithCalendar) -> Date { - let calendar: Calendar - let component: Calendar.Component - let value: Int - - switch right { - case .seconds(let minusValue, let toCalendar): - calendar = toCalendar - component = .second - value = minusValue - case .minutes(let minusValue, let toCalendar): - calendar = toCalendar - component = .minute - value = minusValue - case .hours(let minusValue, let toCalendar): - calendar = toCalendar - component = .hour - value = minusValue - case .days(let minusValue, let toCalendar): - calendar = toCalendar - component = .day - value = minusValue - case .weeks(let minusValue, let toCalendar): - calendar = toCalendar - component = .day - value = minusValue * 7 // All calendars have 7 days in a week - case .months(let minusValue, let toCalendar): - calendar = toCalendar - component = .month - value = minusValue - case .years(let minusValue, let toCalendar): - calendar = toCalendar - component = .year - value = minusValue - } - - guard value != 0 else { return left } - - return calendar.date( - byAdding: component, - value: -value, - to: left - ) ?? left - } -} - private extension Date { //swiftlint:disable file_length } diff --git a/Sources/ZamzamCore/Extensions/String+Keys.swift b/Sources/ZamzamCore/Extensions/String+Keys.swift new file mode 100644 index 00000000..df9dc74f --- /dev/null +++ b/Sources/ZamzamCore/Extensions/String+Keys.swift @@ -0,0 +1,48 @@ +// +// String+Keys.swift +// ZamzamKit +// +// Created by Basem Emara on 2/17/16. +// Copyright © 2016 Zamzam Inc. All rights reserved. +// + +extension String { + + /// Keys for strongly-typed access for User Defaults, Keychain, or custom types. + /// + /// // First define keys with associated types + /// extension String.Keys { + /// static let testString = String.Key("testString") + /// static let testInt = String.Key("testInt") + /// static let testBool = String.Key("testBool") + /// static let testArray = String.Key<[Int]?>("testArray") + /// } + /// + /// // Create method or subscript for generic types using the keys + /// extension UserDefaults { + /// + /// subscript(key: String.Key) -> T? { + /// get { object(forKey: key.name) as? T } + /// set { set(value, forKey: key.name) } + /// } + /// } + /// + /// // Then use strongly-typed values + /// let testString: String? = UserDefaults.standard[.testString] + /// let testInt: Int? = UserDefaults.standard[.testInt] + /// let testBool: Bool? = UserDefaults.standard[.testBool] + /// let testArray: [Int]? = UserDefaults.standard[.testArray] + open class Keys { + fileprivate init() {} + } + + /// User Defaults key for strongly-typed access. + open class Key: Keys { + public let name: String + + public init(_ key: String) { + self.name = key + super.init() + } + } +} diff --git a/Sources/ZamzamCore/Extensions/String+Web.swift b/Sources/ZamzamCore/Extensions/String+Web.swift new file mode 100644 index 00000000..a2a359e4 --- /dev/null +++ b/Sources/ZamzamCore/Extensions/String+Web.swift @@ -0,0 +1,103 @@ +// +// String+Web.swift +// ZamzamKit +// +// Created by Basem Emara on 2/17/16. +// Copyright © 2016 Zamzam Inc. All rights reserved. +// + +import Foundation + +public extension String { + + /// URL escaped string. + var urlEncoded: String { + addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? self + } + + /// Readable string from a URL string. + var urlDecoded: String { + removingPercentEncoding ?? self + } + + /// Stripped out HTML to plain text. + /// + /// "

This is web content with a link.

".htmlStripped -> "This is web content with a link." + var htmlStripped: String { replacing(regex: "<[^>]+>", with: "") } + + /// Decode an HTML string + /// + /// let value = " 4 < 5 & 3 > 2 . Price: 12 €. @" + /// value.htmlDecoded -> " 4 < 5 & 3 > 2 . Price: 12 €. @" + var htmlDecoded: String { + // http://stackoverflow.com/questions/25607247/how-do-i-decode-html-entities-in-swift + guard !isEmpty else { return self } + + var position = startIndex + var result = "" + + // Mapping from XML/HTML character entity reference to character + // From http://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references + let characterEntities: [String: Character] = [ + // XML predefined entities: + """: "\"", + "&": "&", + "'": "'", + "<": "<", + ">": ">", + + // HTML character entity references: + " ": "\u{00a0}" + ] + + // ===== Utility functions ===== + + // Convert the number in the string to the corresponding + // Unicode character, e.g. + // decodeNumeric("64", 10) --> "@" + // decodeNumeric("20ac", 16) --> "€" + func decodeNumeric(_ string: String, base: Int32) -> Character? { + let code = UInt32(strtoul(string, nil, base)) + guard let scalar = UnicodeScalar(code) else { return nil } + return Character(scalar) + } + + // Decode the HTML character entity to the corresponding + // Unicode character, return `nil` for invalid input. + // decode("@") --> "@" + // decode("€") --> "€" + // decode("<") --> "<" + // decode("&foo;") --> nil + func decode(_ entity: String) -> Character? { + return entity.hasPrefix("&#x") || entity.hasPrefix("&#X") + ? decodeNumeric(entity[3...] ?? "", base: 16) + : entity.hasPrefix("&#") + ? decodeNumeric(entity[2...] ?? "", base: 10) + : characterEntities[entity] + } + + // Find the next '&' and copy the characters preceding it to `result`: + while let ampRange = range(of: "&", range: position..This is web content with a link.

".htmlStripped -> "This is web content with a link." - var htmlStripped: String { replacing(regex: "<[^>]+>", with: "") } - - /// Decode an HTML string - /// - /// let value = " 4 < 5 & 3 > 2 . Price: 12 €. @" - /// value.htmlDecoded -> " 4 < 5 & 3 > 2 . Price: 12 €. @" - var htmlDecoded: String { - // http://stackoverflow.com/questions/25607247/how-do-i-decode-html-entities-in-swift - guard !isEmpty else { return self } - - var position = startIndex - var result = "" - - // Mapping from XML/HTML character entity reference to character - // From http://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references - let characterEntities: [String: Character] = [ - // XML predefined entities: - """: "\"", - "&": "&", - "'": "'", - "<": "<", - ">": ">", - - // HTML character entity references: - " ": "\u{00a0}" - ] - - // ===== Utility functions ===== - - // Convert the number in the string to the corresponding - // Unicode character, e.g. - // decodeNumeric("64", 10) --> "@" - // decodeNumeric("20ac", 16) --> "€" - func decodeNumeric(_ string: String, base: Int32) -> Character? { - let code = UInt32(strtoul(string, nil, base)) - guard let scalar = UnicodeScalar(code) else { return nil } - return Character(scalar) - } - - // Decode the HTML character entity to the corresponding - // Unicode character, return `nil` for invalid input. - // decode("@") --> "@" - // decode("€") --> "€" - // decode("<") --> "<" - // decode("&foo;") --> nil - func decode(_ entity: String) -> Character? { - return entity.hasPrefix("&#x") || entity.hasPrefix("&#X") - ? decodeNumeric(entity[3...] ?? "", base: 16) - : entity.hasPrefix("&#") - ? decodeNumeric(entity[2...] ?? "", base: 10) - : characterEntities[entity] - } - - // Find the next '&' and copy the characters preceding it to `result`: - while let ampRange = range(of: "&", range: position..("testString") - /// static let testInt = String.Key("testInt") - /// static let testBool = String.Key("testBool") - /// static let testArray = String.Key<[Int]?>("testArray") - /// } - /// - /// // Then use strongly-typed values - /// let testString: String? = UserDefaults.standard[.testString] - /// let testInt: Int? = UserDefaults.standard[.testInt] - /// let testBool: Bool? = UserDefaults.standard[.testBool] - /// let testArray: [Int]? = UserDefaults.standard[.testArray] - open class Keys { - fileprivate init() {} - } - - /// User Defaults key for strongly-typed access. - open class Key: Keys { - public let name: String - - public init(_ key: String) { - self.name = key - super.init() - } - } -} - public extension Substring { /// A string value representation of the string slice. diff --git a/Sources/ZamzamCore/Preferences/Preferences.swift b/Sources/ZamzamCore/Preferences/Preferences.swift index 463b2db3..84a8d3c8 100644 --- a/Sources/ZamzamCore/Preferences/Preferences.swift +++ b/Sources/ZamzamCore/Preferences/Preferences.swift @@ -17,7 +17,7 @@ public struct Preferences: PreferencesType { public extension Preferences { func get(_ key: String.Key) -> T? { - return store.get(key) + store.get(key) } func set(_ value: T?, forKey key: String.Key) { diff --git a/Sources/ZamzamCore/Preferences/Stores/PreferencesDefaultsStore.swift b/Sources/ZamzamCore/Preferences/Stores/PreferencesDefaultsStore.swift index 29bfaf67..1a65b28c 100644 --- a/Sources/ZamzamCore/Preferences/Stores/PreferencesDefaultsStore.swift +++ b/Sources/ZamzamCore/Preferences/Stores/PreferencesDefaultsStore.swift @@ -19,7 +19,7 @@ public struct PreferencesDefaultsStore: PreferencesStore { public extension PreferencesDefaultsStore { func get(_ key: String.Key) -> T? { - return defaults[key] + defaults[key] } func set(_ value: T?, forKey key: String.Key) { diff --git a/Tests/ZamzamCoreTests/CollectionTests.swift b/Tests/ZamzamCoreTests/CollectionTests.swift index a1a25bba..50567f2a 100644 --- a/Tests/ZamzamCoreTests/CollectionTests.swift +++ b/Tests/ZamzamCoreTests/CollectionTests.swift @@ -11,7 +11,7 @@ import ZamzamCore final class CollectionTests: XCTestCase { - func testGet() { + func testSafeOutOfBoundsIndex() { // Given let sample = [1, 3, 5, 7, 9] From ac54da8d639d53662f47fd334ee29cfaca4bbdb9 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 1 Mar 2020 08:11:24 -0500 Subject: [PATCH 22/31] Add datet is beyond utility --- Sources/ZamzamCore/Extensions/Date.swift | 17 +++++++++++++++++ Tests/ZamzamCoreTests/DateTimeTests.swift | 13 +++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Sources/ZamzamCore/Extensions/Date.swift b/Sources/ZamzamCore/Extensions/Date.swift index 3c757696..c668489f 100644 --- a/Sources/ZamzamCore/Extensions/Date.swift +++ b/Sources/ZamzamCore/Extensions/Date.swift @@ -381,6 +381,23 @@ public extension Date { func isBeyond(_ date: Date, byHours hours: Double) -> Bool { timeIntervalSince(date).hours > hours } + + /// Specifies if the date is beyond the time window. + /// + /// let date = Date(fromString: "2016/03/24 11:40") + /// let fromDate = Date(fromString: "2016/03/22 09:40") + /// + /// date.isBeyond(fromDate, byDays: 1) // true + /// date.isBeyond(fromDate, byDays: 2) // true + /// date.isBeyond(fromDate, byDays: 3) // false + /// + /// - Parameters: + /// - date: Date to use as a reference. + /// - days: Time window the date is considered valid. + /// - Returns: Has the time elapsed the time window. + func isBeyond(_ date: Date, byDays days: Double) -> Bool { + timeIntervalSince(date).days > days + } } // MARK: - String helpers diff --git a/Tests/ZamzamCoreTests/DateTimeTests.swift b/Tests/ZamzamCoreTests/DateTimeTests.swift index b89a90b6..8811aa61 100644 --- a/Tests/ZamzamCoreTests/DateTimeTests.swift +++ b/Tests/ZamzamCoreTests/DateTimeTests.swift @@ -161,6 +161,19 @@ extension DateTimeTests { XCTAssertFalse(date.isBeyond(fromDate, byHours: 2)) XCTAssertFalse(date.isBeyond(fromDate, byHours: 4)) } + + func testIsBeyondDays() { + let formatter = DateFormatter().with { + $0.dateFormat = "yyyy/MM/dd HH:mm" + } + + let date = formatter.date(from: "2016/03/24 11:40")! + let fromDate = formatter.date(from: "2016/03/22 09:40")! + + XCTAssertTrue(date.isBeyond(fromDate, byDays: 1)) + XCTAssertTrue(date.isBeyond(fromDate, byDays: 2)) + XCTAssertFalse(date.isBeyond(fromDate, byDays: 3)) + } } // MARK: - String From 0468da983b77f893787665271d7b601b7bcf9acc Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 1 Mar 2020 10:28:50 -0500 Subject: [PATCH 23/31] Rename unit test for with for convention --- Tests/ZamzamCoreTests/{WithTest.swift => WithTests.swift} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename Tests/ZamzamCoreTests/{WithTest.swift => WithTests.swift} (94%) diff --git a/Tests/ZamzamCoreTests/WithTest.swift b/Tests/ZamzamCoreTests/WithTests.swift similarity index 94% rename from Tests/ZamzamCoreTests/WithTest.swift rename to Tests/ZamzamCoreTests/WithTests.swift index 0c2e68d9..69837a0f 100644 --- a/Tests/ZamzamCoreTests/WithTest.swift +++ b/Tests/ZamzamCoreTests/WithTests.swift @@ -9,7 +9,7 @@ import XCTest import ZamzamCore -final class WithTest: XCTestCase { +final class WithTests: XCTestCase { func testWith() { let model = SomeModel().with { From 34f14c4c01e65d0aaa6e80dd04fc2517744d04a8 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sun, 1 Mar 2020 10:39:49 -0500 Subject: [PATCH 24/31] Minor code convention fixes for unit tests --- Tests/ZamzamCoreTests/CLLocationTests.swift | 7 +++-- Tests/ZamzamCoreTests/LoggingTests.swift | 32 ++++++++++----------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Tests/ZamzamCoreTests/CLLocationTests.swift b/Tests/ZamzamCoreTests/CLLocationTests.swift index 11615d2e..8ab1e120 100644 --- a/Tests/ZamzamCoreTests/CLLocationTests.swift +++ b/Tests/ZamzamCoreTests/CLLocationTests.swift @@ -10,17 +10,17 @@ import XCTest import CoreLocation import ZamzamCore -final class CLLocationTests: XCTestCase { - -} +final class CLLocationTests: XCTestCase {} extension CLLocationTests { func testMetaData() { + // Given let promise = expectation(description: "fetch location") let value = CLLocation(latitude: 43.7, longitude: -79.4) let expected = "Toronto, CA" + // When value.geocoder { defer { promise.fulfill() } @@ -30,6 +30,7 @@ extension CLLocationTests { return } + // Then XCTAssertEqual("\(locality), \(countryCode)", expected, "The location should be \(expected)") diff --git a/Tests/ZamzamCoreTests/LoggingTests.swift b/Tests/ZamzamCoreTests/LoggingTests.swift index 2403f0fd..8ca31fbe 100644 --- a/Tests/ZamzamCoreTests/LoggingTests.swift +++ b/Tests/ZamzamCoreTests/LoggingTests.swift @@ -32,19 +32,19 @@ extension LoggingTests { } } - // Then group.notify(queue: .global()) { - XCTAssertEqual(logStore.entries[.verbose], ["\(LogAPI.Level.verbose) test"]) - XCTAssertEqual(logStore.entries[.debug], ["\(LogAPI.Level.debug) test"]) - XCTAssertEqual(logStore.entries[.info], ["\(LogAPI.Level.info) test"]) - XCTAssertEqual(logStore.entries[.warning], ["\(LogAPI.Level.warning) test"]) - XCTAssertEqual(logStore.entries[.error], ["\(LogAPI.Level.error) test"]) - XCTAssertEqual(logStore.entries[.none], []) - promise.fulfill() } wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(logStore.entries[.verbose], ["\(LogAPI.Level.verbose) test"]) + XCTAssertEqual(logStore.entries[.debug], ["\(LogAPI.Level.debug) test"]) + XCTAssertEqual(logStore.entries[.info], ["\(LogAPI.Level.info) test"]) + XCTAssertEqual(logStore.entries[.warning], ["\(LogAPI.Level.warning) test"]) + XCTAssertEqual(logStore.entries[.error], ["\(LogAPI.Level.error) test"]) + XCTAssertEqual(logStore.entries[.none], []) } } @@ -125,19 +125,19 @@ extension LoggingTests { } } - // Then group.notify(queue: .global()) { - XCTAssertEqual(logStore.entries[.verbose]?.count, iterations) - XCTAssertEqual(logStore.entries[.debug]?.count, iterations) - XCTAssertEqual(logStore.entries[.info]?.count, iterations) - XCTAssertEqual(logStore.entries[.warning]?.count, iterations) - XCTAssertEqual(logStore.entries[.error]?.count, iterations) - XCTAssert(logStore.entries[.none]?.isEmpty == true) - promise.fulfill() } wait(for: [promise], timeout: 30) + + // Then + XCTAssertEqual(logStore.entries[.verbose]?.count, iterations) + XCTAssertEqual(logStore.entries[.debug]?.count, iterations) + XCTAssertEqual(logStore.entries[.info]?.count, iterations) + XCTAssertEqual(logStore.entries[.warning]?.count, iterations) + XCTAssertEqual(logStore.entries[.error]?.count, iterations) + XCTAssert(logStore.entries[.none]?.isEmpty == true) } } From ff630672167c19c7151cafa902883a2705825ffa Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Mon, 2 Mar 2020 09:05:35 -0500 Subject: [PATCH 25/31] Minor code convention fixes --- .../ZamzamCore/Enums/ScheduleInterval.swift | 19 ------------------ .../UNUserNotificationCenter.swift | 20 +++++++++++++++++-- Tests/ZamzamCoreTests/DecodableTests.swift | 2 +- 3 files changed, 19 insertions(+), 22 deletions(-) delete mode 100644 Sources/ZamzamCore/Enums/ScheduleInterval.swift diff --git a/Sources/ZamzamCore/Enums/ScheduleInterval.swift b/Sources/ZamzamCore/Enums/ScheduleInterval.swift deleted file mode 100644 index 430b4407..00000000 --- a/Sources/ZamzamCore/Enums/ScheduleInterval.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// RepeatInterval.swift -// ZamzamKit -// -// Created by Basem Emara on 2/3/17. -// Copyright © 2017 Zamzam Inc. All rights reserved. -// - -import Foundation - -public enum ScheduleInterval { - case once - case minute - case hour - case day - case week - case month - case year -} diff --git a/Sources/ZamzamNotification/UNUserNotificationCenter.swift b/Sources/ZamzamNotification/UNUserNotificationCenter.swift index dd8a490c..c5b611f9 100644 --- a/Sources/ZamzamNotification/UNUserNotificationCenter.swift +++ b/Sources/ZamzamNotification/UNUserNotificationCenter.swift @@ -245,6 +245,19 @@ public extension UNUserNotificationCenter { add(request, withCompletionHandler: completion) } +} + +public extension UNUserNotificationCenter { + + enum ScheduleInterval { + case once + case minute + case hour + case day + case week + case month + case year + } /// Schedules a local notification for delivery. /// @@ -305,8 +318,11 @@ public extension UNUserNotificationCenter { add(request, withCompletionHandler: completion) } +} + +#if os(iOS) +public extension UNUserNotificationCenter { - #if os(iOS) /// Schedules a local notification for delivery. /// /// - Parameters: @@ -345,8 +361,8 @@ public extension UNUserNotificationCenter { add(request, withCompletionHandler: completion) } - #endif } +#endif public extension UNUserNotificationCenter { diff --git a/Tests/ZamzamCoreTests/DecodableTests.swift b/Tests/ZamzamCoreTests/DecodableTests.swift index 1126598d..a428d392 100644 --- a/Tests/ZamzamCoreTests/DecodableTests.swift +++ b/Tests/ZamzamCoreTests/DecodableTests.swift @@ -71,7 +71,7 @@ extension DecodableTests { extension DecodableTests { - func testErrorParsing() { + func testAnyDecodable() { // Given let jsonString = """ { From 378b80a053ad88e2236b62b99535f11f1c472307 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Mon, 2 Mar 2020 09:20:18 -0500 Subject: [PATCH 26/31] Add string charactter sttrip and replace extension --- README.md | 35 +++++++++++++++++ Sources/ZamzamCore/Extensions/String.swift | 45 ++++++++++++++++++++++ Tests/ZamzamCoreTests/StringTests.swift | 34 ++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/README.md b/README.md index aa175917..7594a088 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,41 @@ value[99] // nil "1234567890".separated(every: 2, with: "-") // "12-34-56-78-90" ``` +> Remove the characters contained in a given set: +```swift +let string = """ + { 0 1 + 2 34 + 56 7 8 + 9 + } + """ + +string.strippingCharacters(in: .whitespacesAndNewlines) // {0123456789} +``` + +> Replace the characters contained in a givenharacter set with another string: +```swift +let set = CharacterSet.alphanumerics + .insert(charactersIn: "_") + .inverted + +let string = """ + _abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ + 0{1 2<3>4@5#6`7~8?9,0 + + 1 + """ + +string.replacingCharacters(in: set, with: "_") //_abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0_1_2_3_4_5_6_7_8_9_0__1 +``` + +> Get an encrypted version of the string in hex format: +```swift +"test@example.com".sha256() // 973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b +``` + > Match using a regular expression pattern: ```swift "1234567890".match(regex: "^[0-9]+?$") // true diff --git a/Sources/ZamzamCore/Extensions/String.swift b/Sources/ZamzamCore/Extensions/String.swift index 38976d32..f3b16928 100644 --- a/Sources/ZamzamCore/Extensions/String.swift +++ b/Sources/ZamzamCore/Extensions/String.swift @@ -164,6 +164,51 @@ public extension String { .joined(separator: separator) ) } + + /// Returns a new string made by removing the characters contained in a given set. + /// + /// let string = """ + /// { 0 1 + /// 2 34 + /// 56 7 8 + /// 9 + /// } + /// """ + /// + /// string.strippingCharacters(in: .whitespacesAndNewlines) + /// // {0123456789} + /// + /// - Parameters: + /// - set: A set of character values to remove. + /// - Returns: The string with the removed characters. + func strippingCharacters(in set: CharacterSet) -> String { + replacingCharacters(in: set, with: "") + } + + /// Returns a new string made by replacing the characters contained in a given set with another string. + /// + /// let set = CharacterSet.alphanumerics + /// .insert(charactersIn: "_") + /// .inverted + /// + /// let string = """ + /// _abcdefghijklmnopqrstuvwxyz + /// ABCDEFGHIJKLMNOPQRSTUVWXYZ + /// 0{1 2<3>4@5#6`7~8?9,0 + /// + /// 1 + /// """ + /// + /// string.replacingCharacters(in: set, with: "_") + /// //_abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0_1_2_3_4_5_6_7_8_9_0__1 + /// + /// - Parameters: + /// - set: A set of character values to replace. + /// - string: A string to replace with. + /// - Returns: The string with the replaced characters. + func replacingCharacters(in set: CharacterSet, with string: String) -> String { + components(separatedBy: set).joined(separator: string) + } } // MARK: - Regular Expression diff --git a/Tests/ZamzamCoreTests/StringTests.swift b/Tests/ZamzamCoreTests/StringTests.swift index 784fcc5b..5ca2793a 100644 --- a/Tests/ZamzamCoreTests/StringTests.swift +++ b/Tests/ZamzamCoreTests/StringTests.swift @@ -124,6 +124,40 @@ extension StringTests { XCTAssertEqual("112312451".separated(every: 3, with: ":"), "112:312:451") XCTAssertEqual("112312451".separated(every: 4, with: ":"), "1123:1245:1") } + + func testStrippingWhitespaceAndNewlines() { + let string = """ + { 0 1 + 2 34 + 56 7 8 + 9 + } + """ + + XCTAssertEqual( + string.strippingCharacters(in: .whitespacesAndNewlines), + "{0123456789}" + ) + } + + func testReplacingCharacters() { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "_") + let disallowed = allowed.inverted + + let string = """ + _abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ + 0{1 2<3>4@5#6`7~8?9,0 + + 1 + """ + + XCTAssertEqual( + string.replacingCharacters(in: disallowed, with: "_"), + "_abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0_1_2_3_4_5_6_7_8_9_0__1" + ) + } } extension StringTests { From 6d72dd40d0be623b225249a80e357a6ee8413a09 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sat, 7 Mar 2020 11:35:54 -0500 Subject: [PATCH 27/31] Code convention and comments fixes --- README.md | 2 +- .../Application/ApplicationPluggableDelegate.swift | 3 ++- Sources/ZamzamCore/Application/BackgroundTask.swift | 6 +++--- Sources/ZamzamCore/Extensions/Equatable.swift | 4 ++-- Sources/ZamzamCore/Utilities/Debouncer.swift | 4 +++- Sources/ZamzamCore/Utilities/Localizable.swift | 1 + Sources/ZamzamCore/Utilities/Synchronized.swift | 5 ++++- Sources/ZamzamCore/Utilities/Throttler.swift | 4 +++- .../ZamzamUI/Views/UIKit/Controls/BadgeBarButtonItem.swift | 3 ++- Sources/ZamzamUI/Views/UIKit/Controls/GradientView.swift | 3 ++- .../Views/UIKit/Controls/IntrinsicHeightDataView.swift | 5 ++--- .../Views/UIKit/Controls/NextResponderTextField.swift | 5 +++-- 12 files changed, 28 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7594a088..a530ad6d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ tabBarController.tabBar.items?[safe: 3]?.selectedImage = UIImage("my-image") "b".within(["a", "b", "c"]) // true let status: OrderStatus = .cancelled -status.within([.requeseted, .accepted, .inProgress]) // false +status.within([.requested, .accepted, .inProgress]) // false ```
diff --git a/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift b/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift index 05bb6e75..9dfabc2d 100644 --- a/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift +++ b/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift @@ -1,9 +1,10 @@ // // ApplicationPluggableDelegate.swift // ZamzamKit iOS -// https://github.com/fmo91/PluggableApplicationDelegate // // Created by Basem Emara on 2018-01-28. +// https://github.com/fmo91/PluggableApplicationDelegate +// // Copyright © 2018 Zamzam Inc. All rights reserved. // diff --git a/Sources/ZamzamCore/Application/BackgroundTask.swift b/Sources/ZamzamCore/Application/BackgroundTask.swift index d1e088bf..63c10926 100644 --- a/Sources/ZamzamCore/Application/BackgroundTask.swift +++ b/Sources/ZamzamCore/Application/BackgroundTask.swift @@ -1,9 +1,10 @@ // // BackgroundTask.swift -// https://gist.github.com/phatmann/e96958529cc86ff584a9 // ZamzamKit // // Created by Basem Emara on 3/15/17. +// https://gist.github.com/phatmann/e96958529cc86ff584a9 +// // Copyright © 2017 Zamzam Inc. All rights reserved. // @@ -13,7 +14,7 @@ import UIKit /// Encapsulate iOS background tasks public class BackgroundTask { private let application: UIApplication - fileprivate var identifier: UIBackgroundTaskIdentifier = .invalid + private var identifier: UIBackgroundTaskIdentifier = .invalid private init(application: UIApplication) { self.application = application @@ -33,7 +34,6 @@ public class BackgroundTask { /// - application: The application instance. /// - handler: The long-running background task to execute. public static func run(for application: UIApplication, handler: (BackgroundTask) -> Void) { - // https://gist.github.com/phatmann/e96958529cc86ff584a9 let backgroundTask = BackgroundTask(application: application) // Mark the beginning of a new long-running background task diff --git a/Sources/ZamzamCore/Extensions/Equatable.swift b/Sources/ZamzamCore/Extensions/Equatable.swift index 231d2aee..0bea6cb9 100644 --- a/Sources/ZamzamCore/Extensions/Equatable.swift +++ b/Sources/ZamzamCore/Extensions/Equatable.swift @@ -15,12 +15,12 @@ public extension Equatable { /// "b".within(["a", "b", "c"]) // true /// /// let status: OrderStatus = .cancelled - /// status.within([.requeseted, .accepted, .inProgress]) // false + /// status.within([.requested, .accepted, .inProgress]) // false /// /// - Parameter values: Array of values to check. /// - Returns: Returns true if the values equals to one of the values in the array. func within (_ values: T) -> Bool where T: Sequence, T.Iterator.Element == Self { - values.contains(self) + values.contains(self) } } diff --git a/Sources/ZamzamCore/Utilities/Debouncer.swift b/Sources/ZamzamCore/Utilities/Debouncer.swift index e35e50f8..b443e3ff 100644 --- a/Sources/ZamzamCore/Utilities/Debouncer.swift +++ b/Sources/ZamzamCore/Utilities/Debouncer.swift @@ -1,8 +1,10 @@ // // Debounce.swift -// https://github.com/soffes/RateLimit +// ZamzamCore // // Created by Basem Emara on 2018-10-07. +// https://github.com/soffes/RateLimit +// // Copyright © 2018 Zamzam Inc. All rights reserved. // diff --git a/Sources/ZamzamCore/Utilities/Localizable.swift b/Sources/ZamzamCore/Utilities/Localizable.swift index 2b25ce06..bc4e7459 100644 --- a/Sources/ZamzamCore/Utilities/Localizable.swift +++ b/Sources/ZamzamCore/Utilities/Localizable.swift @@ -4,6 +4,7 @@ // // Created by Basem Emara on 6/27/17. // http://basememara.com/swifty-localization-xcode-support/ +// // Copyright © 2017 Zamzam Inc. All rights reserved. // diff --git a/Sources/ZamzamCore/Utilities/Synchronized.swift b/Sources/ZamzamCore/Utilities/Synchronized.swift index 9a71c18c..9c1a1068 100644 --- a/Sources/ZamzamCore/Utilities/Synchronized.swift +++ b/Sources/ZamzamCore/Utilities/Synchronized.swift @@ -1,8 +1,11 @@ // // Synchronized.swift -// https://basememara.com/creating-thread-safe-generic-values-in-swift/ +// ZamzamCore // // Created by Basem Emara on 2019-10-03. +// https://basememara.com/creating-thread-safe-generic-values-in-swift/ +// +// Copyright © 2019 Zamzam Inc. All rights reserved. // import Foundation diff --git a/Sources/ZamzamCore/Utilities/Throttler.swift b/Sources/ZamzamCore/Utilities/Throttler.swift index ba1e2d06..0fb7ac76 100644 --- a/Sources/ZamzamCore/Utilities/Throttler.swift +++ b/Sources/ZamzamCore/Utilities/Throttler.swift @@ -1,8 +1,10 @@ // // Throttle.swift -// https://github.com/soffes/RateLimit +// ZamzamCore // // Created by Basem Emara on 2018-10-07. +// https://github.com/soffes/RateLimit +// // Copyright © 2018 Zamzam Inc. All rights reserved. // diff --git a/Sources/ZamzamUI/Views/UIKit/Controls/BadgeBarButtonItem.swift b/Sources/ZamzamUI/Views/UIKit/Controls/BadgeBarButtonItem.swift index 986fff85..42cdbbc2 100644 --- a/Sources/ZamzamUI/Views/UIKit/Controls/BadgeBarButtonItem.swift +++ b/Sources/ZamzamUI/Views/UIKit/Controls/BadgeBarButtonItem.swift @@ -1,9 +1,10 @@ // // BadgeBarButtonItem.swift // ZamzamKit iOS -// https://gist.github.com/yonat/75a0f432d791165b1fd6 // // Created by Basem Emara on 2018-01-30. +// https://gist.github.com/yonat/75a0f432d791165b1fd6 +// // Copyright © 2018 Zamzam Inc. All rights reserved. // diff --git a/Sources/ZamzamUI/Views/UIKit/Controls/GradientView.swift b/Sources/ZamzamUI/Views/UIKit/Controls/GradientView.swift index 4901be68..5caa049e 100644 --- a/Sources/ZamzamUI/Views/UIKit/Controls/GradientView.swift +++ b/Sources/ZamzamUI/Views/UIKit/Controls/GradientView.swift @@ -1,9 +1,10 @@ // // GradientView.swift // ZamzamKit iOS -// https://medium.com/@sakhabaevegor/create-a-color-gradient-on-the-storyboard-18ccfd8158c2 // // Created by Basem Emara on 2018-08-10. +// https://medium.com/@sakhabaevegor/create-a-color-gradient-on-the-storyboard-18ccfd8158c2 +// // Copyright © 2018 Zamzam Inc. All rights reserved. // diff --git a/Sources/ZamzamUI/Views/UIKit/Controls/IntrinsicHeightDataView.swift b/Sources/ZamzamUI/Views/UIKit/Controls/IntrinsicHeightDataView.swift index c0caf96c..1e8e9e5c 100644 --- a/Sources/ZamzamUI/Views/UIKit/Controls/IntrinsicHeightDataView.swift +++ b/Sources/ZamzamUI/Views/UIKit/Controls/IntrinsicHeightDataView.swift @@ -3,11 +3,10 @@ // ZamzamKit iOS // // Created by Basem Emara on 2018-07-09. -// Copyright © 2018 Zamzam Inc. All rights reserved. -// -// Resizing UITableView to fit content: // https://stackoverflow.com/a/48623673 // +// Copyright © 2018 Zamzam Inc. All rights reserved. +// #if os(iOS) import UIKit diff --git a/Sources/ZamzamUI/Views/UIKit/Controls/NextResponderTextField.swift b/Sources/ZamzamUI/Views/UIKit/Controls/NextResponderTextField.swift index 89e227c2..7a5c1f06 100644 --- a/Sources/ZamzamUI/Views/UIKit/Controls/NextResponderTextField.swift +++ b/Sources/ZamzamUI/Views/UIKit/Controls/NextResponderTextField.swift @@ -1,10 +1,11 @@ // // NextResponderTextField.swift -// NextResponderTextField +// ZamzamUI +// +// Created by mohamede1945, @author mohamede1945 on 6/20/15. // https://github.com/mohamede1945/NextResponderTextField // https://stackoverflow.com/a/5889795 // -// Created by mohamede1945, @author mohamede1945 on 6/20/15. // Copyright (c) 2015 Varaw. All rights reserved. // From 8afb2d4cdbd7ca03bec7697a95c0f712c93931f8 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sat, 7 Mar 2020 11:37:29 -0500 Subject: [PATCH 28/31] Add URLSession wrapper and URLRequest extensions --- README.md | 59 ++ Sources/ZamzamCore/Enums/ZamzamError.swift | 18 - Sources/ZamzamCore/Errors/NetworkError.swift | 43 + Sources/ZamzamCore/Errors/ZamzamError.swift | 55 ++ .../ZamzamCore/Extensions/URLRequest.swift | 98 +++ Sources/ZamzamCore/Network/NetworkAPI.swift | 68 ++ .../ZamzamCore/Network/NetworkProvider.swift | 25 + .../Stores/NetworkURLSessionStore.swift | 73 ++ Tests/ZamzamCoreTests/NetworkTests.swift | 819 ++++++++++++++++++ 9 files changed, 1240 insertions(+), 18 deletions(-) delete mode 100644 Sources/ZamzamCore/Enums/ZamzamError.swift create mode 100644 Sources/ZamzamCore/Errors/NetworkError.swift create mode 100644 Sources/ZamzamCore/Errors/ZamzamError.swift create mode 100644 Sources/ZamzamCore/Extensions/URLRequest.swift create mode 100644 Sources/ZamzamCore/Network/NetworkAPI.swift create mode 100644 Sources/ZamzamCore/Network/NetworkProvider.swift create mode 100644 Sources/ZamzamCore/Network/Stores/NetworkURLSessionStore.swift create mode 100644 Tests/ZamzamCoreTests/NetworkTests.swift diff --git a/README.md b/README.md index a530ad6d..de7b98ef 100644 --- a/README.md +++ b/README.md @@ -624,6 +624,28 @@ url?.removeQueryItem("xyz") // "https://example.com?abc=123&lmn=tuv" ```
+
+URLRequest + +> Convenient initializer for creating a network request object: +```swift +let request = URLRequest( + url: URL(string: "https://httpbin.org/get")!, + method: .get, + parameters: [ + "abc": 123, + "def": "test456", + "xyz": true + ], + headers: [ + "Abc": "test123", + "Def": "test456", + "Xyz": "test789" + ] +) +``` +
+ ### Application
@@ -799,6 +821,43 @@ log.error("There was an error.") ```
+
+Network + +> Thin wrapper around `URLSession` for simple network requests: +```swift + let request = URLRequest( + url: URL(string: "https://httpbin.org/get")!, + method: .get, + parameters: [ + "abc": 123, + "def": "test456", + "xyz": true + ], + headers: [ + "Abc": "test123", + "Def": "test456", + "Xyz": "test789" + ] + ) + + let networkProvider = NetworkProvider( + store: NetworkURLSessionStore() + ) + + networkProvider.send(with: request) { result in + switch result { + case .success(let response): + response.data + response.headers + response.statusCode + case .failure(let error): + error.statusCode + } + } +``` +
+
SystemConfiguration diff --git a/Sources/ZamzamCore/Enums/ZamzamError.swift b/Sources/ZamzamCore/Enums/ZamzamError.swift deleted file mode 100644 index a660f15a..00000000 --- a/Sources/ZamzamCore/Enums/ZamzamError.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// ZamzamError.swift -// ZamzamKit -// -// Created by Basem Emara on 2/6/17. -// Copyright © 2017 Zamzam Inc. All rights reserved. -// - -import Foundation - -public enum ZamzamError: Error { - case general - case invalidData - case nonExistent - case notReachable - case unauthorized - case other(Error?) -} diff --git a/Sources/ZamzamCore/Errors/NetworkError.swift b/Sources/ZamzamCore/Errors/NetworkError.swift new file mode 100644 index 00000000..4e3e1c90 --- /dev/null +++ b/Sources/ZamzamCore/Errors/NetworkError.swift @@ -0,0 +1,43 @@ +// +// NetworkError.swift +// ZamzamKit +// +// Created by Basem Emara on 2020-03-01. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +import Foundation + +public struct NetworkError: Error { + + /// The original request that initiated the task. + public let request: URLRequest + + /// The data from the response. + public let data: Data? + + /// The HTTP header values from the response. + public let headers: [String: String]? + + /// The status code from the server. + public let statusCode: Int? + + /// The internal error from the network request. + public let internalError: Error? +} + +extension NetworkError: CustomStringConvertible { + + public var description: String { + """ + Error: \(internalError ?? ZamzamError.other(nil)), + Request: { + url: \(request.url?.absoluteString ?? ""), + method: \(request.httpMethod ?? "") + }, + Response: { + status: \(statusCode ?? 0) + } + """ + } +} diff --git a/Sources/ZamzamCore/Errors/ZamzamError.swift b/Sources/ZamzamCore/Errors/ZamzamError.swift new file mode 100644 index 00000000..88aaf1bb --- /dev/null +++ b/Sources/ZamzamCore/Errors/ZamzamError.swift @@ -0,0 +1,55 @@ +// +// ZamzamError.swift +// ZamzamKit +// +// Created by Basem Emara on 2/6/17. +// Copyright © 2017 Zamzam Inc. All rights reserved. +// + +import Foundation + +public enum ZamzamError: Error { + case general + case invalidData + case nonExistent + case duplicate + case unauthorized + case notReachable + case noInternet + case timeout + case parseFailure(Error?) + case cacheFailure(Error?) + case serverFailure(Error?) + case other(Error?) +} + +// MARK: - Helpers + +public extension ZamzamError { + + init(from error: NetworkError?) { + // Handle no internet + if let internalError = error?.internalError as? URLError, + internalError.code == .notConnectedToInternet { + self = .noInternet + return + } + + // Handle timeout + if let internalError = error?.internalError as? URLError, + internalError.code == .timedOut { + self = .timeout + return + } + + // Handle by status code + switch error?.statusCode { + case 400: + self = .invalidData + case 401, 403: + self = .unauthorized + default: + self = .other(error) + } + } +} diff --git a/Sources/ZamzamCore/Extensions/URLRequest.swift b/Sources/ZamzamCore/Extensions/URLRequest.swift new file mode 100644 index 00000000..cadabaa8 --- /dev/null +++ b/Sources/ZamzamCore/Extensions/URLRequest.swift @@ -0,0 +1,98 @@ +// +// URLRequest.swift +// ZamzamKit +// +// Created by Basem Emara on 2020-03-01. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +import Foundation + +public extension URLRequest { + + /// Type representing HTTP methods. + /// + /// See https://tools.ietf.org/html/rfc7231#section-4.3 + enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" + } +} + +public extension URLRequest { + + /// Creates an instance with JSON specific configurations. + /// + /// - Parameters: + /// - url: The URL of the request. + /// - method: The HTTP request method. + /// - parameters: The data sent as the message body of a request. + /// - headers: A dictionary containing all of the HTTP header fields for a request. + /// - timeoutInterval: The timeout interval of the request. If `nil`, the defaults is 10 seconds. + init( + url: URL, + method: HTTPMethod, + parameters: [String: Any]? = nil, + headers: [String: String]? = nil, + timeoutInterval: TimeInterval = 10 + ) { + // Not all HTTP methods support body + let doesSupportBody = !method.within([.get, .delete]) + + self.init( + url: !doesSupportBody + // Parameters become query string parameters for some methods + ? url.appendingQueryItems(parameters ?? [:]) + : url + ) + + self.httpMethod = method.rawValue + + self.allHTTPHeaderFields = headers + self.addValue("application/json", forHTTPHeaderField: "Accept") + self.addValue("application/json", forHTTPHeaderField: "Content-Type") + + // Parameters become serialized into body for all other HTTP methods + if let parameters = parameters, !parameters.isEmpty, doesSupportBody { + self.httpBody = try? JSONSerialization.data(withJSONObject: parameters) + } + + self.timeoutInterval = timeoutInterval + } +} + +public extension URLRequest { + + /// Creates an instance with JSON specific configurations. + /// + /// - Parameters: + /// - url: The URL of the request. + /// - method: The HTTP request method. + /// - data: The data sent as the message body of a request. + /// - headers: A dictionary containing all of the HTTP header fields for a request. + /// - timeoutInterval: The timeout interval of the request. If `nil`, the defaults is 10 seconds. + init( + url: URL, + method: HTTPMethod, + data: Data?, + headers: [String: String]? = nil, + timeoutInterval: TimeInterval = 10 + ) { + self.init(url: url) + + self.httpMethod = method.rawValue + + self.allHTTPHeaderFields = headers + self.addValue("application/json", forHTTPHeaderField: "Accept") + self.addValue("application/json", forHTTPHeaderField: "Content-Type") + + if let data = data, method != .get { + self.httpBody = data + } + + self.timeoutInterval = timeoutInterval + } +} diff --git a/Sources/ZamzamCore/Network/NetworkAPI.swift b/Sources/ZamzamCore/Network/NetworkAPI.swift new file mode 100644 index 00000000..9cdd601d --- /dev/null +++ b/Sources/ZamzamCore/Network/NetworkAPI.swift @@ -0,0 +1,68 @@ +// +// NetworkAPI.swift +// ZamzamCore +// +// Created by Basem Emara on 2020-03-01. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +import Foundation + +// Namespace +public enum NetworkAPI {} + +public protocol NetworkStore { + func send(with request: URLRequest, completion: @escaping (Result) -> Void) +} + +/// The wrapper to handle HTTP requests. +public protocol NetworkProviderType { + + /// Creates a task that retrieves the contents of a URL based on the specified request object, and calls a handler upon completion. + /// + /// let request = URLRequest( + /// url: URL(string: "https://httpbin.org/get")!, + /// method: .get, + /// parameters: [ + /// "abc": 123, + /// "def": "test456", + /// "xyz": true + /// ], + /// headers: [ + /// "Abc": "test123", + /// "Def": "test456", + /// "Xyz": "test789" + /// ] + /// ) + /// + /// let networkProvider = NetworkProvider( + /// store: NetworkURLSessionStore() + /// ) + /// + /// networkProvider.send(with: request) { result in + /// switch result { + /// case .success(let response): + /// response.data + /// response.headers + /// response.statusCode + /// case .failure(let error): + /// error.statusCode + /// } + /// } + /// + /// - Parameters: + /// - request: A network request object that provides the URL, parameters, headers, and so on. + /// - completion: The completion handler to call when the load request is complete. + func send(with request: URLRequest, completion: @escaping (Result) -> Void) +} + +// MARK: - Requests / Responses + +public extension NetworkAPI { + + struct Response { + public let data: Data? + public let headers: [String: String] + public let statusCode: Int + } +} diff --git a/Sources/ZamzamCore/Network/NetworkProvider.swift b/Sources/ZamzamCore/Network/NetworkProvider.swift new file mode 100644 index 00000000..0513106e --- /dev/null +++ b/Sources/ZamzamCore/Network/NetworkProvider.swift @@ -0,0 +1,25 @@ +// +// NetworkProvider.swift +// ZamzamCore +// +// Created by Basem Emara on 2020-03-01. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +import Foundation + +public struct NetworkProvider: NetworkProviderType { + private let store: NetworkStore + + public init(store: NetworkStore) { + self.store = store + } +} + +public extension NetworkProvider { + + func send(with request: URLRequest, completion: @escaping (Result) -> Void) { + store.send(with: request, completion: completion) + } +} + diff --git a/Sources/ZamzamCore/Network/Stores/NetworkURLSessionStore.swift b/Sources/ZamzamCore/Network/Stores/NetworkURLSessionStore.swift new file mode 100644 index 00000000..fc5f69d5 --- /dev/null +++ b/Sources/ZamzamCore/Network/Stores/NetworkURLSessionStore.swift @@ -0,0 +1,73 @@ +// +// NetworkFoundationStore.swift +// ZamzamCore +// +// Created by Basem Emara on 2020-03-01. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +import Foundation + +public struct NetworkURLSessionStore: NetworkStore { + public init() {} +} + +public extension NetworkURLSessionStore { + + func send(with request: URLRequest, completion: @escaping (Result) -> Void) { + URLSession.shared.dataTask( + with: request, + completionHandler: completion + ).resume() + } +} + +// MARK: - Helpers + +private extension URLSession { + + /// Creates a task that retrieves the contents of a URL based on the specified URL request object, and calls a handler upon completion. + /// + /// - Parameters: + /// - request: A URL request object that provides the URL, cache policy, request type, body data or body stream, and so on. + /// - completionHandler: The completion handler to call when the load request is complete. This handler is executed on the main queue. + func dataTask( + with request: URLRequest, + completionHandler: @escaping (Result) -> Void + ) -> URLSessionDataTask { + dataTask(with: request) { (data, response, error) in + if let error = error { + let networkError = NetworkError(request: request, data: nil, headers: nil, statusCode: nil, internalError: error) + DispatchQueue.main.async { completionHandler(.failure(networkError)) } + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + let networkError = NetworkError(request: request, data: nil, headers: nil, statusCode: nil, internalError: nil) + DispatchQueue.main.async { completionHandler(.failure(networkError)) } + return + } + + let headers: [String: String] = Dictionary( + uniqueKeysWithValues: httpResponse.allHeaderFields.map { ("\($0)", "\($1)") } + ) + + guard let data = data else { + let networkError = NetworkError(request: request, data: nil, headers: headers, statusCode: httpResponse.statusCode, internalError: nil) + DispatchQueue.main.async { completionHandler(.failure(networkError)) } + return + } + + guard 200..<300 ~= httpResponse.statusCode else { + let networkError = NetworkError(request: request, data: data, headers: headers, statusCode: httpResponse.statusCode, internalError: nil) + DispatchQueue.main.async { completionHandler(.failure(networkError)) } + return + } + + DispatchQueue.main.async { + let networkResponse = NetworkAPI.Response(data: data, headers: headers, statusCode: httpResponse.statusCode) + completionHandler(.success(networkResponse)) + } + } + } +} diff --git a/Tests/ZamzamCoreTests/NetworkTests.swift b/Tests/ZamzamCoreTests/NetworkTests.swift new file mode 100644 index 00000000..606f1c0c --- /dev/null +++ b/Tests/ZamzamCoreTests/NetworkTests.swift @@ -0,0 +1,819 @@ +// +// NetworkTests.swift +// ZamzamKit +// +// Created by Basem Emara on 2020-03-01. +// + +import XCTest +import ZamzamCore + +final class NetworkTests: XCTestCase { + private let jsonDecoder = JSONDecoder() + + private let networkProvider: NetworkProviderType = NetworkProvider( + store: NetworkURLSessionStore() + ) +} + +// MARK: - GET + +extension NetworkTests { + + func testGET() { + // Given + let promise = expectation(description: #function) + + let request = URLRequest( + url: URL(string: "https://httpbin.org/get")!, + method: .get + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/get") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + } +} + +extension NetworkTests { + + func testGETWithParameters() { + // Given + let promise = expectation(description: #function) + + let parameters: [String: Any] = [ + "abc": 123, + "def": "test456", + "xyz": true + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/get")!, + method: .get, + parameters: parameters + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssert(request.url?.absoluteString.contains("https://httpbin.org/get?") == true) + XCTAssert(request.url?.absoluteString.contains("abc=123") == true) + XCTAssert(request.url?.absoluteString.contains("def=test456") == true) + XCTAssert(request.url?.absoluteString.contains("xyz=true") == true) + + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + parameters.forEach { + XCTAssertEqual(model.args[$0.key], "\($0.value)") + + } + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +extension NetworkTests { + + func testGETWithHeaders() { + // Given + let promise = expectation(description: #function) + + let headers: [String: String] = [ + "Abc": "test123", + "Def": "test456", + "Xyz": "test789" + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/get")!, + method: .get, + headers: headers + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/get") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + headers.forEach { + XCTAssertEqual(model.headers[$0.key], $0.value) + + } + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +// MARK: - POST + +extension NetworkTests { + + func testPOST() { + // Given + let promise = expectation(description: #function) + + let request = URLRequest( + url: URL(string: "https://httpbin.org/post")!, + method: .post + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/post") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + } +} + +extension NetworkTests { + + func testPOSTWithParameters() { + // Given + let promise = expectation(description: #function) + + let parameters: [String: Any] = [ + "abc": 123, + "def": "test456", + "xyz": true + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/post")!, + method: .post, + parameters: parameters + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/post") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + XCTAssertEqual(model.json?["abc"]?.value as? Int, 123) + XCTAssertEqual(model.json?["def"]?.value as? String, "test456") + XCTAssertEqual(model.json?["xyz"]?.value as? Bool, true) + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +extension NetworkTests { + + func testPOSTWithHeaders() { + // Given + let promise = expectation(description: #function) + + let headers: [String: String] = [ + "Abc": "test123", + "Def": "test456", + "Xyz": "test789" + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/post")!, + method: .post, + headers: headers + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/post") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + headers.forEach { + XCTAssertEqual(model.headers[$0.key], $0.value) + + } + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +// MARK: - PATCH + +extension NetworkTests { + + func testPATCH() { + // Given + let promise = expectation(description: #function) + + let request = URLRequest( + url: URL(string: "https://httpbin.org/patch")!, + method: .patch + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/patch") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + } +} + +extension NetworkTests { + + func testPATCHWithParameters() { + // Given + let promise = expectation(description: #function) + + let parameters: [String: Any] = [ + "abc": 123, + "def": "test456", + "xyz": true + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/patch")!, + method: .patch, + parameters: parameters + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/patch") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + XCTAssertEqual(model.json?["abc"]?.value as? Int, 123) + XCTAssertEqual(model.json?["def"]?.value as? String, "test456") + XCTAssertEqual(model.json?["xyz"]?.value as? Bool, true) + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +extension NetworkTests { + + func testPATCHWithHeaders() { + // Given + let promise = expectation(description: #function) + + let headers: [String: String] = [ + "Abc": "test123", + "Def": "test456", + "Xyz": "test789" + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/patch")!, + method: .patch, + headers: headers + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/patch") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + headers.forEach { + XCTAssertEqual(model.headers[$0.key], $0.value) + + } + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +// MARK: - PUT + +extension NetworkTests { + + func testPUT() { + // Given + let promise = expectation(description: #function) + + let request = URLRequest( + url: URL(string: "https://httpbin.org/put")!, + method: .put + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/put") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + } +} + +extension NetworkTests { + + func testPUTWithParameters() { + // Given + let promise = expectation(description: #function) + + let parameters: [String: Any] = [ + "abc": 123, + "def": "test456", + "xyz": true + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/put")!, + method: .put, + parameters: parameters + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/put") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + XCTAssertEqual(model.json?["abc"]?.value as? Int, 123) + XCTAssertEqual(model.json?["def"]?.value as? String, "test456") + XCTAssertEqual(model.json?["xyz"]?.value as? Bool, true) + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +extension NetworkTests { + + func testPUTWithHeaders() { + // Given + let promise = expectation(description: #function) + + let headers: [String: String] = [ + "Abc": "test123", + "Def": "test456", + "Xyz": "test789" + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/put")!, + method: .put, + headers: headers + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/put") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + headers.forEach { + XCTAssertEqual(model.headers[$0.key], $0.value) + + } + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +// MARK: - DELETE + +extension NetworkTests { + + func testDELETE() { + // Given + let promise = expectation(description: #function) + + let request = URLRequest( + url: URL(string: "https://httpbin.org/delete")!, + method: .delete + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/delete") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + } +} + +extension NetworkTests { + + func testDELETEWithParameters() { + // Given + let promise = expectation(description: #function) + + let parameters: [String: Any] = [ + "abc": 123, + "def": "test456", + "xyz": true + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/delete")!, + method: .delete, + parameters: parameters + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssert(request.url?.absoluteString.contains("https://httpbin.org/delete?") == true) + XCTAssert(request.url?.absoluteString.contains("abc=123") == true) + XCTAssert(request.url?.absoluteString.contains("def=test456") == true) + XCTAssert(request.url?.absoluteString.contains("xyz=true") == true) + + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + parameters.forEach { + XCTAssertEqual(model.args[$0.key], "\($0.value)") + + } + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +extension NetworkTests { + + func testDELETEWithHeaders() { + // Given + let promise = expectation(description: #function) + + let headers: [String: String] = [ + "Abc": "test123", + "Def": "test456", + "Xyz": "test789" + ] + + let request = URLRequest( + url: URL(string: "https://httpbin.org/delete")!, + method: .delete, + headers: headers + ) + + var response: NetworkAPI.Response? + + // When + networkProvider.send(with: request) { + defer { promise.fulfill() } + + guard case .success(let value) = $0 else { + XCTFail("The network request failed: \(String(describing: $0.error))") + return + } + + response = value + } + + wait(for: [promise], timeout: 10) + + // Then + XCTAssertEqual(request.url?.absoluteString, "https://httpbin.org/delete") + XCTAssertNotNil(response?.data) + XCTAssertEqual(response?.headers["Content-Type"], "application/json") + XCTAssertEqual(response?.statusCode, 200) + + guard let data = response?.data else { + XCTFail("No response data was found") + return + } + + do { + let model = try jsonDecoder.decode(ResponseModel.self, from: data) + + XCTAssertEqual(model.url, request.url?.absoluteString) + + headers.forEach { + XCTAssertEqual(model.headers[$0.key], $0.value) + + } + } catch { + XCTFail("The resonse data could not be parse: \(error)") + } + } +} + +// MARK: - Helpers + +private extension NetworkTests { + + struct ResponseModel: Decodable { + let url: String + let args: [String: String] + let headers: [String: String] + let json: [String: AnyDecodable]? + } +} From f1f0d1e68f8e08322f1b238e7203aff337522944 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sat, 7 Mar 2020 11:38:28 -0500 Subject: [PATCH 29/31] Add HTTP logger --- .../Destinations/LogHTTPDestination.swift | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 Sources/ZamzamCore/Logging/Destinations/LogHTTPDestination.swift diff --git a/Sources/ZamzamCore/Logging/Destinations/LogHTTPDestination.swift b/Sources/ZamzamCore/Logging/Destinations/LogHTTPDestination.swift new file mode 100644 index 00000000..fab166c3 --- /dev/null +++ b/Sources/ZamzamCore/Logging/Destinations/LogHTTPDestination.swift @@ -0,0 +1,151 @@ +// +// LogHTTPDestination.swift +// ZamzamCore +// +// Created by Basem Emara on 2020-03-04. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +#if os(iOS) +import Foundation +import UIKit +import AdSupport + +/// Log destination for sending over HTTP. +final public class LogHTTPDestination { + private let urlRequest: URLRequest + private let maxEntriesInBuffer: Int + private let appInfo: AppInfo + private let networkService: NetworkProviderType + + private let deviceName = UIDevice.current.name + private let deviceModel = UIDevice.current.model + private var deviceIdentifier = UIDevice.current.identifierForVendor?.uuidString ?? "" + private var advertisingIdentifier = ASIdentifierManager.shared().advertisingIdentifier.uuidString + private let osVersion = UIDevice.current.systemVersion + + /// Stores the log entries in memory until it is ready to send. + private var buffer: [String] = [] { + didSet { buffer.count > maxEntriesInBuffer ? send() : nil } + } + + /// The initializer of the log destination. + /// + /// - Parameters: + /// - urlRequest: A URL load request for the destination. Leave data `nil` as this will be added to the `httpBody` upon sending. + /// - maxEntriesInBuffer: The threshold of the buffer before sending to the destination. + /// - appInfo: Provides details of the current app. + /// - networkService: The object used to send the HTTP request. + /// - notificationCenter: A notification dispatch mechanism that registers observers for flushing the buffer at certain app lifecycle events. + public init( + urlRequest: URLRequest, + maxEntriesInBuffer: Int, + appInfo: AppInfo, + networkService: NetworkProviderType, + notificationCenter: NotificationCenter + ) { + self.urlRequest = urlRequest + self.maxEntriesInBuffer = maxEntriesInBuffer + self.appInfo = appInfo + self.networkService = networkService + + notificationCenter.addObserver( + self, + selector: #selector(send), + name: UIApplication.willResignActiveNotification, + object: nil + ) + + notificationCenter.addObserver( + self, + selector: #selector(send), + name: UIApplication.willTerminateNotification, + object: nil + ) + } +} + +public extension LogHTTPDestination { + + /// Appends the log to the buffer that will be queued for later sending. + /// + /// The buffer size is determined in the initializer. Once the threshold is met, + /// the entries will be flushed and sent to the destination. The buffer is + /// also automatically flushed on the `willResignActive` and + /// `willTerminate` events. + /// + /// - Parameters: + /// - parameters: The values that will be merged and sent to the detination. + /// - level: The current level of the log entry. + /// - path: Path of the caller. + /// - function: Function of the caller. + /// - line: Line of the caller. + func write( + _ parameters: [String: Any], + level: LogAPI.Level, + path: String, + function: String, + line: Int + ) { + let session: [String: Any] = [ + "app": [ + "name": appInfo.appDisplayName ?? "Unknown", + "version": appInfo.appVersion ?? "Unknown", + "build": appInfo.appBuild ?? "Unknown", + "bundle_id": appInfo.appBundleID ?? "Unknown" + ], + "device": [ + "device_id": deviceIdentifier, + "advertising_id": advertisingIdentifier, + "device_name": deviceName, + "device_model": deviceModel, + "os_version": osVersion, + "is_testflight": appInfo.isInTestFlight, + "is_simulator": appInfo.isRunningOnSimulator + ], + "code": [ + "path": path, + "function": function, + "line": line + ] + ] + + let merged = parameters.merging(session) { (parameter, _) in parameter } + + guard let data = try? JSONSerialization.data(withJSONObject: merged, options: []), + let log = String(data: data, encoding: .utf8) else { + print("ERROR: Logger unable to serialize parameters for destination.") + return + } + + // Store in buffer for sending later + buffer.append(log) + } +} + +private extension LogHTTPDestination { + + @objc func send() { + let logs = buffer + buffer = [] + + guard let data = logs.joined(separator: "\n").data(using: .utf8) else { + debugPrint("Could not begin log destination task") + return + } + + var request = urlRequest + request.httpBody = data + + BackgroundTask.run(for: .shared) { task in + self.networkService.send(with: request) { + if case .failure(let error) = $0 { + debugPrint("Error from log destination: \(error)") + } + + task.end() + } + } + } +} +#endif From 8aaa664db85c559462033606a07d6fedfa12ef6d Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sat, 7 Mar 2020 12:56:28 -0500 Subject: [PATCH 30/31] Add UserDefaults and Keychain preferences wrappers --- README.md | 160 +++-- .../ZamzamCore/Extensions/String+Keys.swift | 48 -- .../ZamzamCore/Extensions/UserDefaults.swift | 41 -- .../Preferences/PreferencesAPI.swift | 30 - .../Secured/SecuredPreferences.swift | 32 + .../Secured/SecuredPreferencesAPI.swift | 73 +++ .../SecuredPreferencesKeychainStore.swift | 550 ++++++++++++++++++ .../{ => Shared}/Preferences.swift | 6 +- .../Preferences/Shared/PreferencesAPI.swift | 76 +++ .../Stores/PreferencesDefaultsStore.swift | 13 +- Tests/ZamzamCoreTests/PreferencesTests.swift | 114 ++++ .../SecuredPreferencesTests.swift | 108 ++++ Tests/ZamzamCoreTests/StringKeysTests.swift | 115 ---- 13 files changed, 1030 insertions(+), 336 deletions(-) delete mode 100644 Sources/ZamzamCore/Extensions/String+Keys.swift delete mode 100644 Sources/ZamzamCore/Extensions/UserDefaults.swift delete mode 100644 Sources/ZamzamCore/Preferences/PreferencesAPI.swift create mode 100644 Sources/ZamzamCore/Preferences/Secured/SecuredPreferences.swift create mode 100644 Sources/ZamzamCore/Preferences/Secured/SecuredPreferencesAPI.swift create mode 100644 Sources/ZamzamCore/Preferences/Secured/Stores/SecuredPreferencesKeychainStore.swift rename Sources/ZamzamCore/Preferences/{ => Shared}/Preferences.swift (72%) create mode 100644 Sources/ZamzamCore/Preferences/Shared/PreferencesAPI.swift rename Sources/ZamzamCore/Preferences/{ => Shared}/Stores/PreferencesDefaultsStore.swift (53%) create mode 100644 Tests/ZamzamCoreTests/PreferencesTests.swift create mode 100644 Tests/ZamzamCoreTests/SecuredPreferencesTests.swift delete mode 100644 Tests/ZamzamCoreTests/StringKeysTests.swift diff --git a/README.md b/README.md index de7b98ef..9678a69e 100644 --- a/README.md +++ b/README.md @@ -344,32 +344,6 @@ value.base64URLEncoded var value: String? = "test 123" value.isNilOrEmpty ``` - -> Strongly-typed string keys: -```swift -// First define keys -extension String.Keys { - static let testString = String.Key("testString") - static let testInt = String.Key("testInt") - static let testBool = String.Key("testBool") - static let testArray = String.Key<[Int]?>("testArray") -} - -// Create method or subscript for generic types using the keys -extension UserDefaults { - - subscript(key: String.Key) -> T? { - get { object(forKey: key.name) as? T } - set { set(value, forKey: key.name) } - } -} - -// Then use strongly-typed values -let testString: String? = UserDefaults.standard[.testString] -let testInt: Int? = UserDefaults.standard[.testInt] -let testBool: Bool? = UserDefaults.standard[.testBool] -let testArray: [Int]? = UserDefaults.standard[.testArray] -```
### Foundation+ @@ -604,45 +578,60 @@ label.attributedText = "Abc".attributed + " def " +
-URL +URLSession -> Append or remove query string parameters: +> A thin wrapper around `URLSession` and `URLRequest` for simple network requests: ```swift -let url = URL(string: "https://example.com?abc=123&lmn=tuv&xyz=987") - -url?.appendingQueryItem("def", value: "456") // "https://example.com?abc=123&lmn=tuv&xyz=987&def=456" -url?.appendingQueryItem("xyz", value: "999") // "https://example.com?abc=123&lmn=tuv&xyz=999" - -url?.appendingQueryItems([ - "def": "456", - "jkl": "777", - "abc": "333", - "lmn": nil -]) -> "https://example.com?xyz=987&def=456&abc=333&jkl=777" - -url?.removeQueryItem("xyz") // "https://example.com?abc=123&lmn=tuv" + let request = URLRequest( + url: URL(string: "https://httpbin.org/get")!, + method: .get, + parameters: [ + "abc": 123, + "def": "test456", + "xyz": true + ], + headers: [ + "Abc": "test123", + "Def": "test456", + "Xyz": "test789" + ] + ) + + let networkProvider: NetworkProviderType = NetworkProvider( + store: NetworkURLSessionStore() + ) + + networkProvider.send(with: request) { result in + switch result { + case .success(let response): + response.data + response.headers + response.statusCode + case .failure(let error): + error.statusCode + } + } ```
-URLRequest +UserDefaults -> Convenient initializer for creating a network request object: +> A thin wrapper to manage `UserDefaults`, or other storages that conform to `PreferencesStore`: ```swift -let request = URLRequest( - url: URL(string: "https://httpbin.org/get")!, - method: .get, - parameters: [ - "abc": 123, - "def": "test456", - "xyz": true - ], - headers: [ - "Abc": "test123", - "Def": "test456", - "Xyz": "test789" - ] +let preferences: PreferencesType = Preferences( + store: PreferencesDefaultsStore( + defaults: UserDefaults.standard + ) ) + +preferences.set(123, forKey: .abc) +preferences.get(.token) // 123 + +// Define strongly-typed keys +extension PreferencesAPI.Keys { + static let abc = PreferencesAPI.Key("abc") +} ```
@@ -779,6 +768,28 @@ BackgroundTask.run(for: application) { task in ``` +
+Keychain + +> A thin wrapper to manage Keychain, or other storages that conform to `SecuredPreferencesStore`: +```swift +let keychain: SecuredPreferencesType = SecuredPreferences( + store: SecuredPreferencesKeychainStore() +) + +keychain.set("kjn989hi", forKey: .token) + +keychain.get(.token) { + print($0) // "kjn989hi" +} + +// Define strongly-typed keys +extension SecuredPreferencesAPI.Key { + static let token = SecuredPreferencesAPI.Key("token") +} +``` +
+ ### Utilities
@@ -821,43 +832,6 @@ log.error("There was an error.") ```
-
-Network - -> Thin wrapper around `URLSession` for simple network requests: -```swift - let request = URLRequest( - url: URL(string: "https://httpbin.org/get")!, - method: .get, - parameters: [ - "abc": 123, - "def": "test456", - "xyz": true - ], - headers: [ - "Abc": "test123", - "Def": "test456", - "Xyz": "test789" - ] - ) - - let networkProvider = NetworkProvider( - store: NetworkURLSessionStore() - ) - - networkProvider.send(with: request) { result in - switch result { - case .success(let response): - response.data - response.headers - response.statusCode - case .failure(let error): - error.statusCode - } - } -``` -
-
SystemConfiguration diff --git a/Sources/ZamzamCore/Extensions/String+Keys.swift b/Sources/ZamzamCore/Extensions/String+Keys.swift deleted file mode 100644 index df9dc74f..00000000 --- a/Sources/ZamzamCore/Extensions/String+Keys.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// String+Keys.swift -// ZamzamKit -// -// Created by Basem Emara on 2/17/16. -// Copyright © 2016 Zamzam Inc. All rights reserved. -// - -extension String { - - /// Keys for strongly-typed access for User Defaults, Keychain, or custom types. - /// - /// // First define keys with associated types - /// extension String.Keys { - /// static let testString = String.Key("testString") - /// static let testInt = String.Key("testInt") - /// static let testBool = String.Key("testBool") - /// static let testArray = String.Key<[Int]?>("testArray") - /// } - /// - /// // Create method or subscript for generic types using the keys - /// extension UserDefaults { - /// - /// subscript(key: String.Key) -> T? { - /// get { object(forKey: key.name) as? T } - /// set { set(value, forKey: key.name) } - /// } - /// } - /// - /// // Then use strongly-typed values - /// let testString: String? = UserDefaults.standard[.testString] - /// let testInt: Int? = UserDefaults.standard[.testInt] - /// let testBool: Bool? = UserDefaults.standard[.testBool] - /// let testArray: [Int]? = UserDefaults.standard[.testArray] - open class Keys { - fileprivate init() {} - } - - /// User Defaults key for strongly-typed access. - open class Key: Keys { - public let name: String - - public init(_ key: String) { - self.name = key - super.init() - } - } -} diff --git a/Sources/ZamzamCore/Extensions/UserDefaults.swift b/Sources/ZamzamCore/Extensions/UserDefaults.swift deleted file mode 100644 index 952239cb..00000000 --- a/Sources/ZamzamCore/Extensions/UserDefaults.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// NSUserDefaults.swift -// ZamzamKit -// -// Created by Basem Emara on 3/18/16. -// Copyright © 2016 Zamzam Inc. All rights reserved. -// - -import Foundation - -public extension UserDefaults { - - /// Gets and sets the value from User Defaults that corresponds to the given key. - subscript(key: String.Key) -> T? { - get { object(forKey: key.name) as? T } - - set { - guard let value = newValue else { return remove(key) } - set(value, forKey: key.name) - } - } - - /// Removes the single User Defaults item specified by the key. - /// - /// - Parameter key: The key that is used to delete the user defaults item. - func remove(_ key: String.Key) { - removeObject(forKey: key.name) - } -} - -public extension UserDefaults { - - /// Removes all keys and values from User Defaults. - /// - Note: This method only removes keys on the receiver `UserDefaults` object. - /// System-defined keys will still be present afterwards. - func removeAll() { - dictionaryRepresentation().forEach { - removeObject(forKey: $0.key) - } - } -} diff --git a/Sources/ZamzamCore/Preferences/PreferencesAPI.swift b/Sources/ZamzamCore/Preferences/PreferencesAPI.swift deleted file mode 100644 index cfe6bb6a..00000000 --- a/Sources/ZamzamCore/Preferences/PreferencesAPI.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// PreferencesStoreInterfaces.swift -// ZamzamKit -// -// Created by Basem Emara on 2019-05-09. -// Copyright © 2019 Zamzam Inc. All rights reserved. -// - -public protocol PreferencesStore { - - /// Retrieves the value from user defaults that corresponds to the given key. - /// - /// - Parameter key: The key that is used to read the user defaults item. - func get(_ key: String.Key) -> T? - - /// Stores the value in the user defaults item under the given key. - /// - /// - Parameters: - /// - value: Value to be written to the user defaults. - /// - key: Key under which the value is stored in the user defaults. - func set(_ value: T?, forKey key: String.Key) - - /// Deletes the single user defaults item specified by the key. - /// - /// - Parameter key: The key that is used to delete the user default item. - /// - Returns: True if the item was successfully deleted. - func remove(_ key: String.Key) -} - -public protocol PreferencesType: PreferencesStore {} diff --git a/Sources/ZamzamCore/Preferences/Secured/SecuredPreferences.swift b/Sources/ZamzamCore/Preferences/Secured/SecuredPreferences.swift new file mode 100644 index 00000000..ce775536 --- /dev/null +++ b/Sources/ZamzamCore/Preferences/Secured/SecuredPreferences.swift @@ -0,0 +1,32 @@ +// +// SecuredPreferences.swift +// ZamzamKit +// +// Created by Basem Emara on 2020-03-07. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +import Foundation + +public struct SecuredPreferences: SecuredPreferencesType { + private let store: SecuredPreferencesStore + + public init(store: SecuredPreferencesStore) { + self.store = store + } +} + +public extension SecuredPreferences { + + func get(_ key: SecuredPreferencesAPI.Key, completion: @escaping (String?) -> Void) { + store.get(key, completion: completion) + } + + func set(_ value: String?, forKey key: SecuredPreferencesAPI.Key) -> Bool { + store.set(value, forKey: key) + } + + func remove(_ key: SecuredPreferencesAPI.Key) -> Bool { + store.remove(key) + } +} diff --git a/Sources/ZamzamCore/Preferences/Secured/SecuredPreferencesAPI.swift b/Sources/ZamzamCore/Preferences/Secured/SecuredPreferencesAPI.swift new file mode 100644 index 00000000..b261532a --- /dev/null +++ b/Sources/ZamzamCore/Preferences/Secured/SecuredPreferencesAPI.swift @@ -0,0 +1,73 @@ +// +// SecuredPreferencesAPI.swift +// ZamzamKit +// +// Created by Basem Emara on 2020-03-07. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +/// Preferences request namespace +public enum SecuredPreferencesAPI {} + +public protocol SecuredPreferencesStore { + func get(_ key: SecuredPreferencesAPI.Key, completion: @escaping (String?) -> Void) + func set(_ value: String?, forKey key: SecuredPreferencesAPI.Key) -> Bool + func remove(_ key: SecuredPreferencesAPI.Key) -> Bool +} + +/// A thin wrapper to manage Keychain, or other storages that conform to `SecuredPreferencesStore`. +/// +/// let keychain: SecuredPreferencesType = SecuredPreferences( +/// store: SecuredPreferencesKeychainStore() +/// ) +/// +/// keychain.set("kjn989hi", forKey: .token) +/// +/// keychain.get(.token) { +/// print($0) // "kjn989hi" +/// } +/// +/// // Define strongly-typed keys +/// extension SecuredPreferencesAPI.Key { +/// static let token = SecuredPreferencesAPI.Key("token") +/// } +public protocol SecuredPreferencesType { + + /// Retrieves the value from keychain that corresponds to the given key. + /// + /// - Parameter key: The key that is used to read the user defaults item. + func get(_ key: SecuredPreferencesAPI.Key, completion: @escaping (String?) -> Void) + + /// Stores the value in the keychain item under the given key. + /// + /// - Parameters: + /// - value: Value to be written to the keychain. + /// - key: Key under which the value is stored in the keychain. + @discardableResult + func set(_ value: String?, forKey key: SecuredPreferencesAPI.Key) -> Bool + + /// Deletes the single keychain item specified by the key. + /// + /// - Parameter key: The key that is used to delete the keychain item. + /// - Returns: True if the item was successfully deleted. + @discardableResult + func remove(_ key: SecuredPreferencesAPI.Key) -> Bool +} + +// MARK: Requests / Responses + +extension SecuredPreferencesAPI { + + /// Security key for compile-safe access. + /// + /// extension SecuredPreferencesAPI.Key { + /// static let token = SecuredPreferencesAPI.Key("token") + /// } + public struct Key { + public let name: String + + public init(_ key: String) { + self.name = key + } + } +} diff --git a/Sources/ZamzamCore/Preferences/Secured/Stores/SecuredPreferencesKeychainStore.swift b/Sources/ZamzamCore/Preferences/Secured/Stores/SecuredPreferencesKeychainStore.swift new file mode 100644 index 00000000..64a69266 --- /dev/null +++ b/Sources/ZamzamCore/Preferences/Secured/Stores/SecuredPreferencesKeychainStore.swift @@ -0,0 +1,550 @@ +// +// SecuredPreferencesKeychainStore.swift +// ZamzamKit +// +// Created by Basem Emara on 2020-03-07. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +import Foundation + +public struct SecuredPreferencesKeychainStore: SecuredPreferencesStore { + private static let accessOption: KeychainSwiftAccessOptions = .accessibleAfterFirstUnlock + private let keychain: KeychainSwift + + public init() { + self.keychain = KeychainSwift() + self.keychain.synchronizable = false + } + + public init(teamID: String, accessGroup: String) { + self.init() + self.keychain.accessGroup = "\(teamID).\(accessGroup)" + } +} + +public extension SecuredPreferencesKeychainStore { + private static let queue = DispatchQueue(label: "io.zamzam.ZamzamKit.SecuredPreferencesKeychainStore", qos: .userInitiated) + + func get(_ key: SecuredPreferencesAPI.Key, completion: @escaping (String?) -> Void) { + Self.queue.async { + let value = self.keychain.get(key.name) + + DispatchQueue.main.async { + completion(value) + } + } + } +} + +public extension SecuredPreferencesKeychainStore { + + func set(_ value: String?, forKey key: SecuredPreferencesAPI.Key) -> Bool { + guard let value = value else { return remove(key) } + return keychain.set(value, forKey: key.name) + } +} + +public extension SecuredPreferencesKeychainStore { + + func remove(_ key: SecuredPreferencesAPI.Key) -> Bool { + keychain.delete(key.name) + } +} + +// MARK: - External Library + +// +// KeychainSwiftDistrib.swift +// +// https://github.com/evgenyneu/keychain-swift +// Copied from v19.0.0. Modified for private access controls and lint fixes. +// + +// swiftlint:disable file_length + +// ---------------------------- +// +// KeychainSwift.swift +// +// ---------------------------- + +/** + + A collection of helper functions for saving text and data in the keychain. + + */ +private class KeychainSwift { + + var lastQueryParameters: [String: Any]? // Used by the unit tests + + /// Contains result code from the last operation. Value is noErr (0) for a successful result. + var lastResultCode: OSStatus = noErr + + var keyPrefix = "" // Can be useful in test. + + /** + + Specify an access group that will be used to access keychain items. Access groups can be used to share keychain items between applications. When access group value is nil all application access groups are being accessed. Access group name is used by all functions: set, get, delete and clear. + + */ + var accessGroup: String? + + /** + + Specifies whether the items can be synchronized with other devices through iCloud. Setting this property to true will + add the item to other devices with the `set` method and obtain synchronizable items with the `get` command. Deleting synchronizable items will remove them from all devices. In order for keychain synchronization to work the user must enable "Keychain" in iCloud settings. + + Does not work on macOS. + + */ + var synchronizable: Bool = false + + private let lock = NSLock() + + /// Instantiate a KeychainSwift object + init() { } + + /** + + - parameter keyPrefix: a prefix that is added before the key in get/set methods. Note that `clear` method still clears everything from the Keychain. + + */ + init(keyPrefix: String) { + self.keyPrefix = keyPrefix + } + + /** + + Stores the text value in the keychain item under the given key. + + - parameter key: Key under which the text value is stored in the keychain. + - parameter value: Text string to be written to the keychain. + - parameter withAccess: Value that indicates when your app needs access to the text in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. + + - returns: True if the text was successfully written to the keychain. + + */ + @discardableResult + func set(_ value: String, forKey key: String, + withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool { + + if let value = value.data(using: String.Encoding.utf8) { + return set(value, forKey: key, withAccess: access) + } + + return false + } + + /** + + Stores the data in the keychain item under the given key. + + - parameter key: Key under which the data is stored in the keychain. + - parameter value: Data to be written to the keychain. + - parameter withAccess: Value that indicates when your app needs access to the text in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. + + - returns: True if the text was successfully written to the keychain. + + */ + @discardableResult + func set(_ value: Data, forKey key: String, + withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool { + + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + deleteNoLock(key) // Delete any existing key before saving it + + let accessible = access?.value ?? KeychainSwiftAccessOptions.defaultOption.value + + let prefixedKey = keyWithPrefix(key) + + var query: [String: Any] = [ + KeychainSwiftConstants.klass: kSecClassGenericPassword, + KeychainSwiftConstants.attrAccount: prefixedKey, + KeychainSwiftConstants.valueData: value, + KeychainSwiftConstants.accessible: accessible + ] + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: true) + lastQueryParameters = query + + lastResultCode = SecItemAdd(query as CFDictionary, nil) + + return lastResultCode == noErr + } + + /** + + Stores the boolean value in the keychain item under the given key. + + - parameter key: Key under which the value is stored in the keychain. + - parameter value: Boolean to be written to the keychain. + - parameter withAccess: Value that indicates when your app needs access to the value in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. + + - returns: True if the value was successfully written to the keychain. + + */ + @discardableResult + func set(_ value: Bool, forKey key: String, + withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool { + + let bytes: [UInt8] = value ? [1] : [0] + let data = Data(bytes) + + return set(data, forKey: key, withAccess: access) + } + + /** + + Retrieves the text value from the keychain that corresponds to the given key. + + - parameter key: The key that is used to read the keychain item. + - returns: The text value from the keychain. Returns nil if unable to read the item. + + */ + func get(_ key: String) -> String? { + if let data = getData(key) { + + if let currentString = String(data: data, encoding: .utf8) { + return currentString + } + + lastResultCode = -67853 // errSecInvalidEncoding + } + + return nil + } + + /** + + Retrieves the data from the keychain that corresponds to the given key. + + - parameter key: The key that is used to read the keychain item. + - parameter asReference: If true, returns the data as reference (needed for things like NEVPNProtocol). + - returns: The text value from the keychain. Returns nil if unable to read the item. + + */ + func getData(_ key: String, asReference: Bool = false) -> Data? { + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + let prefixedKey = keyWithPrefix(key) + + var query: [String: Any] = [ + KeychainSwiftConstants.klass: kSecClassGenericPassword, + KeychainSwiftConstants.attrAccount: prefixedKey, + KeychainSwiftConstants.matchLimit: kSecMatchLimitOne + ] + + if asReference { + query[KeychainSwiftConstants.returnReference] = kCFBooleanTrue + } else { + query[KeychainSwiftConstants.returnData] = kCFBooleanTrue + } + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + lastQueryParameters = query + + var result: AnyObject? + + lastResultCode = withUnsafeMutablePointer(to: &result) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + if lastResultCode == noErr { + return result as? Data + } + + return nil + } + + /** + + Retrieves the boolean value from the keychain that corresponds to the given key. + + - parameter key: The key that is used to read the keychain item. + - returns: The boolean value from the keychain. Returns nil if unable to read the item. + + */ + func getBool(_ key: String) -> Bool? { + guard let data = getData(key) else { return nil } + guard let firstBit = data.first else { return nil } + return firstBit == 1 + } + + /** + + Deletes the single keychain item specified by the key. + + - parameter key: The key that is used to delete the keychain item. + - returns: True if the item was successfully deleted. + + */ + @discardableResult + func delete(_ key: String) -> Bool { + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + return deleteNoLock(key) + } + + /** + Return all keys from keychain + + - returns: An string array with all keys from the keychain. + + */ + var allKeys: [String] { + var query: [String: Any] = [ + KeychainSwiftConstants.klass: kSecClassGenericPassword, + KeychainSwiftConstants.returnData: true, + KeychainSwiftConstants.returnAttributes: true, + KeychainSwiftConstants.returnReference: true, + KeychainSwiftConstants.matchLimit: KeychainSwiftConstants.secMatchLimitAll + ] + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + + var result: AnyObject? + + let lastResultCode = withUnsafeMutablePointer(to: &result) { + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) + } + + if lastResultCode == noErr { + return (result as? [[String: Any]])?.compactMap { + $0[KeychainSwiftConstants.attrAccount] as? String } ?? [] + } + + return [] + } + + /** + + Same as `delete` but is only accessed internally, since it is not thread safe. + + - parameter key: The key that is used to delete the keychain item. + - returns: True if the item was successfully deleted. + + */ + @discardableResult + func deleteNoLock(_ key: String) -> Bool { + let prefixedKey = keyWithPrefix(key) + + var query: [String: Any] = [ + KeychainSwiftConstants.klass: kSecClassGenericPassword, + KeychainSwiftConstants.attrAccount: prefixedKey + ] + + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + lastQueryParameters = query + + lastResultCode = SecItemDelete(query as CFDictionary) + + return lastResultCode == noErr + } + + /** + + Deletes all Keychain items used by the app. Note that this method deletes all items regardless of the prefix settings used for initializing the class. + + - returns: True if the keychain items were successfully deleted. + + */ + @discardableResult + func clear() -> Bool { + // The lock prevents the code to be run simultaneously + // from multiple threads which may result in crashing + lock.lock() + defer { lock.unlock() } + + var query: [String: Any] = [kSecClass as String: kSecClassGenericPassword] + query = addAccessGroupWhenPresent(query) + query = addSynchronizableIfRequired(query, addingItems: false) + lastQueryParameters = query + + lastResultCode = SecItemDelete(query as CFDictionary) + + return lastResultCode == noErr + } + + /// Returns the key with currently set prefix. + func keyWithPrefix(_ key: String) -> String { + return "\(keyPrefix)\(key)" + } + + func addAccessGroupWhenPresent(_ items: [String: Any]) -> [String: Any] { + guard let accessGroup = accessGroup else { return items } + + var result: [String: Any] = items + result[KeychainSwiftConstants.accessGroup] = accessGroup + return result + } + + /** + + Adds kSecAttrSynchronizable: kSecAttrSynchronizableAny` item to the dictionary when the `synchronizable` property is true. + + - parameter items: The dictionary where the kSecAttrSynchronizable items will be added when requested. + - parameter addingItems: Use `true` when the dictionary will be used with `SecItemAdd` method (adding a keychain item). For getting and deleting items, use `false`. + + - returns: the dictionary with kSecAttrSynchronizable item added if it was requested. Otherwise, it returns the original dictionary. + + */ + func addSynchronizableIfRequired(_ items: [String: Any], addingItems: Bool) -> [String: Any] { + if !synchronizable { return items } + var result: [String: Any] = items + result[KeychainSwiftConstants.attrSynchronizable] = addingItems == true ? true : kSecAttrSynchronizableAny + return result + } +} + +// ---------------------------- +// +// TegKeychainConstants.swift +// +// ---------------------------- + +/// Constants used by the library +private struct KeychainSwiftConstants { + /// Specifies a Keychain access group. Used for sharing Keychain items between apps. + static var accessGroup: String { return toString(kSecAttrAccessGroup) } + + /** + + A value that indicates when your app needs access to the data in a keychain item. The default value is AccessibleWhenUnlocked. For a list of possible values, see KeychainSwiftAccessOptions. + + */ + static var accessible: String { return toString(kSecAttrAccessible) } + + /// Used for specifying a String key when setting/getting a Keychain value. + static var attrAccount: String { return toString(kSecAttrAccount) } + + /// Used for specifying synchronization of keychain items between devices. + static var attrSynchronizable: String { return toString(kSecAttrSynchronizable) } + + /// An item class key used to construct a Keychain search dictionary. + static var klass: String { return toString(kSecClass) } + + /// Specifies the number of values returned from the keychain. The library only supports single values. + static var matchLimit: String { return toString(kSecMatchLimit) } + + /// A return data type used to get the data from the Keychain. + static var returnData: String { return toString(kSecReturnData) } + + /// Used for specifying a value when setting a Keychain value. + static var valueData: String { return toString(kSecValueData) } + + /// Used for returning a reference to the data from the keychain + static var returnReference: String { return toString(kSecReturnPersistentRef) } + + /// A key whose value is a Boolean indicating whether or not to return item attributes + static var returnAttributes: String { return toString(kSecReturnAttributes) } + + /// A value that corresponds to matching an unlimited number of items + static var secMatchLimitAll: String { return toString(kSecMatchLimitAll) } + + static func toString(_ value: CFString) -> String { + return value as String + } +} + +// ---------------------------- +// +// KeychainSwiftAccessOptions.swift +// +// ---------------------------- + +/** + + These options are used to determine when a keychain item should be readable. The default value is AccessibleWhenUnlocked. + + */ +private enum KeychainSwiftAccessOptions { + + /** + + The data in the keychain item can be accessed only while the device is unlocked by the user. + + This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute migrate to a new device when using encrypted backups. + + This is the default value for keychain items added without explicitly setting an accessibility constant. + + */ + case accessibleWhenUnlocked + + /** + + The data in the keychain item can be accessed only while the device is unlocked by the user. + + This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. + + */ + case accessibleWhenUnlockedThisDeviceOnly + + /** + + The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. + + After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute migrate to a new device when using encrypted backups. + + */ + case accessibleAfterFirstUnlock + + /** + + The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. + + After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. + + */ + case accessibleAfterFirstUnlockThisDeviceOnly + + /** + + The data in the keychain can only be accessed when the device is unlocked. Only available if a passcode is set on the device. + + This is recommended for items that only need to be accessible while the application is in the foreground. Items with this attribute never migrate to a new device. After a backup is restored to a new device, these items are missing. No items can be stored in this class on devices without a passcode. Disabling the device passcode causes all items in this class to be deleted. + + */ + case accessibleWhenPasscodeSetThisDeviceOnly + + static var defaultOption: KeychainSwiftAccessOptions { + return .accessibleWhenUnlocked + } + + var value: String { + switch self { + case .accessibleWhenUnlocked: + return toString(kSecAttrAccessibleWhenUnlocked) + + case .accessibleWhenUnlockedThisDeviceOnly: + return toString(kSecAttrAccessibleWhenUnlockedThisDeviceOnly) + + case .accessibleAfterFirstUnlock: + return toString(kSecAttrAccessibleAfterFirstUnlock) + + case .accessibleAfterFirstUnlockThisDeviceOnly: + return toString(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + + case .accessibleWhenPasscodeSetThisDeviceOnly: + return toString(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly) + } + } + + func toString(_ value: CFString) -> String { + return KeychainSwiftConstants.toString(value) + } +} diff --git a/Sources/ZamzamCore/Preferences/Preferences.swift b/Sources/ZamzamCore/Preferences/Shared/Preferences.swift similarity index 72% rename from Sources/ZamzamCore/Preferences/Preferences.swift rename to Sources/ZamzamCore/Preferences/Shared/Preferences.swift index 84a8d3c8..c52fe395 100644 --- a/Sources/ZamzamCore/Preferences/Preferences.swift +++ b/Sources/ZamzamCore/Preferences/Shared/Preferences.swift @@ -16,15 +16,15 @@ public struct Preferences: PreferencesType { public extension Preferences { - func get(_ key: String.Key) -> T? { + func get(_ key: PreferencesAPI.Key) -> T? { store.get(key) } - func set(_ value: T?, forKey key: String.Key) { + func set(_ value: T?, forKey key: PreferencesAPI.Key) { store.set(value, forKey: key) } - func remove(_ key: String.Key) { + func remove(_ key: PreferencesAPI.Key) { store.remove(key) } } diff --git a/Sources/ZamzamCore/Preferences/Shared/PreferencesAPI.swift b/Sources/ZamzamCore/Preferences/Shared/PreferencesAPI.swift new file mode 100644 index 00000000..e3c62d34 --- /dev/null +++ b/Sources/ZamzamCore/Preferences/Shared/PreferencesAPI.swift @@ -0,0 +1,76 @@ +// +// PreferencesStoreInterfaces.swift +// ZamzamKit +// +// Created by Basem Emara on 2019-05-09. +// Copyright © 2019 Zamzam Inc. All rights reserved. +// + +/// Preferences request namespace +public enum PreferencesAPI {} + +public protocol PreferencesStore { + func get(_ key: PreferencesAPI.Key) -> T? + func set(_ value: T?, forKey key: PreferencesAPI.Key) + func remove(_ key: PreferencesAPI.Key) +} + +/// A thin wrapper to manage `UserDefaults`, or other storages that conform to `PreferencesStore`. +/// +/// let preferences: PreferencesType = Preferences( +/// store: PreferencesDefaultsStore( +/// defaults: UserDefaults.standard +/// ) +/// ) +/// +/// preferences.set(123, forKey: .abc) +/// preferences.get(.token) // 123 +/// +/// // Define strongly-typed keys +/// extension PreferencesAPI.Keys { +/// static let abc = PreferencesAPI.Key("abc") +/// } +public protocol PreferencesType { + + /// Retrieves the value from user defaults that corresponds to the given key. + /// + /// - Parameter key: The key that is used to read the user defaults item. + func get(_ key: PreferencesAPI.Key) -> T? + + /// Stores the value in the user defaults item under the given key. + /// + /// - Parameters: + /// - value: Value to be written to the user defaults. + /// - key: Key under which the value is stored in the user defaults. + func set(_ value: T?, forKey key: PreferencesAPI.Key) + + /// Deletes the single user defaults item specified by the key. + /// + /// - Parameter key: The key that is used to delete the user default item. + /// - Returns: True if the item was successfully deleted. + func remove(_ key: PreferencesAPI.Key) +} + +// MARK: Requests / Responses + +extension PreferencesAPI { + + /// Keys for strongly-typed access for generic types. + open class Keys { + fileprivate init() {} + } + + /// Preferences key for strongly-typed access. + /// + /// extension PreferencesAPI.Keys { + /// static let abc = PreferencesAPI.Key("abc") + /// } + open class Key: Keys { + public let name: String + + public init(_ key: String) { + self.name = key + super.init() + } + } +} diff --git a/Sources/ZamzamCore/Preferences/Stores/PreferencesDefaultsStore.swift b/Sources/ZamzamCore/Preferences/Shared/Stores/PreferencesDefaultsStore.swift similarity index 53% rename from Sources/ZamzamCore/Preferences/Stores/PreferencesDefaultsStore.swift rename to Sources/ZamzamCore/Preferences/Shared/Stores/PreferencesDefaultsStore.swift index 1a65b28c..d2f2764e 100644 --- a/Sources/ZamzamCore/Preferences/Stores/PreferencesDefaultsStore.swift +++ b/Sources/ZamzamCore/Preferences/Shared/Stores/PreferencesDefaultsStore.swift @@ -18,15 +18,16 @@ public struct PreferencesDefaultsStore: PreferencesStore { public extension PreferencesDefaultsStore { - func get(_ key: String.Key) -> T? { - defaults[key] + func get(_ key: PreferencesAPI.Key) -> T? { + defaults.object(forKey: key.name) as? T } - func set(_ value: T?, forKey key: String.Key) { - defaults[key] = value + func set(_ value: T?, forKey key: PreferencesAPI.Key) { + guard let value = value else { return remove(key) } + defaults.set(value, forKey: key.name) } - func remove(_ key: String.Key) { - defaults.remove(key) + func remove(_ key: PreferencesAPI.Key) { + defaults.removeObject(forKey: key.name) } } diff --git a/Tests/ZamzamCoreTests/PreferencesTests.swift b/Tests/ZamzamCoreTests/PreferencesTests.swift new file mode 100644 index 00000000..4203b75f --- /dev/null +++ b/Tests/ZamzamCoreTests/PreferencesTests.swift @@ -0,0 +1,114 @@ +// +// PreferencesTests.swift +// ZamzamKit +// +// Created by Basem Emara on 2017-11-27. +// Copyright © 2017 Zamzam Inc. All rights reserved. +// + +import XCTest +import ZamzamCore + +final class PreferencesTests: XCTestCase { + + private lazy var preferences: PreferencesType = Preferences( + store: PreferencesDefaultsStore( + defaults: UserDefaults(suiteName: "StringKeysTests")! + ) + ) +} + +extension PreferencesTests { + + func testString() { + preferences.set("abc", forKey: .testString1) + preferences.set("xyz", forKey: .testString2) + + XCTAssertEqual(preferences.get(.testString1), "abc") + XCTAssertEqual(preferences.get(.testString2), "xyz") + } + + func testBoolean() { + preferences.set(true, forKey: .testBool1) + preferences.set(false, forKey: .testBool2) + + XCTAssertEqual(preferences.get(.testBool1), true) + XCTAssertEqual(preferences.get(.testBool2), false) + } + + func testInteger() { + preferences.set(123, forKey: .testInt1) + preferences.set(987, forKey: .testInt2) + + XCTAssertEqual(preferences.get(.testInt1), 123) + XCTAssertEqual(preferences.get(.testInt2), 987) + } + + func testFloat() { + preferences.set(1.1, forKey: .testFloat1) + preferences.set(9.9, forKey: .testFloat2) + + XCTAssertEqual(preferences.get(.testFloat1), 1.1) + XCTAssertEqual(preferences.get(.testFloat2), 9.9) + } + + func testDouble() { + preferences.set(2.123456789, forKey: .testDouble1) + preferences.set(9.876543219, forKey: .testDouble2) + + XCTAssertEqual(preferences.get(.testDouble1), 2.123456789) + XCTAssertEqual(preferences.get(.testDouble2), 9.876543219) + } + + func testDate() { + let value1 = Date() + let value2 = Date(timeIntervalSinceNow: 12345678) + + preferences.set(value1, forKey: .testDate1) + preferences.set(value2, forKey: .testDate2) + + XCTAssertEqual(preferences.get(.testDate1), value1) + XCTAssertEqual(preferences.get(.testDate2), value2) + } + + func testArray() { + let value1 = ["abc", "def", "ghi", "lmn"] + let value2 = [1, 2, 3, 4, 5, 6, 7, 8, 9] + + preferences.set(value1, forKey: .testArray1) + preferences.set(value2, forKey: .testArray2) + + XCTAssertEqual(preferences.get(.testArray1), value1) + XCTAssertEqual(preferences.get(.testArray2), value2) + } + + func testDictionary() { + let value1 = ["abc": "xyz", "def": "tuv", "ghi": "qrs"] + let value2 = ["abc": 123, "def": 456, "ghi": 789] + + preferences.set(value1, forKey: .testDictionary1) + preferences.set(value2, forKey: .testDictionary2) + + XCTAssertEqual(preferences.get(.testDictionary1), value1) + XCTAssertEqual(preferences.get(.testDictionary2), value2) + } +} + +private extension PreferencesAPI.Keys { + static let testString1 = PreferencesAPI.Key("testString1") + static let testString2 = PreferencesAPI.Key("testString2") + static let testBool1 = PreferencesAPI.Key("testBool1") + static let testBool2 = PreferencesAPI.Key("testBool2") + static let testInt1 = PreferencesAPI.Key("testInt1") + static let testInt2 = PreferencesAPI.Key("testInt2") + static let testFloat1 = PreferencesAPI.Key("testFloat1") + static let testFloat2 = PreferencesAPI.Key("testFloat2") + static let testDouble1 = PreferencesAPI.Key("testDouble1") + static let testDouble2 = PreferencesAPI.Key("testDouble2") + static let testDate1 = PreferencesAPI.Key("testDate1") + static let testDate2 = PreferencesAPI.Key("testDate2") + static let testArray1 = PreferencesAPI.Key<[String]?>("testArray1") + static let testArray2 = PreferencesAPI.Key<[Int]?>("testArray2") + static let testDictionary1 = PreferencesAPI.Key<[String: String]?>("testDictionary1") + static let testDictionary2 = PreferencesAPI.Key<[String: Int]?>("testDictionary2") +} diff --git a/Tests/ZamzamCoreTests/SecuredPreferencesTests.swift b/Tests/ZamzamCoreTests/SecuredPreferencesTests.swift new file mode 100644 index 00000000..773a192b --- /dev/null +++ b/Tests/ZamzamCoreTests/SecuredPreferencesTests.swift @@ -0,0 +1,108 @@ +// +// SecuredPreferencesTests.swift +// ZamzamKit +// +// Created by Basem Emara on 2020-03-07. +// Copyright © 2020 Zamzam Inc. All rights reserved. +// + +import XCTest +import ZamzamCore + +final class SecuredPreferencesTests: XCTestCase { + + private lazy var keychain: SecuredPreferencesType = SecuredPreferences( + store: SecuredPreferencesTestStore() + ) +} + +extension SecuredPreferencesTests { + + func testString() { + // Given + let promise1 = expectation(description: "\(#function)1") + let promise2 = expectation(description: "\(#function)2") + let value1 = "abc" + let value2 = "xyz" + + // When + keychain.set(value1, forKey: .testString1) + keychain.set(value2, forKey: .testString2) + + // Then + keychain.get(.testString1) { + XCTAssertEqual($0, value1) + promise1.fulfill() + } + + keychain.get(.testString2) { + XCTAssertEqual($0, value2) + promise2.fulfill() + } + + wait(for: [promise1, promise2], timeout: 10) + } +} + +extension SecuredPreferencesTests { + + func testRemove() { + // Given + let promise1 = expectation(description: "\(#function)1") + let promise2 = expectation(description: "\(#function)2") + let value1 = "abc" + let value2 = "xyz" + + // When + keychain.set(value1, forKey: .testString1) + keychain.set(value2, forKey: .testString2) + keychain.remove(.testString1) + keychain.remove(.testString2) + + // Then + keychain.get(.testString1) { + XCTAssertNil($0) + promise1.fulfill() + } + + keychain.get(.testString2) { + XCTAssertNil($0) + promise2.fulfill() + } + + wait(for: [promise1, promise2], timeout: 10) + } +} + +private extension SecuredPreferencesAPI.Key { + static let testString1 = SecuredPreferencesAPI.Key("testString1") + static let testString2 = SecuredPreferencesAPI.Key("testString2") +} + +// MARK: - Helpers + +// Unit test mocked since Keychain needs application host, see app for Keychain testing +// https://github.com/onmyway133/blog/issues/92 +// https://forums.swift.org/t/host-application-for-spm-tests/24363 +private class SecuredPreferencesTestStore: SecuredPreferencesStore { + var values = [String: String?]() + + func get(_ key: SecuredPreferencesAPI.Key, completion: @escaping (String?) -> Void) { + guard let value = values[key.name] else { + completion(nil) + return + } + + completion(value) + } + + func set(_ value: String?, forKey key: SecuredPreferencesAPI.Key) -> Bool { + values[key.name] = value + return true + } + + func remove(_ key: SecuredPreferencesAPI.Key) -> Bool { + values.removeValue(forKey: key.name) + return true + } +} diff --git a/Tests/ZamzamCoreTests/StringKeysTests.swift b/Tests/ZamzamCoreTests/StringKeysTests.swift deleted file mode 100644 index 71107f4e..00000000 --- a/Tests/ZamzamCoreTests/StringKeysTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// File.swift -// ZamzamKit -// -// Created by Basem Emara on 2017-11-27. -// Copyright © 2017 Zamzam Inc. All rights reserved. -// - -import XCTest -import ZamzamCore - -final class StringKeysTests: XCTestCase { - - private let defaults = UserDefaults(suiteName: "StringKeysTests")! - - override func setUp() { - super.setUp() - defaults.removeAll() - } -} - -extension StringKeysTests { - - func testString() { - defaults[.testString1] = "abc" - defaults[.testString2] = "xyz" - - XCTAssertEqual(defaults[.testString1], "abc") - XCTAssertEqual(defaults[.testString2], "xyz") - } - - func testBoolean() { - defaults[.testBool1] = true - defaults[.testBool2] = false - - XCTAssertEqual(defaults[.testBool1], true) - XCTAssertEqual(defaults[.testBool2], false) - } - - func testInteger() { - defaults[.testInt1] = 123 - defaults[.testInt2] = 987 - - XCTAssertEqual(defaults[.testInt1], 123) - XCTAssertEqual(defaults[.testInt2], 987) - } - - func testFloat() { - defaults[.testFloat1] = 1.1 - defaults[.testFloat2] = 9.9 - - XCTAssertEqual(defaults[.testFloat1], 1.1) - XCTAssertEqual(defaults[.testFloat2], 9.9) - } - - func testDouble() { - defaults[.testDouble1] = 2.123456789 - defaults[.testDouble2] = 9.876543219 - - XCTAssertEqual(defaults[.testDouble1], 2.123456789) - XCTAssertEqual(defaults[.testDouble2], 9.876543219) - } - - func testDate() { - let value1 = Date() - let value2 = Date(timeIntervalSinceNow: 12345678) - - defaults[.testDate1] = value1 - defaults[.testDate2] = value2 - - XCTAssertEqual(defaults[.testDate1], value1) - XCTAssertEqual(defaults[.testDate2], value2) - } - - func testArray() { - let value1 = ["abc", "def", "ghi", "lmn"] - let value2 = [1, 2, 3, 4, 5, 6, 7, 8, 9] - - defaults[.testArray1] = value1 - defaults[.testArray2] = value2 - - XCTAssertEqual(defaults[.testArray1]!, value1) - XCTAssertEqual(defaults[.testArray2]!, value2) - } - - func testDictionary() { - let value1 = ["abc": "xyz", "def": "tuv", "ghi": "qrs"] - let value2 = ["abc": 123, "def": 456, "ghi": 789] - - defaults[.testDictionary1] = value1 - defaults[.testDictionary2] = value2 - - XCTAssertEqual(defaults[.testDictionary1]!, value1) - XCTAssertEqual(defaults[.testDictionary2]!, value2) - } -} - -private extension String.Keys { - static let testString1 = String.Key("testString1") - static let testString2 = String.Key("testString2") - static let testBool1 = String.Key("testBool1") - static let testBool2 = String.Key("testBool2") - static let testInt1 = String.Key("testInt1") - static let testInt2 = String.Key("testInt2") - static let testFloat1 = String.Key("testFloat1") - static let testFloat2 = String.Key("testFloat2") - static let testDouble1 = String.Key("testDouble1") - static let testDouble2 = String.Key("testDouble2") - static let testDate1 = String.Key("testDate1") - static let testDate2 = String.Key("testDate2") - static let testArray1 = String.Key<[String]?>("testArray1") - static let testArray2 = String.Key<[Int]?>("testArray2") - static let testDictionary1 = String.Key<[String: String]?>("testDictionary1") - static let testDictionary2 = String.Key<[String: Int]?>("testDictionary2") -} From 3108cbbc0963a8f22eea1cbabdc1d5fc9dab1e92 Mon Sep 17 00:00:00 2001 From: Basem Emara Date: Sat, 7 Mar 2020 12:56:40 -0500 Subject: [PATCH 31/31] Fix network comments --- Sources/ZamzamCore/Network/NetworkAPI.swift | 62 ++++++++++----------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/Sources/ZamzamCore/Network/NetworkAPI.swift b/Sources/ZamzamCore/Network/NetworkAPI.swift index 9cdd601d..c8d0bdd0 100644 --- a/Sources/ZamzamCore/Network/NetworkAPI.swift +++ b/Sources/ZamzamCore/Network/NetworkAPI.swift @@ -15,41 +15,41 @@ public protocol NetworkStore { func send(with request: URLRequest, completion: @escaping (Result) -> Void) } -/// The wrapper to handle HTTP requests. +/// A thin wrapper to handle HTTP requests. +/// +/// let request = URLRequest( +/// url: URL(string: "https://httpbin.org/get")!, +/// method: .get, +/// parameters: [ +/// "abc": 123, +/// "def": "test456", +/// "xyz": true +/// ], +/// headers: [ +/// "Abc": "test123", +/// "Def": "test456", +/// "Xyz": "test789" +/// ] +/// ) +/// +/// let networkProvider = NetworkProvider( +/// store: NetworkURLSessionStore() +/// ) +/// +/// networkProvider.send(with: request) { result in +/// switch result { +/// case .success(let response): +/// response.data +/// response.headers +/// response.statusCode +/// case .failure(let error): +/// error.statusCode +/// } +/// } public protocol NetworkProviderType { /// Creates a task that retrieves the contents of a URL based on the specified request object, and calls a handler upon completion. /// - /// let request = URLRequest( - /// url: URL(string: "https://httpbin.org/get")!, - /// method: .get, - /// parameters: [ - /// "abc": 123, - /// "def": "test456", - /// "xyz": true - /// ], - /// headers: [ - /// "Abc": "test123", - /// "Def": "test456", - /// "Xyz": "test789" - /// ] - /// ) - /// - /// let networkProvider = NetworkProvider( - /// store: NetworkURLSessionStore() - /// ) - /// - /// networkProvider.send(with: request) { result in - /// switch result { - /// case .success(let response): - /// response.data - /// response.headers - /// response.statusCode - /// case .failure(let error): - /// error.statusCode - /// } - /// } - /// /// - Parameters: /// - request: A network request object that provides the URL, parameters, headers, and so on. /// - completion: The completion handler to call when the load request is complete.