diff --git a/README.md b/README.md index 4b0e78bd..9678a69e 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 ``` @@ -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 @@ -309,36 +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 any type -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) - } - } -} - -// 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+ @@ -573,23 +578,60 @@ label.attributedText = "Abc".attributed + " def " +
-URL +URLSession + +> A thin wrapper around `URLSession` and `URLRequest` 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: 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 + } + } +``` +
-> Append or remove query string parameters: -```swift -let url = URL(string: "https://example.com?abc=123&lmn=tuv&xyz=987") +
+UserDefaults -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" +> A thin wrapper to manage `UserDefaults`, or other storages that conform to `PreferencesStore`: +```swift +let preferences: PreferencesType = Preferences( + store: PreferencesDefaultsStore( + defaults: UserDefaults.standard + ) +) -url?.appendingQueryItems([ - "def": "456", - "jkl": "777", - "abc": "333", - "lmn": nil -]) -> "https://example.com?xyz=987&def=456&abc=333&jkl=777" +preferences.set(123, forKey: .abc) +preferences.get(.token) // 123 -url?.removeQueryItem("xyz") // "https://example.com?abc=123&lmn=tuv" +// Define strongly-typed keys +extension PreferencesAPI.Keys { + static let abc = PreferencesAPI.Key("abc") +} ```
@@ -726,43 +768,30 @@ BackgroundTask.run(for: application) { task in ``` -### Utilities -
-Dependencies +Keychain -> Lightweight dependency injection via property wrapper ([read more](https://basememara.com/swift-dependency-injection-via-property-wrapper/)): +> A thin wrapper to manage Keychain, or other storages that conform to `SecuredPreferencesStore`: ```swift -class AppDelegate: UIResponder, UIApplicationDelegate { - - private let dependencies = Dependencies { - Module { WidgetModule() as WidgetModuleType } - Module { SampleModule() as SampleModuleType } - } - - override init() { - super.init() - dependencies.build() - } -} +let keychain: SecuredPreferencesType = SecuredPreferences( + store: SecuredPreferencesKeychainStore() +) -// Some time later... +keychain.set("kjn989hi", forKey: .token) -class ViewController: UIViewController { - - @Inject private var widgetService: WidgetServiceType - @Inject private var sampleService: SampleServiceType - - override func viewDidLoad() { - super.viewDidLoad() - - print(widgetService.test()) - print(sampleService.test()) - } +keychain.get(.token) { + print($0) // "kjn989hi" +} + +// Define strongly-typed keys +extension SecuredPreferencesAPI.Key { + static let token = SecuredPreferencesAPI.Key("token") } ```
+### Utilities +
Localization @@ -785,9 +814,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 +983,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 +991,7 @@ class LocationViewController: UIViewController { @IBOutlet weak var outputLabel: UILabel! - var locationsWorker: LocationsWorkerType = LocationsWorker( + var locationsProvider: LocationsProviderType = LocationsProvider( desiredAccuracy: kCLLocationAccuracyThreeKilometers, distanceFilter: 1000 ) @@ -970,38 +999,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/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/Application/ApplicationPluggableDelegate.swift b/Sources/ZamzamCore/Application/ApplicationPluggableDelegate.swift index fd6dcba1..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. // @@ -12,7 +13,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 +26,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 +40,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 @@ -80,6 +81,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 +140,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 +158,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/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/Application/ExtensionPluggableDelegate.swift b/Sources/ZamzamCore/Application/ExtensionPluggableDelegate.swift index d5cecaf1..db6772f9 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 { /// @@ -21,9 +21,9 @@ import WatchKit /// ]} /// } /// -/// Each application module has access to the `ExtensionDelegate` lifecycle events: +/// Each application plugin 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 { @@ -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) diff --git a/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift b/Sources/ZamzamCore/Application/ScenePluggableDelegate.swift index 5e80b57c..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 @@ -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,9 +121,8 @@ public extension ScenePlugin { @available(iOS 13.0, *) func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {} -} - -public protocol WindowDelegate: class { - var window: UIWindow? { get set } + + @available(iOS 13.0, *) + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {} } #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/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/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/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/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..c668489f 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. @@ -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 @@ -593,177 +610,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/DateFormatter.swift b/Sources/ZamzamCore/Extensions/DateFormatter.swift index c219b7a2..bffb10bc 100644 --- a/Sources/ZamzamCore/Extensions/DateFormatter.swift +++ b/Sources/ZamzamCore/Extensions/DateFormatter.swift @@ -82,3 +82,19 @@ public extension DateFormatter { } } } + +public extension String.StringInterpolation { + + private static let formatter = DateFormatter( + iso8601Format: "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/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/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/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/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/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/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/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.. 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 @@ -207,107 +252,11 @@ public extension String { } } -// MARK: - Web utilities - -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..("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/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/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/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 diff --git a/Sources/ZamzamCore/Logging/LogAPI.swift b/Sources/ZamzamCore/Logging/LogAPI.swift index 3e9ccee9..1eb46cb7 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. @@ -11,90 +11,134 @@ import Foundation // Namespace 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. - */ - 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. - */ - 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. - */ - 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. - */ - 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. - */ - func error(_ message: String, path: String, function: String, line: Int, context: [String: Any]?) +public protocol LogStore { + + /// 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: CustomStringConvertible]?) + + /// 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: CustomStringConvertible]?) -> String } -public protocol LogWorkerType: LogStore {} -public extension LogWorkerType { +public extension LogStore { - /// 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 canWrite(for level: LogAPI.Level) -> Bool { + minLevel <= level && level != .none } - /// 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 format(_ message: String, _ path: String, _ function: String, _ line: Int, _ context: [String: CustomStringConvertible]?) -> String { + "\(URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent).\(function):\(line) - \(message)" } +} + +public protocol LogProviderType { + + /// 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. + /// - 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: CustomStringConvertible]?, completion: (() -> Void)?) + + /// 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. + /// - completion: The block to call when log entries sent. + 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: + /// - 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. + /// - completion: The block to call when log entries sent. + 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) - func info(_ message: String, path: String = #file, function: String = #function, line: Int = #line) { - info(message, path: path, function: function, line: line, context: nil) - } + /// - 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. + /// - completion: The block to call when log entries sent. + 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) - func warn(_ message: String, path: String = #file, function: String = #function, line: Int = #line) { - warning(message, path: path, function: function, line: line, context: nil) - } + /// - 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. + /// - completion: The block to call when log entries sent. + 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) - func error(_ message: String, path: String = #file, function: String = #function, line: Int = #line) { - error(message, path: path, function: function, line: line, context: nil) + /// - 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. + /// - completion: The block to call when log entries sent. + func error(_ message: String, path: String, function: String, line: Int, context: [String: CustomStringConvertible]?, completion: (() -> Void)?) +} + +public extension LogProviderType { + + 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: 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: 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: 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: CustomStringConvertible]? = nil, completion: (() -> Void)? = nil) { + write(.error, with: message, path: path, function: function, line: line, context: context, completion: completion) } } @@ -102,13 +146,16 @@ public extension LogWorkerType { public extension LogAPI { - enum Level: Int, Comparable { + enum Level: Int, Comparable, CaseIterable { case verbose case debug case info 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 } diff --git a/Sources/ZamzamCore/Logging/LogProvider.swift b/Sources/ZamzamCore/Logging/LogProvider.swift new file mode 100644 index 00000000..4cdbc8ab --- /dev/null +++ b/Sources/ZamzamCore/Logging/LogProvider.swift @@ -0,0 +1,38 @@ +// +// LogProvider.swift +// ZamzamCore +// +// Created by Basem Emara on 2019-06-11. +// Copyright © 2019 Zamzam Inc. All rights reserved. +// + +import Foundation + +public struct LogProvider: LogProviderType { + private let stores: [LogStore] + + public init(stores: [LogStore]) { + self.stores = stores + } +} + +public extension LogProvider { + + 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 + guard !destinations.isEmpty else { + completion?() + return + } + + DispatchQueue.logger.async { + destinations.forEach { + $0.write(level, with: message, path: path, function: function, line: line, context: context) + } + + completion?() + } + } +} diff --git a/Sources/ZamzamCore/Logging/LogWorker.swift b/Sources/ZamzamCore/Logging/LogWorker.swift deleted file mode 100644 index db61034c..00000000 --- a/Sources/ZamzamCore/Logging/LogWorker.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Logger.swift -// ZamzamCore -// -// Created by Basem Emara on 2019-06-11. -// Copyright © 2019 Zamzam Inc. All rights reserved. -// - -import Foundation - -public struct LogWorker: LogWorkerType { - private let stores: [LogStore] - - public init(stores: [LogStore]) { - self.stores = stores - } -} - -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) } - } -} diff --git a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift index 96ea1a41..087f273f 100644 --- a/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift +++ b/Sources/ZamzamCore/Logging/Stores/LogConsoleStore.swift @@ -1,16 +1,17 @@ // -// File.swift +// LogConsoleStore.swift +// ZamzamCore // // // Created by Basem Emara on 2019-10-28. +// Copyright © 2019 Zamzam Inc. All rights reserved. // 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 +20,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: CustomStringConvertible]?) { + let prefix: String + + switch level { + case .verbose: + prefix = "💜 \(timestamp: Date()) VERBOSE" + case .debug: + prefix = "💚 \(timestamp: Date()) DEBUG" + case .info: + prefix = "💙 \(timestamp: Date()) INFO" + case .warning: + prefix = "💛 \(timestamp: Date()) WARNING" + case .error: + prefix = "❤️ \(timestamp: Date()) 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..ccfeeda6 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 @@ -10,13 +12,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 +27,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: CustomStringConvertible]?) { + 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, format(message, path, function, line, context)) } } diff --git a/Sources/ZamzamCore/Network/NetworkAPI.swift b/Sources/ZamzamCore/Network/NetworkAPI.swift new file mode 100644 index 00000000..c8d0bdd0 --- /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) +} + +/// 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. + /// + /// - 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/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 69% rename from Sources/ZamzamCore/Preferences/Preferences.swift rename to Sources/ZamzamCore/Preferences/Shared/Preferences.swift index 463b2db3..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? { - return store.get(key) + 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 29bfaf67..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? { - return 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/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/Dependencies.swift b/Sources/ZamzamCore/Utilities/Dependencies.swift deleted file mode 100644 index adf11c72..00000000 --- a/Sources/ZamzamCore/Utilities/Dependencies.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// A Swift micro-library that provides lightweight dependency injection. -// -// 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. -// - -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()) -/// } -/// } -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) { - 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 { - 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 - } -} - -// MARK: - Public API - -public extension Dependencies { - /// Composition root container of dependencies. - fileprivate static var root = Dependencies() - - /// Construct dependency resolutions. - convenience init(@ModuleBuilder _ modules: () -> [Module]) { - self.init() - modules().forEach { add(module: $0) } - } - - /// Construct dependency resolution. - convenience init(@ModuleBuilder _ module: () -> Module) { - self.init() - add(module: module()) - } - - /// Assigns the current container to the composition root. - func build() { - // Used later in property wrapper - Self.root = self - } - - /// DSL for declaring modules within the container dependency initializer. - @_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. -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 - } -} - -/// Resolves an instance from the dependency injection container. -@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 - } -} 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/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 {} 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 aab80ef5..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,9 +247,11 @@ public extension LocationWorker { // MARK: - Delegates -extension LocationWorker: CLLocationManagerDelegate { +extension LocationProvider: 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 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/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/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 -} 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. // 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() /// } /// } /// } 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/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] 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/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 diff --git a/Tests/ZamzamCoreTests/DecodableTests.swift b/Tests/ZamzamCoreTests/DecodableTests.swift index 815602e5..a428d392 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 testAnyDecodable() { + // 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")) - } -} diff --git a/Tests/ZamzamCoreTests/DependencyTests.swift b/Tests/ZamzamCoreTests/DependencyTests.swift deleted file mode 100644 index c1a3a286..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 widgetWorker: WidgetWorkerType = 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 = widgetWorker.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() -> WidgetWorkerType { - WidgetWorker( - 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 WidgetWorker: WidgetWorkerType { - 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() -> WidgetWorkerType - 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 WidgetWorkerType { - func fetch(id: Int) -> String -} - -protocol HTTPServiceType { - func get(url: String) -> String - func post(url: String) -> String -} diff --git a/Tests/ZamzamCoreTests/LoggingTests.swift b/Tests/ZamzamCoreTests/LoggingTests.swift new file mode 100644 index 00000000..8ca31fbe --- /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: LogProviderType = LogProvider(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() + } + } + + group.notify(queue: .global()) { + 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], []) + } +} + +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: LogProviderType = LogProvider(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() + } + } + } + + group.notify(queue: .global()) { + 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) + } +} + +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: CustomStringConvertible]?) { + entries.updateValue(entries[level, default: []] + [message], forKey: level) + } + } +} 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]? + } +} 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") + } +} 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") -} diff --git a/Tests/ZamzamCoreTests/StringTests.swift b/Tests/ZamzamCoreTests/StringTests.swift index f59136ff..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 { @@ -151,6 +185,16 @@ extension StringTests { } } +extension StringTests { + + func testSHA256() { + XCTAssertEqual( + "JYGK Udsf6ITR^%$#UTY6GI7UGdsf gdsfgSDKHkjb768stb&(&T* &".sha256(), + "71e80ab896673f757d3e378d9191d8432346d961cb59e224de31977bc23def76" + ) + } +} + extension StringTests { func testHTMLStripped() { 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 {