diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index bd5a175..d14dd42 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -27,3 +27,18 @@ jobs: run: swift build -v - name: Run tests run: swift test -v + + swift-lint: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Run SwiftLint + uses: norio-nomura/action-swiftlint@3.2.1 + # TODO: enable these settings: + # env: + # DIFF_BASE: ${{ github.base_ref }} + # with: + # args: --strict diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..a717917 --- /dev/null +++ b/.swiftformat @@ -0,0 +1 @@ +--disable redundantInternal diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..94018e1 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,17 @@ +excluded: # paths to ignore during linting. Takes precedence over `included`. + - Carthage + - .build + +disabled_rules: # rule identifiers to exclude from running + - nesting + - trailing_whitespace + - opening_brace + - trailing_comma + +line_length: + warning: 140 + error: 160 + ignores_comments: true + ignores_urls: true + + \ No newline at end of file diff --git a/Example/FlagsmithClient/AppDelegate.swift b/Example/FlagsmithClient/AppDelegate.swift index a68b1d0..16c2daa 100644 --- a/Example/FlagsmithClient/AppDelegate.swift +++ b/Example/FlagsmithClient/AppDelegate.swift @@ -9,30 +9,31 @@ import UIKit import FlagsmithClient -func isSuccess(_ result: Result) -> Bool { +func isSuccess(_ result: Result) -> Bool { if case .success = result { return true } else { return false } } @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - + var window: UIWindow? let concurrentQueue = DispatchQueue(label: "concurrentQueue", attributes: .concurrent) - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. Flagsmith.shared.apiKey = "" - + // set default flags Flagsmith.shared.defaultFlags = [Flag(featureName: "feature_a", enabled: false), - Flag(featureName: "font_size", intValue:12, enabled: true), - Flag(featureName: "my_name", stringValue:"Testing", enabled: true)] - + Flag(featureName: "font_size", intValue: 12, enabled: true), + Flag(featureName: "my_name", stringValue: "Testing", enabled: true)] + // set cache on / off (defaults to off) Flagsmith.shared.cacheConfig.useCache = true - + // set custom cache to use (defaults to shared URLCache) - //Flagsmith.shared.cacheConfig.cache = + // Flagsmith.shared.cacheConfig.cache = // set skip API on / off (defaults to off) Flagsmith.shared.cacheConfig.skipAPI = false @@ -42,53 +43,53 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // set analytics on or off Flagsmith.shared.enableAnalytics = true - + // set the analytics flush period in seconds Flagsmith.shared.analyticsFlushPeriod = 10 - - Flagsmith.shared.getFeatureFlags() { (result) in + + Flagsmith.shared.getFeatureFlags { (result) in print(result) } Flagsmith.shared.hasFeatureFlag(withID: "freeze_delinquent_accounts") { (result) in print(result) } - + // Try getting the feature flags concurrently to ensure that this does not cause any issues // This was originally highlighted in https://github.com/Flagsmith/flagsmith-ios-client/pull/40 for _ in 1...20 { concurrentQueue.async { - Flagsmith.shared.getFeatureFlags() { (result) in + Flagsmith.shared.getFeatureFlags { (_) in } } } - - //Flagsmith.shared.setTrait(Trait(key: "", value: ""), forIdentity: "") { (result) in print(result) } - //Flagsmith.shared.getIdentity("") { (result) in print(result) } + + // Flagsmith.shared.setTrait(Trait(key: "", value: ""), forIdentity: "") { (result) in print(result) } + // Flagsmith.shared.getIdentity("") { (result) in print(result) } return true } - + func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. } - + func applicationDidEnterBackground(_ application: UIApplication) { // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. } - + func applicationWillEnterForeground(_ application: UIApplication) { // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. } - + func applicationDidBecomeActive(_ application: UIApplication) { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. } - + func applicationWillTerminate(_ application: UIApplication) { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } - + #if swift(>=5.5.2) /// (Example) Setup the app based on the available feature flags. /// @@ -98,7 +99,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { @available(iOS 13.0, *) func determineAppConfiguration() async { let flagsmith = Flagsmith.shared - + do { if try await flagsmith.hasFeatureFlag(withID: "ab_test_enabled") { if let theme = try await flagsmith.getValueForFeature(withID: "app_theme") { @@ -116,7 +117,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { print(error) } } - + func setTheme(_ theme: TypedValue) {} func processFlags(_ flags: [Flag]) {} #endif diff --git a/Example/FlagsmithClient/ViewController.swift b/Example/FlagsmithClient/ViewController.swift index 0549c97..bd273ba 100644 --- a/Example/FlagsmithClient/ViewController.swift +++ b/Example/FlagsmithClient/ViewController.swift @@ -10,15 +10,5 @@ import UIKit import FlagsmithClient class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view, typically from a nib. - } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. - } - + } diff --git a/FlagsmithClient/Classes/Feature.swift b/FlagsmithClient/Classes/Feature.swift index 446bb4f..f4618c9 100644 --- a/FlagsmithClient/Classes/Feature.swift +++ b/FlagsmithClient/Classes/Feature.swift @@ -11,27 +11,21 @@ import Foundation A Feature represents a flag or remote configuration value on the server. */ public struct Feature: Codable, Sendable { - enum CodingKeys: String, CodingKey { - case name - case type - case description - } - - /// The name of the feature - public let name: String - public let type: String? - public let description: String? - - init(name: String, type: String?, description: String?) { - self.name = name - self.type = type - self.description = description - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.name, forKey: .name) - try container.encodeIfPresent(self.type, forKey: .type) - try container.encodeIfPresent(self.description, forKey: .description) - } + enum CodingKeys: String, CodingKey { + case name + case type + case description + } + + /// The name of the feature + public let name: String + public let type: String? + public let description: String? + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(type, forKey: .type) + try container.encodeIfPresent(description, forKey: .description) + } } diff --git a/FlagsmithClient/Classes/Flag.swift b/FlagsmithClient/Classes/Flag.swift index b9a9ebc..2a9d792 100644 --- a/FlagsmithClient/Classes/Flag.swift +++ b/FlagsmithClient/Classes/Flag.swift @@ -8,49 +8,66 @@ import Foundation /** -A Flag represents a feature flag on the server. -*/ + A Flag represents a feature flag on the server. + */ public struct Flag: Codable, Sendable { - enum CodingKeys: String, CodingKey { - case feature - case value = "feature_state_value" - case enabled - } - - public let feature: Feature - public let value: TypedValue - public let enabled: Bool - - public init(featureName:String, boolValue: Bool, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.bool(boolValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, floatValue: Float, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.float(floatValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, intValue: Int, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.int(intValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, stringValue: String, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.string(stringValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.null, enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, value: TypedValue, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.feature = Feature(name: featureName, type: featureType, description: featureDescription) - self.value = value - self.enabled = enabled - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.feature, forKey: .feature) - try container.encode(self.value, forKey: .value) - try container.encode(self.enabled, forKey: .enabled) - } + enum CodingKeys: String, CodingKey { + case feature + case value = "feature_state_value" + case enabled + } + + public let feature: Feature + public let value: TypedValue + public let enabled: Bool + + public init(featureName: String, boolValue: Bool, enabled: Bool, + featureType: String? = nil, featureDescription: String? = nil) + { + self.init(featureName: featureName, value: TypedValue.bool(boolValue), enabled: enabled, + featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, floatValue: Float, enabled: Bool, + featureType: String? = nil, featureDescription: String? = nil) + { + self.init(featureName: featureName, value: TypedValue.float(floatValue), enabled: enabled, + featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, intValue: Int, enabled: Bool, + featureType: String? = nil, featureDescription: String? = nil) + { + self.init(featureName: featureName, value: TypedValue.int(intValue), enabled: enabled, + featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, stringValue: String, enabled: Bool, + featureType: String? = nil, featureDescription: String? = nil) + { + self.init(featureName: featureName, value: TypedValue.string(stringValue), enabled: enabled, + featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, enabled: Bool, + featureType: String? = nil, featureDescription: String? = nil) + { + self.init(featureName: featureName, value: TypedValue.null, enabled: enabled, + featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, value: TypedValue, enabled: Bool, + featureType: String? = nil, featureDescription: String? = nil) + { + feature = Feature(name: featureName, type: featureType, description: featureDescription) + self.value = value + self.enabled = enabled + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(feature, forKey: .feature) + try container.encode(value, forKey: .value) + try container.encode(enabled, forKey: .enabled) + } } diff --git a/FlagsmithClient/Classes/Flagsmith+Concurrency.swift b/FlagsmithClient/Classes/Flagsmith+Concurrency.swift index 0e0c2f1..20171a3 100644 --- a/FlagsmithClient/Classes/Flagsmith+Concurrency.swift +++ b/FlagsmithClient/Classes/Flagsmith+Concurrency.swift @@ -1,5 +1,5 @@ // -// Flagsmith.swift +// Flagsmith+Concurrency.swift // FlagsmithClient // // Created by Richard Piazza on 3/10/22. @@ -9,173 +9,173 @@ import Foundation @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) public extension Flagsmith { - /// Get all feature flags (flags and remote config) optionally for a specific identity - /// - /// - Parameters: - /// - identity: ID of the user (optional) - /// - returns: Collection of Flag objects - func getFeatureFlags(forIdentity identity: String? = nil) async throws -> [Flag] { - try await withCheckedThrowingContinuation { continuation in - getFeatureFlags(forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + /// Get all feature flags (flags and remote config) optionally for a specific identity + /// + /// - Parameters: + /// - identity: ID of the user (optional) + /// - returns: Collection of Flag objects + func getFeatureFlags(forIdentity identity: String? = nil) async throws -> [Flag] { + try await withCheckedThrowingContinuation { continuation in + getFeatureFlags(forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } } - } - - /// Check feature exists and is enabled optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - returns: Bool value of the feature - func hasFeatureFlag(withID id: String, forIdentity identity: String? = nil) async throws -> Bool { - try await withCheckedThrowingContinuation({ continuation in - hasFeatureFlag(withID: id, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + + /// Check feature exists and is enabled optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - returns: Bool value of the feature + func hasFeatureFlag(withID id: String, forIdentity identity: String? = nil) async throws -> Bool { + try await withCheckedThrowingContinuation { continuation in + hasFeatureFlag(withID: id, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } + } + } + + /// Get remote config value optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - returns: String value of the feature if available + @available(*, deprecated, renamed: "getValueForFeature(withID:forIdentity:)") + func getFeatureValue(withID id: String, forIdentity identity: String? = nil) async throws -> String? { + try await withCheckedThrowingContinuation { continuation in + getFeatureValue(withID: id, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Get remote config value optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - returns: String value of the feature if available - @available(*, deprecated, renamed: "getValueForFeature(withID:forIdentity:)") - func getFeatureValue(withID id: String, forIdentity identity: String? = nil) async throws -> String? { - try await withCheckedThrowingContinuation({ continuation in - getFeatureValue(withID: id, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Get remote config value optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - returns: String value of the feature if available + func getValueForFeature(withID id: String, forIdentity identity: String? = nil) async throws -> TypedValue? { + try await withCheckedThrowingContinuation { continuation in + getValueForFeature(withID: id, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Get remote config value optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - returns: String value of the feature if available - func getValueForFeature(withID id: String, forIdentity identity: String? = nil) async throws -> TypedValue? { - try await withCheckedThrowingContinuation({ continuation in - getValueForFeature(withID: id, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Get all user traits for provided identity. Optionally filter results with a list of keys + /// + /// - Parameters: + /// - ids: IDs of the trait (optional) + /// - identity: ID of the user + /// - returns: Collection of Trait objects + func getTraits(withIDS ids: [String]? = nil, forIdentity identity: String) async throws -> [Trait] { + try await withCheckedThrowingContinuation { continuation in + getTraits(withIDS: ids, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Get all user traits for provided identity. Optionally filter results with a list of keys - /// - /// - Parameters: - /// - ids: IDs of the trait (optional) - /// - identity: ID of the user - /// - returns: Collection of Trait objects - func getTraits(withIDS ids: [String]? = nil, forIdentity identity: String) async throws -> [Trait] { - try await withCheckedThrowingContinuation({ continuation in - getTraits(withIDS: ids, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Get user trait for provided identity and trait key + /// + /// - Parameters: + /// - id: ID of the trait + /// - identity: ID of the user + /// - returns: Optional Trait if found. + func getTrait(withID id: String, forIdentity identity: String) async throws -> Trait? { + try await withCheckedThrowingContinuation { continuation in + getTrait(withID: id, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Get user trait for provided identity and trait key - /// - /// - Parameters: - /// - id: ID of the trait - /// - identity: ID of the user - /// - returns: Optional Trait if found. - func getTrait(withID id: String, forIdentity identity: String) async throws -> Trait? { - try await withCheckedThrowingContinuation({ continuation in - getTrait(withID: id, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Set user trait for provided identity + /// + /// - Parameters: + /// - trait: Trait to be created or updated + /// - identity: ID of the user + /// - returns: The Trait requested to be set. + @discardableResult func setTrait(_ trait: Trait, forIdentity identity: String) async throws -> Trait { + try await withCheckedThrowingContinuation { continuation in + setTrait(trait, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Set user trait for provided identity - /// - /// - Parameters: - /// - trait: Trait to be created or updated - /// - identity: ID of the user - /// - returns: The Trait requested to be set. - @discardableResult func setTrait(_ trait: Trait, forIdentity identity: String) async throws -> Trait { - try await withCheckedThrowingContinuation({ continuation in - setTrait(trait, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Set user traits in bulk for provided identity + /// + /// - Parameters: + /// - trait: Traits to be created or updated + /// - identity: ID of the user + /// - returns: The Traits requested to be set. + @discardableResult func setTraits(_ traits: [Trait], forIdentity identity: String) async throws -> [Trait] { + try await withCheckedThrowingContinuation { continuation in + setTraits(traits, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } + } - /// Set user traits in bulk for provided identity - /// - /// - Parameters: - /// - trait: Traits to be created or updated - /// - identity: ID of the user - /// - returns: The Traits requested to be set. - @discardableResult func setTraits(_ traits: [Trait], forIdentity identity: String) async throws -> [Trait] { - try await withCheckedThrowingContinuation({ continuation in - setTraits(traits, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) - } - } - }) - } - - /// Get both feature flags and user traits for the provided identity - /// - /// - Parameters: - /// - identity: ID of the user - /// - returns: Identity matching the requested ID. - func getIdentity(_ identity: String) async throws -> Identity { - try await withCheckedThrowingContinuation({ continuation in - getIdentity(identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + /// Get both feature flags and user traits for the provided identity + /// + /// - Parameters: + /// - identity: ID of the user + /// - returns: Identity matching the requested ID. + func getIdentity(_ identity: String) async throws -> Identity { + try await withCheckedThrowingContinuation { continuation in + getIdentity(identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } + } } diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 495fff6..28331d7 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -7,274 +7,274 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Manage feature flags and remote config across multiple projects, /// environments and organisations. public class Flagsmith { - /// Shared singleton client object - public static let shared = Flagsmith() - private let apiManager = APIManager() - private lazy var analytics = FlagsmithAnalytics(apiManager: apiManager) - - /// Base URL - /// - /// The default implementation uses: `https://edge.api.flagsmith.com/api/v1`. - public var baseURL: URL { - set { apiManager.baseURL = newValue } - get { apiManager.baseURL } - } - - /// API Key unique to your organization. - /// - /// This value must be provided before any request can succeed. - public var apiKey: String? { - set { apiManager.apiKey = newValue } - get { apiManager.apiKey } - } + /// Shared singleton client object + public static let shared = Flagsmith() + private let apiManager = APIManager() + private lazy var analytics = FlagsmithAnalytics(apiManager: apiManager) - /// Is flag analytics enabled? - public var enableAnalytics: Bool { - set { analytics.enableAnalytics = newValue } - get { analytics.enableAnalytics } - } + /// Base URL + /// + /// The default implementation uses: `https://edge.api.flagsmith.com/api/v1`. + public var baseURL: URL { + get { apiManager.baseURL } + set { apiManager.baseURL = newValue } + } - /// How often to send the flag analytics, in seconds - public var analyticsFlushPeriod: Int { - set { analytics.flushPeriod = newValue } - get { analytics.flushPeriod } - } - - /// Default flags to fall back on if an API call fails - public var defaultFlags: [Flag] = [] - - /// Configuration class for the cache settings - public var cacheConfig:CacheConfig = CacheConfig() + /// API Key unique to your organization. + /// + /// This value must be provided before any request can succeed. + public var apiKey: String? { + get { apiManager.apiKey } + set { apiManager.apiKey = newValue } + } - private init() { - } - - /// Get all feature flags (flags and remote config) optionally for a specific identity - /// - /// - Parameters: - /// - identity: ID of the user (optional) - /// - completion: Closure with Result which contains array of Flag objects in case of success or Error in case of failure - public func getFeatureFlags(forIdentity identity: String? = nil, - completion: @escaping (Result<[Flag], Error>) -> Void) { - - if let identity = identity { - getIdentity(identity) { (result) in - switch result { - case .success(let thisIdentity): - completion(.success(thisIdentity.flags)) - case .failure(let error): - if self.defaultFlags.isEmpty { - completion(.failure(error)) - } - else { - completion(.success(self.defaultFlags)) - } - } - } - } else { - apiManager.request(.getFlags) { (result: Result<[Flag], Error>) in - switch result { - case .success(let flags): - completion(.success(flags)) - case .failure(let error): - if self.defaultFlags.isEmpty { - completion(.failure(error)) - } - else { - completion(.success(self.defaultFlags)) - } - } - } + /// Is flag analytics enabled? + public var enableAnalytics: Bool { + get { analytics.enableAnalytics } + set { analytics.enableAnalytics = newValue } } - } - - /// Check feature exists and is enabled optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - completion: Closure with Result which contains Bool in case of success or Error in case of failure - public func hasFeatureFlag(withID id: String, - forIdentity identity: String? = nil, - completion: @escaping (Result) -> Void) { - analytics.trackEvent(flagName: id) - getFeatureFlags(forIdentity: identity) { (result) in - switch result { - case .success(let flags): - let hasFlag = flags.contains(where: {$0.feature.name == id && $0.enabled}) - completion(.success(hasFlag)) - case .failure(let error): - if self.defaultFlags.contains(where: {$0.feature.name == id && $0.enabled}) { - completion(.success(true)) - } - else { - completion(.failure(error)) + + /// How often to send the flag analytics, in seconds + public var analyticsFlushPeriod: Int { + get { analytics.flushPeriod } + set { analytics.flushPeriod = newValue } + } + + /// Default flags to fall back on if an API call fails + public var defaultFlags: [Flag] = [] + + /// Configuration class for the cache settings + public var cacheConfig: CacheConfig = .init() + + private init() {} + + /// Get all feature flags (flags and remote config) optionally for a specific identity + /// + /// - Parameters: + /// - identity: ID of the user (optional) + /// - completion: Closure with Result which contains array of Flag objects in case of success or Error in case of failure + public func getFeatureFlags(forIdentity identity: String? = nil, + completion: @escaping (Result<[Flag], Error>) -> Void) + { + if let identity = identity { + getIdentity(identity) { result in + switch result { + case let .success(thisIdentity): + completion(.success(thisIdentity.flags)) + case let .failure(error): + if self.defaultFlags.isEmpty { + completion(.failure(error)) + } else { + completion(.success(self.defaultFlags)) + } + } + } + } else { + apiManager.request(.getFlags) { (result: Result<[Flag], Error>) in + switch result { + case let .success(flags): + completion(.success(flags)) + case let .failure(error): + if self.defaultFlags.isEmpty { + completion(.failure(error)) + } else { + completion(.success(self.defaultFlags)) + } + } + } } - } } - } - - /// Get remote config value optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - completion: Closure with Result which String in case of success or Error in case of failure - @available(*, deprecated, renamed: "getValueForFeature(withID:forIdentity:completion:)") - public func getFeatureValue(withID id: String, - forIdentity identity: String? = nil, - completion: @escaping (Result) -> Void) { - analytics.trackEvent(flagName: id) - getFeatureFlags(forIdentity: identity) { (result) in - switch result { - case .success(let flags): - let flag = flags.first(where: {$0.feature.name == id}) - completion(.success(flag?.value.stringValue)) - case .failure(let error): - if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { - completion(.success(flag.value.stringValue)) + + /// Check feature exists and is enabled optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - completion: Closure with Result which contains Bool in case of success or Error in case of failure + public func hasFeatureFlag(withID id: String, + forIdentity identity: String? = nil, + completion: @escaping (Result) -> Void) + { + analytics.trackEvent(flagName: id) + getFeatureFlags(forIdentity: identity) { result in + switch result { + case let .success(flags): + let hasFlag = flags.contains(where: { $0.feature.name == id && $0.enabled }) + completion(.success(hasFlag)) + case let .failure(error): + if self.defaultFlags.contains(where: { $0.feature.name == id && $0.enabled }) { + completion(.success(true)) + } else { + completion(.failure(error)) + } + } } - else { - completion(.failure(error)) + } + + /// Get remote config value optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - completion: Closure with Result which String in case of success or Error in case of failure + @available(*, deprecated, renamed: "getValueForFeature(withID:forIdentity:completion:)") + public func getFeatureValue(withID id: String, + forIdentity identity: String? = nil, + completion: @escaping (Result) -> Void) + { + analytics.trackEvent(flagName: id) + getFeatureFlags(forIdentity: identity) { result in + switch result { + case let .success(flags): + let flag = flags.first(where: { $0.feature.name == id }) + completion(.success(flag?.value.stringValue)) + case let .failure(error): + if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { + completion(.success(flag.value.stringValue)) + } else { + completion(.failure(error)) + } + } } - } } - } - - /// Get remote config value optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - completion: Closure with Result of `TypedValue` in case of success or `Error` in case of failure - public func getValueForFeature(withID id: String, - forIdentity identity: String? = nil, - completion: @escaping (Result) -> Void) { - analytics.trackEvent(flagName: id) - getFeatureFlags(forIdentity: identity) { (result) in - switch result { - case .success(let flags): - let flag = flags.first(where: {$0.feature.name == id}) - completion(.success(flag?.value)) - case .failure(let error): - if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { - completion(.success(flag.value)) + + /// Get remote config value optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - completion: Closure with Result of `TypedValue` in case of success or `Error` in case of failure + public func getValueForFeature(withID id: String, + forIdentity identity: String? = nil, + completion: @escaping (Result) -> Void) + { + analytics.trackEvent(flagName: id) + getFeatureFlags(forIdentity: identity) { result in + switch result { + case let .success(flags): + let flag = flags.first(where: { $0.feature.name == id }) + completion(.success(flag?.value)) + case let .failure(error): + if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { + completion(.success(flag.value)) + } else { + completion(.failure(error)) + } + } } - else { - completion(.failure(error)) + } + + /// Get all user traits for provided identity. Optionally filter results with a list of keys + /// + /// - Parameters: + /// - ids: IDs of the trait (optional) + /// - identity: ID of the user + /// - completion: Closure with Result which contains array of Trait objects in case of success or Error in case of failure + public func getTraits(withIDS ids: [String]? = nil, + forIdentity identity: String, + completion: @escaping (Result<[Trait], Error>) -> Void) + { + getIdentity(identity) { result in + switch result { + case let .success(identity): + if let ids = ids { + let traits = identity.traits.filter { ids.contains($0.key) } + completion(.success(traits)) + } else { + completion(.success(identity.traits)) + } + case let .failure(error): + completion(.failure(error)) + } } - } } - } - - /// Get all user traits for provided identity. Optionally filter results with a list of keys - /// - /// - Parameters: - /// - ids: IDs of the trait (optional) - /// - identity: ID of the user - /// - completion: Closure with Result which contains array of Trait objects in case of success or Error in case of failure - public func getTraits(withIDS ids: [String]? = nil, - forIdentity identity: String, - completion: @escaping (Result<[Trait], Error>) -> Void) { - getIdentity(identity) { (result) in - switch result { - case .success(let identity): - if let ids = ids { - let traits = identity.traits.filter({ids.contains($0.key)}) - completion(.success(traits)) - } else { - completion(.success(identity.traits)) + + /// Get user trait for provided identity and trait key + /// + /// - Parameters: + /// - id: ID of the trait + /// - identity: ID of the user + /// - completion: Closure with Result which contains Trait in case of success or Error in case of failure + public func getTrait(withID id: String, + forIdentity identity: String, + completion: @escaping (Result) -> Void) + { + getIdentity(identity) { result in + switch result { + case let .success(identity): + let trait = identity.traits.first(where: { $0.key == id }) + completion(.success(trait)) + case let .failure(error): + completion(.failure(error)) + } } - case .failure(let error): - completion(.failure(error)) - } } - } - - /// Get user trait for provided identity and trait key - /// - /// - Parameters: - /// - id: ID of the trait - /// - identity: ID of the user - /// - completion: Closure with Result which contains Trait in case of success or Error in case of failure - public func getTrait(withID id: String, - forIdentity identity: String, - completion: @escaping (Result) -> Void) { - getIdentity(identity) { (result) in - switch result { - case .success(let identity): - let trait = identity.traits.first(where: {$0.key == id}) - completion(.success(trait)) - case .failure(let error): - completion(.failure(error)) - } + + /// Set user trait for provided identity + /// + /// - Parameters: + /// - trait: Trait to be created or updated + /// - identity: ID of the user + /// - completion: Closure with Result which contains Trait in case of success or Error in case of failure + public func setTrait(_ trait: Trait, + forIdentity identity: String, + completion: @escaping (Result) -> Void) + { + apiManager.request(.postTrait(trait: trait, identity: identity)) { (result: Result) in + completion(result) + } } - } - - /// Set user trait for provided identity - /// - /// - Parameters: - /// - trait: Trait to be created or updated - /// - identity: ID of the user - /// - completion: Closure with Result which contains Trait in case of success or Error in case of failure - public func setTrait(_ trait: Trait, - forIdentity identity: String, - completion: @escaping (Result) -> Void) { - apiManager.request(.postTrait(trait: trait, identity: identity)) { (result: Result) in - completion(result) + + /// Set user traits in bulk for provided identity + /// + /// - Parameters: + /// - traits: Traits to be created or updated + /// - identity: ID of the user + /// - completion: Closure with Result which contains Traits in case of success or Error in case of failure + public func setTraits(_ traits: [Trait], + forIdentity identity: String, + completion: @escaping (Result<[Trait], Error>) -> Void) + { + apiManager.request(.postTraits(identity: identity, traits: traits)) { (result: Result) in + completion(result.map(\.traits)) + } } - } - /// Set user traits in bulk for provided identity - /// - /// - Parameters: - /// - traits: Traits to be created or updated - /// - identity: ID of the user - /// - completion: Closure with Result which contains Traits in case of success or Error in case of failure - public func setTraits(_ traits: [Trait], - forIdentity identity: String, - completion: @escaping (Result<[Trait], Error>) -> Void) { - apiManager.request(.postTraits(identity: identity, traits: traits)) { (result: Result) in - completion(result.map(\.traits)) + /// Get both feature flags and user traits for the provided identity + /// + /// - Parameters: + /// - identity: ID of the user + /// - completion: Closure with Result which contains Identity in case of success or Error in case of failure + public func getIdentity(_ identity: String, + completion: @escaping (Result) -> Void) + { + apiManager.request(.getIdentity(identity: identity)) { (result: Result) in + completion(result) + } } - } - - /// Get both feature flags and user traits for the provided identity - /// - /// - Parameters: - /// - identity: ID of the user - /// - completion: Closure with Result which contains Identity in case of success or Error in case of failure - public func getIdentity(_ identity: String, - completion: @escaping (Result) -> Void) { - apiManager.request(.getIdentity(identity: identity)) { (result: Result) in - completion(result) + + /// Return a flag for a flag ID from the default flags. + private func getFlagUsingDefaults(withID id: String, forIdentity _: String? = nil) -> Flag? { + return defaultFlags.first(where: { $0.feature.name == id }) } - } - - /// Return a flag for a flag ID from the default flags. - private func getFlagUsingDefaults(withID id: String, forIdentity identity: String? = nil) -> Flag? { - return self.defaultFlags.first(where: {$0.feature.name == id}) - } } public class CacheConfig { + /// Cache to use when enabled, defaults to the shared app cache + public var cache: URLCache = .shared - /// Cache to use when enabled, defaults to the shared app cache - public var cache: URLCache = URLCache.shared - - /// Use cached flags as a fallback? - public var useCache: Bool = false + /// Use cached flags as a fallback? + public var useCache: Bool = false - /// TTL for the cache in seconds, default of 0 means infinite - public var cacheTTL: Double = 0 + /// TTL for the cache in seconds, default of 0 means infinite + public var cacheTTL: Double = 0 - /// Skip API if there is a cache available - public var skipAPI: Bool = false - + /// Skip API if there is a cache available + public var skipAPI: Bool = false } diff --git a/FlagsmithClient/Classes/FlagsmithError.swift b/FlagsmithClient/Classes/FlagsmithError.swift index fda9675..4f60711 100644 --- a/FlagsmithClient/Classes/FlagsmithError.swift +++ b/FlagsmithClient/Classes/FlagsmithError.swift @@ -21,24 +21,24 @@ public enum FlagsmithError: LocalizedError, Sendable { case decoding(DecodingError) /// Unknown or unhandled error was encountered. case unhandled(Error) - + public var errorDescription: String? { switch self { case .apiKey: return "API Key was not provided or invalid" - case .apiURL(let path): + case let .apiURL(path): return "API URL '\(path)' was invalid" - case .encoding(let error): + case let .encoding(error): return "API Request could not be encoded: \(error.localizedDescription)" - case .statusCode(let code): + case let .statusCode(code): return "API Status Code '\(code)' was not expected." - case .decoding(let error): + case let .decoding(error): return "API Response could not be decoded: \(error.localizedDescription)" - case .unhandled(let error): + case let .unhandled(error): return "An unknown or unhandled error was encountered: \(error.localizedDescription)" } } - + /// Initialize a `FlagsmithError` using an existing `Swift.Error`. /// /// The error provided will be processed in several ways: diff --git a/FlagsmithClient/Classes/Identity.swift b/FlagsmithClient/Classes/Identity.swift index 1dfbce3..0f97830 100644 --- a/FlagsmithClient/Classes/Identity.swift +++ b/FlagsmithClient/Classes/Identity.swift @@ -8,14 +8,14 @@ import Foundation /** -An Identity represents a user stored on the server. -*/ + An Identity represents a user stored on the server. + */ public struct Identity: Decodable, Sendable { - enum CodingKeys: String, CodingKey { - case flags - case traits - } - - public let flags: [Flag] - public let traits: [Trait] + enum CodingKeys: String, CodingKey { + case flags + case traits + } + + public let flags: [Flag] + public let traits: [Trait] } diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift index b13b898..7ae1052 100644 --- a/FlagsmithClient/Classes/Internal/APIManager.swift +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -7,146 +7,149 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Handles interaction with a **Flagsmith** api. -class APIManager : NSObject, URLSessionDataDelegate { - - private var session: URLSession! - - /// Base `URL` used for requests. - var baseURL = URL(string: "https://edge.api.flagsmith.com/api/v1/")! - /// API Key unique to an organization. - var apiKey: String? - - // store the completion handlers and accumulated data for each task - private var tasksToCompletionHandlers:[Int:(Result) -> Void] = [:] - private var tasksToData:[Int:Data] = [:] - private let serialAccessQueue = DispatchQueue(label: "flagsmithSerialAccessQueue") - - override init() { - super.init() - let configuration = URLSessionConfiguration.default - self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - serialAccessQueue.sync { - if let dataTask = task as? URLSessionDataTask { - if let completion = tasksToCompletionHandlers[dataTask.taskIdentifier] { - if let error = error { - DispatchQueue.main.async { completion(.failure(FlagsmithError.unhandled(error))) } - } - else { - let data = tasksToData[dataTask.taskIdentifier] ?? Data() - DispatchQueue.main.async { completion(.success(data)) } - } +class APIManager: NSObject, URLSessionDataDelegate { + private var session: URLSession! + + /// Base `URL` used for requests. + var baseURL = URL(string: "https://edge.api.flagsmith.com/api/v1/")! + /// API Key unique to an organization. + var apiKey: String? + + // store the completion handlers and accumulated data for each task + private var tasksToCompletionHandlers: [Int: (Result) -> Void] = [:] + private var tasksToData: [Int: Data] = [:] + private let serialAccessQueue = DispatchQueue(label: "flagsmithSerialAccessQueue") + + override init() { + super.init() + let configuration = URLSessionConfiguration.default + session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) + } + + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + serialAccessQueue.sync { + if let dataTask = task as? URLSessionDataTask { + if let completion = tasksToCompletionHandlers[dataTask.taskIdentifier] { + if let error = error { + DispatchQueue.main.async { completion(.failure(FlagsmithError.unhandled(error))) } + } else { + let data = tasksToData[dataTask.taskIdentifier] ?? Data() + DispatchQueue.main.async { completion(.success(data)) } + } + } + tasksToCompletionHandlers[dataTask.taskIdentifier] = nil + tasksToData[dataTask.taskIdentifier] = nil + } } - tasksToCompletionHandlers[dataTask.taskIdentifier] = nil - tasksToData[dataTask.taskIdentifier] = nil - } } - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, - completionHandler: @escaping (CachedURLResponse?) -> Void) { - serialAccessQueue.sync { - // intercept and modify the cache settings for the response - if Flagsmith.shared.cacheConfig.useCache { - let newResponse = proposedResponse.response(withExpirationDuration: Int(Flagsmith.shared.cacheConfig.cacheTTL)) - DispatchQueue.main.async { completionHandler(newResponse) } - } else { - DispatchQueue.main.async { completionHandler(proposedResponse) } - } + + func urlSession(_: URLSession, dataTask _: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, + completionHandler: @escaping (CachedURLResponse?) -> Void) + { + serialAccessQueue.sync { + // intercept and modify the cache settings for the response + if Flagsmith.shared.cacheConfig.useCache { + let newResponse = proposedResponse.response(withExpirationDuration: Int(Flagsmith.shared.cacheConfig.cacheTTL)) + DispatchQueue.main.async { completionHandler(newResponse) } + } else { + DispatchQueue.main.async { completionHandler(proposedResponse) } + } + } } - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - serialAccessQueue.sync { - var existingData = tasksToData[dataTask.taskIdentifier] ?? Data() - existingData.append(data) - tasksToData[dataTask.taskIdentifier] = existingData + + func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + serialAccessQueue.sync { + var existingData = tasksToData[dataTask.taskIdentifier] ?? Data() + existingData.append(data) + tasksToData[dataTask.taskIdentifier] = existingData + } } - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + + func urlSession(_: URLSession, dataTask _: URLSessionDataTask, didReceive _: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) + { completionHandler(.allow) } - - /// Base request method that handles creating a `URLRequest` and processing - /// the `URLSession` response. - /// - /// - parameters: - /// - router: The path and parameters that should be requested. - /// - completion: Function block executed with the result of the request. - private func request(_ router: Router, completion: @escaping (Result) -> Void) { - guard let apiKey = apiKey, !apiKey.isEmpty else { - completion(.failure(FlagsmithError.apiKey)) - return - } - - var request: URLRequest - do { - request = try router.request(baseUrl: baseURL, apiKey: apiKey) - } catch { - completion(.failure(error)) - return - } - - // set the cache policy based on Flagsmith settings - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - session.configuration.urlCache = Flagsmith.shared.cacheConfig.cache - if Flagsmith.shared.cacheConfig.useCache { - request.cachePolicy = .useProtocolCachePolicy - if Flagsmith.shared.cacheConfig.skipAPI { - request.cachePolicy = .returnCacheDataElseLoad - } - } - - // we must use the delegate form here, not the completion handler, to be able to modify the cache - serialAccessQueue.sync { - let task = session.dataTask(with: request) - tasksToCompletionHandlers[task.taskIdentifier] = completion - task.resume() - } - } - - /// Requests a api route and only relays success or failure of the action. - /// - /// - parameters: - /// - router: The path and parameters that should be requested. - /// - completion: Function block executed with the result of the request. - func request(_ router: Router, completion: @escaping (Result) -> Void) { - request(router) { (result: Result) in - switch result { - case .failure(let error): - completion(.failure(FlagsmithError(error))) - case .success: - completion(.success(())) - } - } - } - - /// Requests a api route and attempts the decode the response. - /// - /// - parameters: - /// - router: The path and parameters that should be requested. - /// - decoder: `JSONDecoder` used to deserialize the response data. - /// - completion: Function block executed with the result of the request. - func request(_ router: Router, using decoder: JSONDecoder = JSONDecoder(), completion: @escaping (Result) -> Void) { - request(router) { (result: Result) in - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let data): + + /// Base request method that handles creating a `URLRequest` and processing + /// the `URLSession` response. + /// + /// - parameters: + /// - router: The path and parameters that should be requested. + /// - completion: Function block executed with the result of the request. + private func request(_ router: Router, completion: @escaping (Result) -> Void) { + guard let apiKey = apiKey, !apiKey.isEmpty else { + completion(.failure(FlagsmithError.apiKey)) + return + } + + var request: URLRequest do { - let value = try decoder.decode(T.self, from: data) - completion(.success(value)) + request = try router.request(baseUrl: baseURL, apiKey: apiKey) } catch { - completion(.failure(FlagsmithError(error))) + completion(.failure(error)) + return + } + + // set the cache policy based on Flagsmith settings + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + session.configuration.urlCache = Flagsmith.shared.cacheConfig.cache + if Flagsmith.shared.cacheConfig.useCache { + request.cachePolicy = .useProtocolCachePolicy + if Flagsmith.shared.cacheConfig.skipAPI { + request.cachePolicy = .returnCacheDataElseLoad + } + } + + // we must use the delegate form here, not the completion handler, to be able to modify the cache + serialAccessQueue.sync { + let task = session.dataTask(with: request) + tasksToCompletionHandlers[task.taskIdentifier] = completion + task.resume() + } + } + + /// Requests a api route and only relays success or failure of the action. + /// + /// - parameters: + /// - router: The path and parameters that should be requested. + /// - completion: Function block executed with the result of the request. + func request(_ router: Router, completion: @escaping (Result) -> Void) { + request(router) { (result: Result) in + switch result { + case let .failure(error): + completion(.failure(FlagsmithError(error))) + case .success: + completion(.success(())) + } + } + } + + /// Requests a api route and attempts the decode the response. + /// + /// - parameters: + /// - router: The path and parameters that should be requested. + /// - decoder: `JSONDecoder` used to deserialize the response data. + /// - completion: Function block executed with the result of the request. + func request(_ router: Router, using decoder: JSONDecoder = JSONDecoder(), + completion: @escaping (Result) -> Void) + { + request(router) { (result: Result) in + switch result { + case let .failure(error): + completion(.failure(error)) + case let .success(data): + do { + let value = try decoder.decode(T.self, from: data) + completion(.success(value)) + } catch { + completion(.failure(FlagsmithError(error))) + } + } } - } } - } } diff --git a/FlagsmithClient/Classes/Internal/CachedURLResponse.swift b/FlagsmithClient/Classes/Internal/CachedURLResponse.swift index 730fce2..a724ae3 100644 --- a/FlagsmithClient/Classes/Internal/CachedURLResponse.swift +++ b/FlagsmithClient/Classes/Internal/CachedURLResponse.swift @@ -1,5 +1,5 @@ // -// File.swift +// CachedURLResponse.swift // CachedURLResponse // // Created by Daniel Wichett on 21/06/2023. @@ -7,23 +7,28 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif extension CachedURLResponse { func response(withExpirationDuration duration: Int) -> CachedURLResponse { var cachedResponse = self - if let httpResponse = cachedResponse.response as? HTTPURLResponse, var headers = httpResponse.allHeaderFields as? [String : String], let url = httpResponse.url{ - - //set to 1 year (the max allowed) if the value is 0 - headers["Cache-Control"] = "max-age=\(duration == 0 ? 31536000 : duration)" - headers.removeValue(forKey: "Expires") - headers.removeValue(forKey: "s-maxage") + if let httpResponse = cachedResponse.response as? HTTPURLResponse, + var headers = httpResponse.allHeaderFields as? [String: String], + let url = httpResponse.url + { + // set to 1 year (the max allowed) if the value is 0 + headers["Cache-Control"] = "max-age=\(duration == 0 ? 31_536_000 : duration)" + headers.removeValue(forKey: "Expires") + headers.removeValue(forKey: "s-maxage") - if let newResponse = HTTPURLResponse(url: url, statusCode: httpResponse.statusCode, httpVersion: "HTTP/1.1", headerFields: headers) { - cachedResponse = CachedURLResponse(response: newResponse, data: cachedResponse.data, userInfo: headers, storagePolicy: cachedResponse.storagePolicy) - } - } - return cachedResponse + if let newResponse = HTTPURLResponse(url: url, statusCode: httpResponse.statusCode, + httpVersion: "HTTP/1.1", headerFields: headers) + { + cachedResponse = CachedURLResponse(response: newResponse, data: cachedResponse.data, + userInfo: headers, storagePolicy: cachedResponse.storagePolicy) + } + } + return cachedResponse } } diff --git a/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift b/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift index d172b3b..cbe03e8 100644 --- a/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift +++ b/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift @@ -9,101 +9,101 @@ import Foundation /// Internal analytics for the **FlagsmithClient** class FlagsmithAnalytics { - - /// Indicates if analytics are enabled. - var enableAnalytics: Bool = true - /// How often analytics events are processed (in seconds). - var flushPeriod: Int = 10 { - didSet { - setupTimer() + /// Indicates if analytics are enabled. + var enableAnalytics: Bool = true + /// How often analytics events are processed (in seconds). + var flushPeriod: Int = 10 { + didSet { + setupTimer() + } } - } - - private unowned let apiManager: APIManager - private let EVENTS_KEY = "events" - private var events:[String:Int] = [:] - private var timer:Timer? - - init(apiManager: APIManager) { - self.apiManager = apiManager - events = UserDefaults.standard.dictionary(forKey: EVENTS_KEY) as? [String:Int] ?? [:] - setupTimer() - } - - /// Counts the instances of a `Flag` being queried. - func trackEvent(flagName:String) { - let current = events[flagName] ?? 0 - events[flagName] = current + 1 - saveEvents() - } - - /// Invalidate and re-schedule timer for processing events - /// - /// On Apple (Darwin) platforms, this uses the Objective-C based - /// target/selector message sending API. - /// - /// Non-Darwin systems will use the corelibs Foundation block-based - /// api. Both platforms could use this approach, but the podspec - /// declares iOS 8.0 as a minimum target, and that api is only - /// available on 10+. (12.0 would be a good base in the future). - private func setupTimer() { - timer?.invalidate() - #if canImport(ObjectiveC) - timer = Timer.scheduledTimer( - timeInterval: TimeInterval(flushPeriod), - target: self, - selector: #selector(postAnalyticsWhenEnabled(_:)), - userInfo: nil, - repeats: true - ) - #else - timer = Timer.scheduledTimer( - withTimeInterval: TimeInterval(flushPeriod), - repeats: true, - block: { [weak self] _ in - self?.postAnalytics() - } - ) - #endif - } - - /// Reset events after successful processing. - private func reset() { - events = [:] - saveEvents() - } - - /// Persist the events to storage. - private func saveEvents() { - UserDefaults.standard.set(events, forKey: EVENTS_KEY) - } - - /// Send analytics to the api when enabled. - private func postAnalytics() { - guard enableAnalytics else { - return + + private unowned let apiManager: APIManager + + private let eventsKey = "events" + private var events: [String: Int] = [:] + private var timer: Timer? + + init(apiManager: APIManager) { + self.apiManager = apiManager + events = UserDefaults.standard.dictionary(forKey: eventsKey) as? [String: Int] ?? [:] + setupTimer() + } + + /// Counts the instances of a `Flag` being queried. + func trackEvent(flagName: String) { + let current = events[flagName] ?? 0 + events[flagName] = current + 1 + saveEvents() + } + + /// Invalidate and re-schedule timer for processing events + /// + /// On Apple (Darwin) platforms, this uses the Objective-C based + /// target/selector message sending API. + /// + /// Non-Darwin systems will use the corelibs Foundation block-based + /// api. Both platforms could use this approach, but the podspec + /// declares iOS 8.0 as a minimum target, and that api is only + /// available on 10+. (12.0 would be a good base in the future). + private func setupTimer() { + timer?.invalidate() + #if canImport(ObjectiveC) + timer = Timer.scheduledTimer( + timeInterval: TimeInterval(flushPeriod), + target: self, + selector: #selector(postAnalyticsWhenEnabled(_:)), + userInfo: nil, + repeats: true + ) + #else + timer = Timer.scheduledTimer( + withTimeInterval: TimeInterval(flushPeriod), + repeats: true, + block: { [weak self] _ in + self?.postAnalytics() + } + ) + #endif } - - guard !events.isEmpty else { - return + + /// Reset events after successful processing. + private func reset() { + events = [:] + saveEvents() } - - apiManager.request(.postAnalytics(events: events)) { [weak self] (result: Result) in - switch result { - case .failure: - print("Upload analytics failed") - case .success: - self?.reset() - } + + /// Persist the events to storage. + private func saveEvents() { + UserDefaults.standard.set(events, forKey: eventsKey) } - } - - #if canImport(ObjectiveC) - /// Event triggered when timer fired. - /// - /// Exposed on Apple platforms to relay selector-based events - @objc private func postAnalyticsWhenEnabled(_ timer: Timer) { - postAnalytics() - } - #endif + + /// Send analytics to the api when enabled. + private func postAnalytics() { + guard enableAnalytics else { + return + } + + guard !events.isEmpty else { + return + } + + apiManager.request(.postAnalytics(events: events)) { [weak self] (result: Result) in + switch result { + case .failure: + print("Upload analytics failed") + case .success: + self?.reset() + } + } + } + + #if canImport(ObjectiveC) + /// Event triggered when timer fired. + /// + /// Exposed on Apple platforms to relay selector-based events + @objc private func postAnalyticsWhenEnabled(_: Timer) { + postAnalytics() + } + #endif } diff --git a/FlagsmithClient/Classes/Internal/Router.swift b/FlagsmithClient/Classes/Internal/Router.swift index 639be56..d8d76a6 100644 --- a/FlagsmithClient/Classes/Internal/Router.swift +++ b/FlagsmithClient/Classes/Internal/Router.swift @@ -7,98 +7,98 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif enum Router: Sendable { - private enum HTTPMethod: String { - case get = "GET" - case post = "POST" - } - - case getFlags - case getIdentity(identity: String) - case postTrait(trait: Trait, identity: String) - case postTraits(identity: String, traits: [Trait]) - case postAnalytics(events: [String:Int]) - - private var method: HTTPMethod { - switch self { - case .getFlags, .getIdentity: - return .get - case .postTrait, .postTraits, .postAnalytics: - return .post + private enum HTTPMethod: String { + case get = "GET" + case post = "POST" } - } - - private var path: String { - switch self { - case .getFlags: - return "flags/" - case .getIdentity, .postTraits: - return "identities/" - case .postTrait: - return "traits/" - case .postAnalytics: - return "analytics/flags/" - } - } - private var parameters: [URLQueryItem]? { - switch self { - case .getIdentity(let identity), .postTraits(let identity, _): - return [URLQueryItem(name: "identifier", value: identity)] - default: - return nil + case getFlags + case getIdentity(identity: String) + case postTrait(trait: Trait, identity: String) + case postTraits(identity: String, traits: [Trait]) + case postAnalytics(events: [String: Int]) + + private var method: HTTPMethod { + switch self { + case .getFlags, .getIdentity: + return .get + case .postTrait, .postTraits, .postAnalytics: + return .post + } } - } - private func body(using encoder: JSONEncoder) throws -> Data? { - switch self { - case .getFlags, .getIdentity: - return nil - case .postTrait(let trait, let identifier): - let traitWithIdentity = Trait(trait: trait, identifier: identifier) - return try encoder.encode(traitWithIdentity) - case .postTraits(let identifier, let traits): - let traitsWithIdentity = Traits(traits: traits, identifier: identifier) - return try encoder.encode(traitsWithIdentity) - case .postAnalytics(let events): - return try encoder.encode(events) + private var path: String { + switch self { + case .getFlags: + return "flags/" + case .getIdentity, .postTraits: + return "identities/" + case .postTrait: + return "traits/" + case .postAnalytics: + return "analytics/flags/" + } } - } - - /// Generate a `URLRequest` with headers and encoded body. - /// - /// - parameters: - /// - baseUrl: The base URL of the api on which to base the request. - /// - apiKey: The organization key to provide in the request headers. - /// - encoder: `JSONEncoder` used to encode the request body. - func request(baseUrl: URL, - apiKey: String, - using encoder: JSONEncoder = JSONEncoder() - ) throws -> URLRequest { - let urlString = baseUrl.appendingPathComponent(path).absoluteString - var urlComponents = URLComponents(string: urlString) - urlComponents?.queryItems = parameters - guard let url = urlComponents?.url else { - // This is unlikely to ever be hit, but it is safer than - // relying on the forcefully-unwrapped optional. - throw FlagsmithError.apiURL(urlString) + + private var parameters: [URLQueryItem]? { + switch self { + case let .getIdentity(identity), let .postTraits(identity, _): + return [URLQueryItem(name: "identifier", value: identity)] + default: + return nil + } } - - guard !url.isFileURL else { - throw FlagsmithError.apiURL(urlString) + + private func body(using encoder: JSONEncoder) throws -> Data? { + switch self { + case .getFlags, .getIdentity: + return nil + case let .postTrait(trait, identifier): + let traitWithIdentity = Trait(trait: trait, identifier: identifier) + return try encoder.encode(traitWithIdentity) + case let .postTraits(identifier, traits): + let traitsWithIdentity = Traits(traits: traits, identifier: identifier) + return try encoder.encode(traitsWithIdentity) + case let .postAnalytics(events): + return try encoder.encode(events) + } } - - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - if let body = try self.body(using: encoder) { - request.httpBody = body + + /// Generate a `URLRequest` with headers and encoded body. + /// + /// - parameters: + /// - baseUrl: The base URL of the api on which to base the request. + /// - apiKey: The organization key to provide in the request headers. + /// - encoder: `JSONEncoder` used to encode the request body. + func request(baseUrl: URL, + apiKey: String, + using encoder: JSONEncoder = JSONEncoder()) throws -> URLRequest + { + let urlString = baseUrl.appendingPathComponent(path).absoluteString + var urlComponents = URLComponents(string: urlString) + urlComponents?.queryItems = parameters + guard let url = urlComponents?.url else { + // This is unlikely to ever be hit, but it is safer than + // relying on the forcefully-unwrapped optional. + throw FlagsmithError.apiURL(urlString) + } + + guard !url.isFileURL else { + throw FlagsmithError.apiURL(urlString) + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + if let body = try body(using: encoder) { + request.httpBody = body + } + request.addValue(apiKey, forHTTPHeaderField: "X-Environment-Key") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + return request } - request.addValue(apiKey, forHTTPHeaderField: "X-Environment-Key") - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - return request - } } diff --git a/FlagsmithClient/Classes/Trait.swift b/FlagsmithClient/Classes/Trait.swift index 85e9ec6..8195915 100644 --- a/FlagsmithClient/Classes/Trait.swift +++ b/FlagsmithClient/Classes/Trait.swift @@ -8,126 +8,128 @@ import Foundation /** -A Trait represents a value stored against an Identity (user) on the server. -*/ + A Trait represents a value stored against an Identity (user) on the server. + */ public struct Trait: Codable, Sendable { - enum CodingKeys: String, CodingKey { - case key = "trait_key" - case value = "trait_value" - case identity - case identifier - } - - public let key: String - /// The underlying value for the `Trait` - /// - /// - note: In the future, this can be renamed back to 'value' as major/feature-breaking - /// updates are released. - public var typedValue: TypedValue - /// The identity of the `Trait` when creating. - internal let identifier: String? - - public init(key: String, value: TypedValue) { - self.key = key - self.typedValue = value - self.identifier = nil - } - - /// Initializes a `Trait` with an identifier. - /// - /// When a `identifier` is provided, the resulting _encoded_ form of the `Trait` - /// will contain a `identity` key. - internal init(trait: Trait, identifier: String) { - self.key = trait.key - self.typedValue = trait.typedValue - self.identifier = identifier - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - key = try container.decode(String.self, forKey: .key) - typedValue = try container.decode(TypedValue.self, forKey: .value) - identifier = nil - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(key, forKey: .key) - try container.encode(typedValue, forKey: .value) - - if let identifier = identifier { - var identity = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .identity) - try identity.encode(identifier, forKey: .identifier) + enum CodingKeys: String, CodingKey { + case key = "trait_key" + case value = "trait_value" + case identity + case identifier + } + + public let key: String + /// The underlying value for the `Trait` + /// + /// - note: In the future, this can be renamed back to 'value' as major/feature-breaking + /// updates are released. + public var typedValue: TypedValue + /// The identity of the `Trait` when creating. + internal let identifier: String? + + public init(key: String, value: TypedValue) { + self.key = key + typedValue = value + identifier = nil + } + + /// Initializes a `Trait` with an identifier. + /// + /// When a `identifier` is provided, the resulting _encoded_ form of the `Trait` + /// will contain a `identity` key. + internal init(trait: Trait, identifier: String) { + key = trait.key + typedValue = trait.typedValue + self.identifier = identifier + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: .key) + typedValue = try container.decode(TypedValue.self, forKey: .value) + identifier = nil + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(key, forKey: .key) + try container.encode(typedValue, forKey: .value) + + if let identifier = identifier { + var identity = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .identity) + try identity.encode(identifier, forKey: .identifier) + } } - } } // MARK: - Convenience Initializers + public extension Trait { - init(key: String, value: Bool) { - self.key = key - self.typedValue = .bool(value) - self.identifier = nil - } - - init(key: String, value: Float) { - self.key = key - self.typedValue = .float(value) - self.identifier = nil - } - - init(key: String, value: Int) { - self.key = key - self.typedValue = .int(value) - self.identifier = nil - } - - init(key: String, value: String) { - self.key = key - self.typedValue = .string(value) - self.identifier = nil - } + init(key: String, value: Bool) { + self.key = key + typedValue = .bool(value) + identifier = nil + } + + init(key: String, value: Float) { + self.key = key + typedValue = .float(value) + identifier = nil + } + + init(key: String, value: Int) { + self.key = key + typedValue = .int(value) + identifier = nil + } + + init(key: String, value: String) { + self.key = key + typedValue = .string(value) + identifier = nil + } } // MARK: - Deprecations + public extension Trait { - @available(*, deprecated, renamed: "typedValue") - var value: String { - get { typedValue.description } - set { typedValue = .string(newValue) } - } + @available(*, deprecated, renamed: "typedValue") + var value: String { + get { typedValue.description } + set { typedValue = .string(newValue) } + } } /** -A PostTrait represents a structure to set a new trait, with the Trait fields and the identity. -*/ + A PostTrait represents a structure to set a new trait, with the Trait fields and the identity. + */ @available(*, deprecated) public struct PostTrait: Codable { - enum CodingKeys: String, CodingKey { - case key = "trait_key" - case value = "trait_value" - case identity = "identity" - } - - public let key: String - public var value: String - public var identity: IdentityStruct - - public struct IdentityStruct: Codable { - public var identifier: String - - public enum CodingKeys: String, CodingKey { - case identifier = "identifier" + enum CodingKeys: String, CodingKey { + case key = "trait_key" + case value = "trait_value" + case identity } - - public init(identifier: String) { - self.identifier = identifier + + public let key: String + public var value: String + public var identity: IdentityStruct + + public struct IdentityStruct: Codable { + public var identifier: String + + public enum CodingKeys: String, CodingKey { + case identifier + } + + public init(identifier: String) { + self.identifier = identifier + } + } + + public init(key: String, value: String, identifier: String) { + self.key = key + self.value = value + identity = IdentityStruct(identifier: identifier) } - } - - public init(key: String, value: String, identifier:String) { - self.key = key - self.value = value - self.identity = IdentityStruct(identifier: identifier) - } } diff --git a/FlagsmithClient/Classes/Traits.swift b/FlagsmithClient/Classes/Traits.swift index a6e41cb..da8d4ea 100644 --- a/FlagsmithClient/Classes/Traits.swift +++ b/FlagsmithClient/Classes/Traits.swift @@ -1,5 +1,5 @@ // -// Router.swift +// Traits.swift // FlagsmithClient // // Created by Rob Valdes on 07/02/23. @@ -8,8 +8,8 @@ import Foundation /** -A Traits object represent a collection of different `Trait`s stored against the same Identity (user) on the server. -*/ + A Traits object represent a collection of different `Trait`s stored against the same Identity (user) on the server. + */ public struct Traits: Codable, Sendable { public let traits: [Trait] public let identifier: String? diff --git a/FlagsmithClient/Classes/TypedValue.swift b/FlagsmithClient/Classes/TypedValue.swift index 5bd5794..bb6c223 100644 --- a/FlagsmithClient/Classes/TypedValue.swift +++ b/FlagsmithClient/Classes/TypedValue.swift @@ -9,111 +9,111 @@ import Foundation /// A value associated to a `Flag` or `Trait` public enum TypedValue: Equatable, Sendable { - case bool(Bool) - case float(Float) - case int(Int) - case string(String) - case null + case bool(Bool) + case float(Float) + case int(Int) + case string(String) + case null } extension TypedValue: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if let value = try? container.decode(Bool.self) { - self = .bool(value) - return - } - - if let value = try? container.decode(Int.self) { - self = .int(value) - return - } - - if let value = try? container.decode(Float.self) { - self = .float(value) - return - } - - if let value = try? container.decode(String.self) { - self = .string(value) - return - } - - if container.decodeNil() { - self = .null - return + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(Bool.self) { + self = .bool(value) + return + } + + if let value = try? container.decode(Int.self) { + self = .int(value) + return + } + + if let value = try? container.decode(Float.self) { + self = .float(value) + return + } + + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + + if container.decodeNil() { + self = .null + return + } + + let context = DecodingError.Context( + codingPath: [], + debugDescription: "No decodable `TypedValue` value found." + ) + throw DecodingError.valueNotFound(Decodable.self, context) } - - let context = DecodingError.Context( - codingPath: [], - debugDescription: "No decodable `TypedValue` value found." - ) - throw DecodingError.valueNotFound(Decodable.self, context) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .bool(let value): - try container.encode(value) - case .float(let value): - try container.encode(value) - case .int(let value): - try container.encode(value) - case .string(let value): - try container.encode(value) - case .null: - try container.encodeNil() + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .bool(value): + try container.encode(value) + case let .float(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case .null: + try container.encodeNil() + } } - } } extension TypedValue: CustomStringConvertible { - public var description: String { - switch self { - case .bool(let value): return "\(value)" - case .float(let value): return "\(value)" - case .int(let value): return "\(value)" - case .string(let value): return value - case .null: return "" + public var description: String { + switch self { + case let .bool(value): return "\(value)" + case let .float(value): return "\(value)" + case let .int(value): return "\(value)" + case let .string(value): return value + case .null: return "" + } } - } } // Provides backwards compatible API for `UnknownTypeValue` // (eg: `Flag.value.intValue?`, `Flag.value.stringValue?`, `Flag.value.floatValue?`) public extension TypedValue { - /// Attempts to cast the associated value as an `Int` - @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") - var intValue: Int? { - switch self { - case .bool(let value): return (value) ? 1 : 0 - case .float(let value): return Int(value) - case .int(let value): return value - case .string(let value): return Int(value) - case .null: return nil + /// Attempts to cast the associated value as an `Int` + @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") + var intValue: Int? { + switch self { + case let .bool(value): return value ? 1 : 0 + case let .float(value): return Int(value) + case let .int(value): return value + case let .string(value): return Int(value) + case .null: return nil + } } - } - - /// Attempts to cast the associated value as an `Float` - @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") - var floatValue: Float? { - switch self { - case .bool(let value): return (value) ? 1.0 : 0.0 - case .float(let value): return value - case .int(let value): return Float(value) - case .string(let value): return Float(value) - case .null: return nil + + /// Attempts to cast the associated value as an `Float` + @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") + var floatValue: Float? { + switch self { + case let .bool(value): return value ? 1.0 : 0.0 + case let .float(value): return value + case let .int(value): return Float(value) + case let .string(value): return Float(value) + case .null: return nil + } } - } - - /// Attempts to cast the associated value as an `String` - @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") - var stringValue: String? { - switch self { - case .null: return nil - default: return description + + /// Attempts to cast the associated value as an `String` + @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") + var stringValue: String? { + switch self { + case .null: return nil + default: return description + } } - } } diff --git a/FlagsmithClient/Classes/UnknownTypeValue.swift b/FlagsmithClient/Classes/UnknownTypeValue.swift index 7ff2ed0..aebbb50 100644 --- a/FlagsmithClient/Classes/UnknownTypeValue.swift +++ b/FlagsmithClient/Classes/UnknownTypeValue.swift @@ -8,19 +8,18 @@ import Foundation /** -An UnknownTypeValue represents a value which can have a variable type -*/ + An UnknownTypeValue represents a value which can have a variable type + */ @available(*, deprecated, renamed: "TypedValue") public enum UnknownTypeValue: Decodable, Sendable { - case int(Int), string(String), float(Float), null - + public init(from decoder: Decoder) throws { if let int = try? decoder.singleValueContainer().decode(Int.self) { self = .int(int) return } - + if let string = try? decoder.singleValueContainer().decode(String.self) { self = .string(string) return @@ -33,34 +32,34 @@ public enum UnknownTypeValue: Decodable, Sendable { self = .null } - - public enum UnknownTypeError:Error { + + public enum UnknownTypeError: Error { case missingValue } - + public var intValue: Int? { switch self { - case .int(let value): return value - case .string(let value): return Int(value) - case .float(let value): return Int(value) + case let .int(value): return value + case let .string(value): return Int(value) + case let .float(value): return Int(value) case .null: return nil } } public var stringValue: String? { switch self { - case .int(let value): return String(value) - case .string(let value): return value - case .float(let value): return String(value) + case let .int(value): return String(value) + case let .string(value): return value + case let .float(value): return String(value) case .null: return nil } } public var floatValue: Float? { switch self { - case .int(let value): return Float(value) - case .string(let value): return Float(value) - case .float(let value): return value + case let .int(value): return Float(value) + case let .string(value): return Float(value) + case let .float(value): return value case .null: return nil } } diff --git a/FlagsmithClient/Tests/APIManagerTests.swift b/FlagsmithClient/Tests/APIManagerTests.swift index 435e74f..c87a39f 100644 --- a/FlagsmithClient/Tests/APIManagerTests.swift +++ b/FlagsmithClient/Tests/APIManagerTests.swift @@ -5,87 +5,86 @@ // Created by Richard Piazza on 3/18/22. // -import XCTest @testable import FlagsmithClient +import XCTest final class APIManagerTests: FlagsmithClientTestCase { - let apiManager = APIManager() - + /// Verify that an invalid API key produces the expected error. func testInvalidAPIKey() throws { apiManager.apiKey = nil - + let requestFinished = expectation(description: "Request Finished") var error: FlagsmithError? - + apiManager.request(.getFlags) { (result: Result) in - if case let .failure(e) = result { - error = e as? FlagsmithError + if case let .failure(err) = result { + error = err as? FlagsmithError } - + requestFinished.fulfill() } - + wait(for: [requestFinished], timeout: 1.0) - + let flagsmithError = try XCTUnwrap(error) guard case .apiKey = flagsmithError else { XCTFail("Wrong Error") return } } - + /// Verify that an invalid API url produces the expected error. func testInvalidAPIURL() throws { apiManager.apiKey = "8D5ABC87-6BBF-4AE7-BC05-4DC1AFE770DF" apiManager.baseURL = URL(fileURLWithPath: "/dev/null") - + let requestFinished = expectation(description: "Request Finished") var error: FlagsmithError? - + apiManager.request(.getFlags) { (result: Result) in - if case let .failure(e) = result { - error = e as? FlagsmithError + if case let .failure(err) = result { + error = err as? FlagsmithError } - + requestFinished.fulfill() } - + wait(for: [requestFinished], timeout: 1.0) - + let flagsmithError = try XCTUnwrap(error) guard case .apiURL = flagsmithError else { XCTFail("Wrong Error") return } } - + func testConcurrentRequests() throws { apiManager.apiKey = "8D5ABC87-6BBF-4AE7-BC05-4DC1AFE770DF" let concurrentQueue = DispatchQueue(label: "concurrentQueue", attributes: .concurrent) - - var expectations:[XCTestExpectation] = []; + + var expectations: [XCTestExpectation] = [] let iterations = 500 var error: FlagsmithError? - - for concurrentIteration in 1...iterations { + + for concurrentIteration in 1 ... iterations { let expectation = XCTestExpectation(description: "Multiple threads can access the APIManager \(concurrentIteration)") expectations.append(expectation) concurrentQueue.async { self.apiManager.request(.getFlags) { (result: Result) in - if case let .failure(e) = result { - error = e as? FlagsmithError - } + if case let .failure(err) = result { + error = err as? FlagsmithError + } expectation.fulfill() } } } - + wait(for: expectations, timeout: 10) // Ensure that we didn't have any errors during the process XCTAssertTrue(error == nil) - + print("Finished!") } } diff --git a/FlagsmithClient/Tests/ComparableJson.swift b/FlagsmithClient/Tests/ComparableJson.swift index 15d85a7..c5ee8e7 100644 --- a/FlagsmithClient/Tests/ComparableJson.swift +++ b/FlagsmithClient/Tests/ComparableJson.swift @@ -9,7 +9,7 @@ import XCTest extension String { func json(using encoding: String.Encoding) throws -> NSDictionary { - return try self.data(using: encoding).json() + return try data(using: encoding).json() } } @@ -23,7 +23,10 @@ extension Optional where Wrapped == Data { extension Data { func json() throws -> NSDictionary { let json = try JSONSerialization.jsonObject(with: self) - let dict = json as! [String : Any] - return NSDictionary(dictionary: dict) + if let dict = json as? [String: Any] { + return NSDictionary(dictionary: dict) + } else { + return NSDictionary() + } } } diff --git a/FlagsmithClient/Tests/FlagTests.swift b/FlagsmithClient/Tests/FlagTests.swift index 7763778..d60869b 100644 --- a/FlagsmithClient/Tests/FlagTests.swift +++ b/FlagsmithClient/Tests/FlagTests.swift @@ -5,11 +5,10 @@ // Created by Richard Piazza on 3/16/22. // -import XCTest @testable import FlagsmithClient +import XCTest final class FlagTests: FlagsmithClientTestCase { - func testDecodeFlags() throws { let json = """ [ @@ -31,18 +30,17 @@ final class FlagTests: FlagsmithClientTestCase { } ] """ - + let data = try XCTUnwrap(json.data(using: .utf8)) let flags = try decoder.decode([Flag].self, from: data) XCTAssertEqual(flags.count, 2) - - let enabledFlag = try XCTUnwrap(flags.first(where: { $0.enabled } )) + + let enabledFlag = try XCTUnwrap(flags.first(where: { $0.enabled })) XCTAssertEqual(enabledFlag.feature.name, "app_theme") XCTAssertEqual(enabledFlag.value, .int(4)) - - let disabledFlag = try XCTUnwrap(flags.first(where: { !$0.enabled } )) + + let disabledFlag = try XCTUnwrap(flags.first(where: { !$0.enabled })) XCTAssertEqual(disabledFlag.feature.name, "realtime_diagnostics_level") XCTAssertEqual(disabledFlag.value, .string("debug")) } - } diff --git a/FlagsmithClient/Tests/FlagsmithClientTestCase.swift b/FlagsmithClient/Tests/FlagsmithClientTestCase.swift index b247348..8f684f0 100644 --- a/FlagsmithClient/Tests/FlagsmithClientTestCase.swift +++ b/FlagsmithClient/Tests/FlagsmithClientTestCase.swift @@ -5,11 +5,10 @@ // Created by Richard Piazza on 3/16/22. // -import XCTest @testable import FlagsmithClient +import XCTest class FlagsmithClientTestCase: XCTestCase { - let encoder: JSONEncoder = .init() let decoder: JSONDecoder = .init() } diff --git a/FlagsmithClient/Tests/RouterTests.swift b/FlagsmithClient/Tests/RouterTests.swift index 1dcef82..45b2382 100644 --- a/FlagsmithClient/Tests/RouterTests.swift +++ b/FlagsmithClient/Tests/RouterTests.swift @@ -5,14 +5,13 @@ // Created by Richard Piazza on 3/21/22. // -import XCTest @testable import FlagsmithClient +import XCTest final class RouterTests: FlagsmithClientTestCase { - let baseUrl = URL(string: "https://edge.api.flagsmith.com/api/v1") let apiKey = "E71DC632-82BA-4522-82F3-D39FB6DC90AC" - + func testGetFlagsRequest() throws { let url = try XCTUnwrap(baseUrl) let route = Router.getFlags @@ -22,17 +21,18 @@ final class RouterTests: FlagsmithClientTestCase { XCTAssertTrue(request.allHTTPHeaderFields?.contains(where: { $0.key == "X-Environment-Key" }) ?? false) XCTAssertNil(request.httpBody) } - + func testGetIdentityRequest() throws { let url = try XCTUnwrap(baseUrl) let route = Router.getIdentity(identity: "6056BCBF") let request = try route.request(baseUrl: url, apiKey: apiKey) XCTAssertEqual(request.httpMethod, "GET") - XCTAssertEqual(request.url?.absoluteString, "https://edge.api.flagsmith.com/api/v1/identities/?identifier=6056BCBF") + XCTAssertEqual(request.url?.absoluteString, + "https://edge.api.flagsmith.com/api/v1/identities/?identifier=6056BCBF") XCTAssertTrue(request.allHTTPHeaderFields?.contains(where: { $0.key == "X-Environment-Key" }) ?? false) XCTAssertNil(request.httpBody) } - + func testPostTraitRequest() throws { let trait = Trait(key: "meaning_of_life", value: 42) let url = try XCTUnwrap(baseUrl) @@ -40,7 +40,7 @@ final class RouterTests: FlagsmithClientTestCase { let request = try route.request(baseUrl: url, apiKey: apiKey, using: encoder) XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.url?.absoluteString, "https://edge.api.flagsmith.com/api/v1/traits/") - + let json = try """ { "identity" : { @@ -51,7 +51,7 @@ final class RouterTests: FlagsmithClientTestCase { } """.json(using: .utf8) let body = try request.httpBody.json() - + XCTAssertEqual(body, json) } @@ -82,20 +82,20 @@ final class RouterTests: FlagsmithClientTestCase { let body = try request.httpBody.json() XCTAssertEqual(body, expectedJson) } - + func testPostAnalyticsRequest() throws { let events: [String: Int] = [ "one": 1, - "two": 2 + "two": 2, ] - + let url = try XCTUnwrap(baseUrl) let route = Router.postAnalytics(events: events) let request = try route.request(baseUrl: url, apiKey: apiKey, using: encoder) - + XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.url?.absoluteString, "https://edge.api.flagsmith.com/api/v1/analytics/flags/") - + let json = try """ { "one" : 1, diff --git a/FlagsmithClient/Tests/TraitTests.swift b/FlagsmithClient/Tests/TraitTests.swift index 6b38463..c26bff0 100644 --- a/FlagsmithClient/Tests/TraitTests.swift +++ b/FlagsmithClient/Tests/TraitTests.swift @@ -5,12 +5,11 @@ // Created by Richard Piazza on 3/16/22. // -import XCTest @testable import FlagsmithClient +import XCTest /// Tests `Trait` final class TraitTests: FlagsmithClientTestCase { - func testDecodeTraits() throws { let json = """ [ @@ -36,27 +35,27 @@ final class TraitTests: FlagsmithClientTestCase { } ] """ - + let data = try XCTUnwrap(json.data(using: .utf8)) let traits = try decoder.decode([Trait].self, from: data) XCTAssertEqual(traits.count, 5) - + let boolTrait = try XCTUnwrap(traits.first(where: { $0.key == "is_orange" })) XCTAssertEqual(boolTrait.typedValue, .bool(false)) - + let floatTrait = try XCTUnwrap(traits.first(where: { $0.key == "pi" })) XCTAssertEqual(floatTrait.typedValue, .float(3.14)) - + let intTrait = try XCTUnwrap(traits.first(where: { $0.key == "miles_per_hour" })) XCTAssertEqual(intTrait.typedValue, .int(88)) - + let stringTrait = try XCTUnwrap(traits.first(where: { $0.key == "message" })) XCTAssertEqual(stringTrait.typedValue, .string("Welcome")) - + let nullTrait = try XCTUnwrap(traits.first(where: { $0.key == "deprecated" })) XCTAssertEqual(nullTrait.typedValue, .null) } - + func testEncodeTraits() throws { let wrappedTrait = Trait(key: "dark_mode", value: .bool(true)) let trait = Trait(trait: wrappedTrait, identifier: "theme_settings") diff --git a/FlagsmithClient/Tests/TypedValueTests.swift b/FlagsmithClient/Tests/TypedValueTests.swift index 388c304..a5f60aa 100644 --- a/FlagsmithClient/Tests/TypedValueTests.swift +++ b/FlagsmithClient/Tests/TypedValueTests.swift @@ -5,68 +5,67 @@ // Created by Richard Piazza on 3/16/22. // -import XCTest @testable import FlagsmithClient +import XCTest /// Tests `TypedValue` final class TypedValueTests: FlagsmithClientTestCase { - func testDecodeBool() throws { let json = "true" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .bool(true)) } - + func testDecodeFloat() throws { let json = "3.14" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .float(3.14)) } - + func testDecodeInt() throws { let json = "47" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .int(47)) } - + func testDecodeString() throws { let json = "\"DarkMode\"" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .string("DarkMode")) } - + func testDecodeNull() throws { let json = "null" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .null) } - + func testEncodeBool() throws { let typedValue: TypedValue = .bool(false) let data = try encoder.encode(typedValue) let json = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertEqual(json, "false") } - + func testEncodeFloat() throws { let typedValue: TypedValue = .float(1.888) let data = try encoder.encode(typedValue) let json = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertTrue(json.hasPrefix("1.888")) } - + func testEncodeInt() throws { let typedValue: TypedValue = .int(88) let data = try encoder.encode(typedValue) let json = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertEqual(json, "88") } - + func testEncodeString() throws { let typedValue: TypedValue = .string("iOS 15.4") let data = try encoder.encode(typedValue) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..92cd757 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,97 @@ +{ + "object": { + "pins": [ + { + "package": "CollectionConcurrencyKit", + "repositoryURL": "https://github.com/JohnSundell/CollectionConcurrencyKit.git", + "state": { + "branch": null, + "revision": "b4f23e24b5a1bff301efc5e70871083ca029ff95", + "version": "0.2.0" + } + }, + { + "package": "CryptoSwift", + "repositoryURL": "https://github.com/krzyzanowskim/CryptoSwift.git", + "state": { + "branch": null, + "revision": "c9c3df6ab812de32bae61fc0cd1bf6d45170ebf0", + "version": "1.8.2" + } + }, + { + "package": "SourceKitten", + "repositoryURL": "https://github.com/jpsim/SourceKitten.git", + "state": { + "branch": null, + "revision": "b6dc09ee51dfb0c66e042d2328c017483a1a5d56", + "version": "0.34.1" + } + }, + { + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser.git", + "state": { + "branch": null, + "revision": "8f4d2753f0e4778c76d5f05ad16c74f707390531", + "version": "1.2.3" + } + }, + { + "package": "swift-syntax", + "repositoryURL": "https://github.com/apple/swift-syntax.git", + "state": { + "branch": null, + "revision": "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version": "509.0.2" + } + }, + { + "package": "SwiftFormat", + "repositoryURL": "https://github.com/nicklockwood/SwiftFormat", + "state": { + "branch": null, + "revision": "ab238886b8b50f8b678b251f3c28c0c887305407", + "version": "0.53.8" + } + }, + { + "package": "SwiftLint", + "repositoryURL": "https://github.com/realm/SwiftLint.git", + "state": { + "branch": null, + "revision": "f17a4f9dfb6a6afb0408426354e4180daaf49cee", + "version": "0.54.0" + } + }, + { + "package": "SwiftyTextTable", + "repositoryURL": "https://github.com/scottrhoyt/SwiftyTextTable.git", + "state": { + "branch": null, + "revision": "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", + "version": "0.9.0" + } + }, + { + "package": "SWXMLHash", + "repositoryURL": "https://github.com/drmohundro/SWXMLHash.git", + "state": { + "branch": null, + "revision": "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version": "7.0.2" + } + }, + { + "package": "Yams", + "repositoryURL": "https://github.com/jpsim/Yams.git", + "state": { + "branch": null, + "revision": "9234124cff5e22e178988c18d8b95a8ae8007f76", + "version": "5.1.2" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index 84a8f31..f2c4445 100644 --- a/Package.swift +++ b/Package.swift @@ -5,16 +5,23 @@ import PackageDescription let package = Package( name: "FlagsmithClient", products: [ - .library(name: "FlagsmithClient", targets: ["FlagsmithClient"]), + .library(name: "FlagsmithClient", targets: ["FlagsmithClient"]) + ], + dependencies: [ + .package(url: "https://github.com/realm/SwiftLint.git", from: "0.54.0"), + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.8") ], targets: [ .target( name: "FlagsmithClient", dependencies: [], - path: "FlagsmithClient/Classes"), + path: "FlagsmithClient/Classes" + // plugins: [ + // .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")] + ), .testTarget( name: "FlagsmitClientTests", dependencies: ["FlagsmithClient"], - path: "FlagsmithClient/Tests"), + path: "FlagsmithClient/Tests") ] ) diff --git a/README.md b/README.md index 9109835..d619a82 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,15 @@ For full documentation visit [https://docs.flagsmith.com/clients/ios/](https://d ## Contributing -Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code of conduct, and the process for submitting pull requests +Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code of conduct, and the process for submitting pull requests. + +We use [SwiftLint](https://github.com/realm/SwiftLint) and [SwiftFormat](https://github.com/nicklockwood/SwiftFormat) to keep our code consistent. + +To run `swiftformat` on the project run the following command: + + swift package plugin swiftformat + +To run `swiftlint` on the project please check out the documentation above to install the linter with your preferred method of integration. The linter will run on the project when the PR is raised, so it's worth checking beforehand. ## Getting Help