From a2d9398bf95873bd6c0461089167541fb7250688 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Mon, 22 May 2023 15:59:34 +0100 Subject: [PATCH 01/12] Adding default flags, and flag cache. --- Example/FlagsmithClient/AppDelegate.swift | 8 +++++ FlagsmithClient/Classes/Feature.swift | 6 ++++ FlagsmithClient/Classes/Flag.swift | 26 ++++++++++++++ FlagsmithClient/Classes/Flagsmith.swift | 41 +++++++++++++++++++++-- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/Example/FlagsmithClient/AppDelegate.swift b/Example/FlagsmithClient/AppDelegate.swift index 04297f3..a5e9ba6 100644 --- a/Example/FlagsmithClient/AppDelegate.swift +++ b/Example/FlagsmithClient/AppDelegate.swift @@ -18,6 +18,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { 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)] + + // set cache on / off (defaults to on) + Flagsmith.shared.useCache = true // set analytics on or off Flagsmith.shared.enableAnalytics = true diff --git a/FlagsmithClient/Classes/Feature.swift b/FlagsmithClient/Classes/Feature.swift index 6657a81..2f9d8df 100644 --- a/FlagsmithClient/Classes/Feature.swift +++ b/FlagsmithClient/Classes/Feature.swift @@ -21,4 +21,10 @@ public struct Feature: Decodable { 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 + } } diff --git a/FlagsmithClient/Classes/Flag.swift b/FlagsmithClient/Classes/Flag.swift index 7c61c26..aa32905 100644 --- a/FlagsmithClient/Classes/Flag.swift +++ b/FlagsmithClient/Classes/Flag.swift @@ -20,4 +20,30 @@ public struct Flag: Decodable { public let feature: Feature public let value: TypedValue public let enabled: Bool + + 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) + } + + 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) + } + + 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) + } + + 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) + } + + init(featureName:String, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { + self.init(featureName: featureName, value: TypedValue.null, enabled: enabled, featureType: featureType, featureDescription: featureDescription) + } + + 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 + } } diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index c8565dc..f7cbc84 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -42,6 +42,15 @@ public class Flagsmith { set { analytics.flushPeriod = newValue } get { analytics.flushPeriod } } + + /// Default flags to fall back on if an API call fails + public var defaultFlags: [Flag] = [] + + /// Cached flags to fall back on if an API call fails, by identity + private var cachedFlags: [String:[Flag]] = [:] + + /// Use cached values? + public var useCache: Bool = true private init() {} @@ -56,6 +65,7 @@ public class Flagsmith { getIdentity(identity) { (result) in switch result { case .success(let identity): + self.updateCache(flags: identity.flags) completion(.success(identity.flags)) case .failure(let error): completion(.failure(error)) @@ -82,6 +92,8 @@ public class Flagsmith { switch result { case .success(let flags): let hasFlag = flags.contains(where: {$0.feature.name == id && $0.enabled}) + || (self.useCache && self.getCache(forIdentity: identity).contains(where: {$0.feature.name == id && $0.enabled})) + || self.defaultFlags.contains(where: {$0.feature.name == id && $0.enabled}) completion(.success(hasFlag)) case .failure(let error): completion(.failure(error)) @@ -103,13 +115,32 @@ public class Flagsmith { getFeatureFlags(forIdentity: identity) { (result) in switch result { case .success(let flags): - let value = flags.first(where: {$0.feature.name == id})?.value + var value = flags.first(where: {$0.feature.name == id})?.value + if value == nil && self.useCache { + value = self.getCache(forIdentity: identity).first(where: {$0.feature.name == id})?.value + } + if value == nil { + value = self.defaultFlags.first(where: {$0.feature.name == id})?.value + } completion(.success(value?.stringValue)) case .failure(let error): completion(.failure(error)) } } } + + private func updateCache(flags:[Flag], forIdentity identity: String? = nil) { + for flag in flags { + var identityCachedFlags = getCache(forIdentity: identity) + identityCachedFlags.removeAll(where: {$0.feature.name == flag.feature.name}) + identityCachedFlags.append(flag) + self.cachedFlags[identity ?? "nil-identity"] = identityCachedFlags + } + } + + private func getCache(forIdentity identity: String? = nil) -> [Flag] { + return self.cachedFlags[identity ?? "nil-identity"] ?? [] + } /// Get remote config value optionally for a specific identity /// @@ -124,7 +155,13 @@ public class Flagsmith { getFeatureFlags(forIdentity: identity) { (result) in switch result { case .success(let flags): - let value = flags.first(where: {$0.feature.name == id})?.value + var value = flags.first(where: {$0.feature.name == id})?.value + if value == nil && self.useCache { + value = self.getCache(forIdentity: identity).first(where: {$0.feature.name == id})?.value + } + if value == nil { + value = self.defaultFlags.first(where: {$0.feature.name == id})?.value + } completion(.success(value)) case .failure(let error): completion(.failure(error)) From 70eb2f0500d444bf249233d242dd7c0d0298c910 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Tue, 23 May 2023 10:24:24 +0100 Subject: [PATCH 02/12] Ensuring cache is stored, and updating logic to enable cache and defaults. --- .../FlagsmithClient.xcodeproj/project.pbxproj | 2 +- Example/Pods/Pods.xcodeproj/project.pbxproj | 8 +- FlagsmithClient/Classes/Feature.swift | 9 +- FlagsmithClient/Classes/Flag.swift | 23 +-- FlagsmithClient/Classes/Flagsmith.swift | 135 +++++++++++++----- 5 files changed, 129 insertions(+), 48 deletions(-) diff --git a/Example/FlagsmithClient.xcodeproj/project.pbxproj b/Example/FlagsmithClient.xcodeproj/project.pbxproj index 7b26991..0669203 100644 --- a/Example/FlagsmithClient.xcodeproj/project.pbxproj +++ b/Example/FlagsmithClient.xcodeproj/project.pbxproj @@ -27,7 +27,7 @@ DAED1E8E268DBF9100F91DBC /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; DAED1E90268DBF9100F91DBC /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; DAED1E91268DBF9100F91DBC /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; - DAED1E92268DBF9100F91DBC /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + DAED1E92268DBF9100F91DBC /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; tabWidth = 2; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ diff --git a/Example/Pods/Pods.xcodeproj/project.pbxproj b/Example/Pods/Pods.xcodeproj/project.pbxproj index 40cddcb..5033b0a 100644 --- a/Example/Pods/Pods.xcodeproj/project.pbxproj +++ b/Example/Pods/Pods.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 83D44370DA79D1E1C2C20ABD98D13475 /* FlagsmithClient-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 2EF53626DB8E67CE608BFEEB08DF2A5C /* FlagsmithClient-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; B7C44FEAEB3099494B79EA35B9F71DE0 /* FlagsmithClient-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = C7A4CB83310920D7A2F76B1F4F715BA5 /* FlagsmithClient-dummy.m */; }; C5344C505516395035EAB8B9FC111FE2 /* Pods-FlagsmithClient_Example-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 0F66D3EFFD8615AE149DCEEE155C049F /* Pods-FlagsmithClient_Example-dummy.m */; }; + DA2D2B182A1BBFF200CD98D9 /* Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2D2B172A1BBFF200CD98D9 /* Traits.swift */; }; E119200D27E4E5CE00820B87 /* FlagsmithError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200827E4E5CE00820B87 /* FlagsmithError.swift */; }; E119200E27E4E5CE00820B87 /* FlagsmithAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200A27E4E5CE00820B87 /* FlagsmithAnalytics.swift */; }; E119200F27E4E5CE00820B87 /* APIManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200B27E4E5CE00820B87 /* APIManager.swift */; }; @@ -44,14 +45,14 @@ 23348535C2523FAC4E5FD55FF5CA18AA /* FlagsmithClient.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = FlagsmithClient.release.xcconfig; sourceTree = ""; }; 250DE57229233B0BAD273A076F108A0E /* Pods-FlagsmithClient_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-FlagsmithClient_Example.release.xcconfig"; sourceTree = ""; }; 287971AEF04B8BFE50BA6B4E9CB5A76E /* Flagsmith+Concurrency.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "Flagsmith+Concurrency.swift"; path = "FlagsmithClient/Classes/Flagsmith+Concurrency.swift"; sourceTree = ""; }; - 2DDEFBF90EE0DCCBEE1F3D989919BC4A /* Flag.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Flag.swift; path = FlagsmithClient/Classes/Flag.swift; sourceTree = ""; }; + 2DDEFBF90EE0DCCBEE1F3D989919BC4A /* Flag.swift */ = {isa = PBXFileReference; includeInIndex = 1; indentWidth = 2; lastKnownFileType = sourcecode.swift; name = Flag.swift; path = FlagsmithClient/Classes/Flag.swift; sourceTree = ""; tabWidth = 2; }; 2EF53626DB8E67CE608BFEEB08DF2A5C /* FlagsmithClient-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "FlagsmithClient-umbrella.h"; sourceTree = ""; }; 368DFAE2AAAB80524EFAFD71A2C92F84 /* Pods-FlagsmithClient_Example-acknowledgements.markdown */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = "Pods-FlagsmithClient_Example-acknowledgements.markdown"; sourceTree = ""; }; 3B040CFA25391975C1615BFB481B68C9 /* Pods-FlagsmithClient_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-FlagsmithClient_Example.debug.xcconfig"; sourceTree = ""; }; 68DDF334F75630BAA85571DF47D87C89 /* FlagsmithClient.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FlagsmithClient.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 69E74567D7DE2313D6054FA08CFC5940 /* FlagsmithClient-prefix.pch */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "FlagsmithClient-prefix.pch"; sourceTree = ""; }; 73010CC983E3809BECEE5348DA1BB8C6 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; - 757CB2DED75CA13C202204D5B88B7B9A /* Flagsmith.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Flagsmith.swift; path = FlagsmithClient/Classes/Flagsmith.swift; sourceTree = ""; }; + 757CB2DED75CA13C202204D5B88B7B9A /* Flagsmith.swift */ = {isa = PBXFileReference; includeInIndex = 1; indentWidth = 2; lastKnownFileType = sourcecode.swift; name = Flagsmith.swift; path = FlagsmithClient/Classes/Flagsmith.swift; sourceTree = ""; tabWidth = 2; }; 772F9BEA66F23FD7DB187ACBEB72BA93 /* UnknownTypeValue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = UnknownTypeValue.swift; path = FlagsmithClient/Classes/UnknownTypeValue.swift; sourceTree = ""; }; 7CB5FDC929BB21CB23FFC8143AF6322F /* FlagsmithClient.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; path = FlagsmithClient.podspec; sourceTree = ""; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; 81A815D8A0C28062CD4A8224C6883D5D /* Pods-FlagsmithClient_Example.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = "Pods-FlagsmithClient_Example.modulemap"; sourceTree = ""; }; @@ -66,6 +67,7 @@ C1CEBBBF5F9F986FB033B56D22422A4A /* Trait.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Trait.swift; path = FlagsmithClient/Classes/Trait.swift; sourceTree = ""; }; C55271DDAE55BE2BA1AFA657FA24971A /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; C7A4CB83310920D7A2F76B1F4F715BA5 /* FlagsmithClient-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "FlagsmithClient-dummy.m"; sourceTree = ""; }; + DA2D2B172A1BBFF200CD98D9 /* Traits.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Traits.swift; path = FlagsmithClient/Classes/Traits.swift; sourceTree = ""; }; E119200827E4E5CE00820B87 /* FlagsmithError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FlagsmithError.swift; path = FlagsmithClient/Classes/FlagsmithError.swift; sourceTree = ""; }; E119200A27E4E5CE00820B87 /* FlagsmithAnalytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlagsmithAnalytics.swift; sourceTree = ""; }; E119200B27E4E5CE00820B87 /* APIManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIManager.swift; sourceTree = ""; }; @@ -122,6 +124,7 @@ 1A94E973B95A4D1E5D88FED450500C7B /* FlagsmithClient */ = { isa = PBXGroup; children = ( + DA2D2B172A1BBFF200CD98D9 /* Traits.swift */, A8A7BD53895AF16384B78CB7116DEBD7 /* Feature.swift */, 2DDEFBF90EE0DCCBEE1F3D989919BC4A /* Flag.swift */, 757CB2DED75CA13C202204D5B88B7B9A /* Flagsmith.swift */, @@ -338,6 +341,7 @@ 4A510C0AE712CD782D25F2276904F827 /* Flag.swift in Sources */, E190202E27E24E7600C5EE2C /* TypedValue.swift in Sources */, 1845D603B597C651F0EE6080EF459DBB /* Flagsmith.swift in Sources */, + DA2D2B182A1BBFF200CD98D9 /* Traits.swift in Sources */, 2C55F74AE43F34E4F3952F5B86AADBF5 /* Flagsmith+Concurrency.swift in Sources */, E119200F27E4E5CE00820B87 /* APIManager.swift in Sources */, E119201027E4E5CE00820B87 /* Router.swift in Sources */, diff --git a/FlagsmithClient/Classes/Feature.swift b/FlagsmithClient/Classes/Feature.swift index 2f9d8df..1c0ce75 100644 --- a/FlagsmithClient/Classes/Feature.swift +++ b/FlagsmithClient/Classes/Feature.swift @@ -10,7 +10,7 @@ import Foundation /** A Feature represents a flag or remote configuration value on the server. */ -public struct Feature: Decodable { +public struct Feature: Codable { enum CodingKeys: String, CodingKey { case name case type @@ -27,4 +27,11 @@ public struct Feature: Decodable { 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) + } } diff --git a/FlagsmithClient/Classes/Flag.swift b/FlagsmithClient/Classes/Flag.swift index aa32905..1929beb 100644 --- a/FlagsmithClient/Classes/Flag.swift +++ b/FlagsmithClient/Classes/Flag.swift @@ -10,7 +10,7 @@ import Foundation /** A Flag represents a feature flag on the server. */ -public struct Flag: Decodable { +public struct Flag: Codable { enum CodingKeys: String, CodingKey { case feature case value = "feature_state_value" @@ -20,30 +20,37 @@ public struct Flag: Decodable { public let feature: Feature public let value: TypedValue public let enabled: Bool - - init(featureName:String, boolValue: Bool, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { + + 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) } - init(featureName:String, floatValue: Float, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { + 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) } - init(featureName:String, intValue: Int, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { + 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) } - init(featureName:String, stringValue: String, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { + 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) } - init(featureName:String, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { + 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) } - init(featureName:String, value: TypedValue, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { + 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) + } } diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index f7cbc84..d50a00e 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -46,13 +46,22 @@ public class Flagsmith { /// Default flags to fall back on if an API call fails public var defaultFlags: [Flag] = [] + private let CACHED_FLAGS_KEY = "cachedFlags" + private let NIL_IDENTITY_KEY = "nil-identity" + /// Cached flags to fall back on if an API call fails, by identity private var cachedFlags: [String:[Flag]] = [:] - /// Use cached values? + /// Use cached flags as a fallback? public var useCache: Bool = true - private init() {} + private init() { + if let data = UserDefaults.standard.value(forKey: CACHED_FLAGS_KEY) as? Data { + if let cachedFlagsObject = try? JSONDecoder().decode([String:[Flag]].self, from: data) { + self.cachedFlags = cachedFlagsObject + } + } + } /// Get all feature flags (flags and remote config) optionally for a specific identity /// @@ -64,16 +73,34 @@ public class Flagsmith { if let identity = identity { getIdentity(identity) { (result) in switch result { - case .success(let identity): - self.updateCache(flags: identity.flags) - completion(.success(identity.flags)) + case .success(let thisIdentity): + self.updateCache(flags: thisIdentity.flags, forIdentity: identity) + completion(.success(self.getFlagsUsingCacheAndDefaults(flags: thisIdentity.flags, forIdentity: identity))) case .failure(let error): - completion(.failure(error)) + let fallbackFlags = self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity) + if fallbackFlags.isEmpty { + completion(.failure(error)) + } + else { + completion(.success(fallbackFlags)) + } } } } else { apiManager.request(.getFlags) { (result: Result<[Flag], Error>) in - completion(result) + switch result { + case .success(let flags): + self.updateCache(flags: flags, forIdentity: identity) + completion(.success(self.getFlagsUsingCacheAndDefaults(flags: flags, forIdentity: identity))) + case .failure(let error): + let fallbackFlags = self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity) + if fallbackFlags.isEmpty { + completion(.failure(error)) + } + else { + completion(.success(fallbackFlags)) + } + } } } } @@ -115,32 +142,15 @@ public class Flagsmith { getFeatureFlags(forIdentity: identity) { (result) in switch result { case .success(let flags): - var value = flags.first(where: {$0.feature.name == id})?.value - if value == nil && self.useCache { - value = self.getCache(forIdentity: identity).first(where: {$0.feature.name == id})?.value - } - if value == nil { - value = self.defaultFlags.first(where: {$0.feature.name == id})?.value - } - completion(.success(value?.stringValue)) + var flag = flags.first(where: {$0.feature.name == id}) + flag = self.getFlagUsingCacheAndDefaults(withID: id, flag: flag, forIdentity: identity) + + completion(.success(flag?.value.stringValue)) case .failure(let error): completion(.failure(error)) } } } - - private func updateCache(flags:[Flag], forIdentity identity: String? = nil) { - for flag in flags { - var identityCachedFlags = getCache(forIdentity: identity) - identityCachedFlags.removeAll(where: {$0.feature.name == flag.feature.name}) - identityCachedFlags.append(flag) - self.cachedFlags[identity ?? "nil-identity"] = identityCachedFlags - } - } - - private func getCache(forIdentity identity: String? = nil) -> [Flag] { - return self.cachedFlags[identity ?? "nil-identity"] ?? [] - } /// Get remote config value optionally for a specific identity /// @@ -155,14 +165,9 @@ public class Flagsmith { getFeatureFlags(forIdentity: identity) { (result) in switch result { case .success(let flags): - var value = flags.first(where: {$0.feature.name == id})?.value - if value == nil && self.useCache { - value = self.getCache(forIdentity: identity).first(where: {$0.feature.name == id})?.value - } - if value == nil { - value = self.defaultFlags.first(where: {$0.feature.name == id})?.value - } - completion(.success(value)) + var flag = flags.first(where: {$0.feature.name == id}) + flag = self.getFlagUsingCacheAndDefaults(withID: id, flag: flag, forIdentity: identity) + completion(.success(flag?.value)) case .failure(let error): completion(.failure(error)) } @@ -252,4 +257,62 @@ public class Flagsmith { completion(result) } } + + /// Return a flag for a flag ID and identity, using either the cache (if enabled) or the default flag when the passed flag is nil + private func getFlagUsingCacheAndDefaults(withID id: String, flag:Flag?, forIdentity identity: String? = nil) -> Flag? { + var returnFlag = flag + if returnFlag == nil && self.useCache { + returnFlag = self.getCache(forIdentity: identity).first(where: {$0.feature.name == id}) + } + if returnFlag == nil { + returnFlag = self.defaultFlags.first(where: {$0.feature.name == id}) + } + + return returnFlag + } + + /// Return an array of flag for an identity, including the cached flags (if enabled) and the default flags when they are not already present in the passed array + private func getFlagsUsingCacheAndDefaults(flags:[Flag], forIdentity identity: String? = nil) -> [Flag] { + var returnFlags:[Flag] = [] + returnFlags.append(contentsOf: flags) + + if useCache { + for flag in getCache(forIdentity: identity) { + if !returnFlags.contains(where: { $0.feature.name == flag.feature.name }) { + if flag.value != .null { + returnFlags.append(flag) + } + } + } + } + + for flag in defaultFlags { + if !returnFlags.contains(where: { $0.feature.name == flag.feature.name }) { + if flag.value != .null { + returnFlags.append(flag) + } + } + } + + return returnFlags + } + + /// Update the cache for an identity for a set of flags, and store + private func updateCache(flags:[Flag], forIdentity identity: String? = nil) { + for flag in flags { + var identityCachedFlags = getCache(forIdentity: identity) + identityCachedFlags.removeAll(where: {$0.feature.name == flag.feature.name}) + identityCachedFlags.append(flag) + self.cachedFlags[identity ?? NIL_IDENTITY_KEY] = identityCachedFlags + } + + if let data = try? JSONEncoder().encode(cachedFlags) { + UserDefaults.standard.set(data, forKey: CACHED_FLAGS_KEY) + } + } + + /// Get the cached flags for an identity + private func getCache(forIdentity identity: String? = nil) -> [Flag] { + return self.cachedFlags[identity ?? NIL_IDENTITY_KEY] ?? [] + } } From ef1da17b84c173e2c020cf39361624420ea07202 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Tue, 23 May 2023 11:28:40 +0100 Subject: [PATCH 03/12] Version bump for release --- FlagsmithClient.podspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlagsmithClient.podspec b/FlagsmithClient.podspec index 70b5599..a517467 100644 --- a/FlagsmithClient.podspec +++ b/FlagsmithClient.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'FlagsmithClient' - s.version = '3.2.1' + s.version = '3.3.0' s.summary = 'iOS Client written in Swift for Flagsmith. Ship features with confidence using feature flags and remote config.' s.homepage = 'https://github.com/Flagsmith/flagsmith-ios-client' s.license = { :type => 'MIT', :file => 'LICENSE' } From 7ddeed785c219c5de0dfc5a666ee9ce8ffc37106 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Thu, 25 May 2023 11:02:00 +0100 Subject: [PATCH 04/12] Fixing UserDefaults typo calling wrong method. --- FlagsmithClient/Classes/Flagsmith.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index d50a00e..24cdf14 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -56,7 +56,7 @@ public class Flagsmith { public var useCache: Bool = true private init() { - if let data = UserDefaults.standard.value(forKey: CACHED_FLAGS_KEY) as? Data { + if let data = UserDefaults.standard.object(forKey: CACHED_FLAGS_KEY) as? Data { if let cachedFlagsObject = try? JSONDecoder().decode([String:[Flag]].self, from: data) { self.cachedFlags = cachedFlagsObject } From 59ed9054c94e0c65c4e7d7129d90de24a9eb26bd Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Thu, 25 May 2023 16:06:05 +0100 Subject: [PATCH 05/12] Improving getFlagUsingCacheAndDefaults --- FlagsmithClient/Classes/Flagsmith.swift | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 24cdf14..98a0ad0 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -143,7 +143,9 @@ public class Flagsmith { switch result { case .success(let flags): var flag = flags.first(where: {$0.feature.name == id}) - flag = self.getFlagUsingCacheAndDefaults(withID: id, flag: flag, forIdentity: identity) + if flag == nil { + flag = self.getFlagUsingCacheAndDefaults(withID: id, forIdentity: identity) + } completion(.success(flag?.value.stringValue)) case .failure(let error): @@ -166,7 +168,9 @@ public class Flagsmith { switch result { case .success(let flags): var flag = flags.first(where: {$0.feature.name == id}) - flag = self.getFlagUsingCacheAndDefaults(withID: id, flag: flag, forIdentity: identity) + if flag == nil { + flag = self.getFlagUsingCacheAndDefaults(withID: id, forIdentity: identity) + } completion(.success(flag?.value)) case .failure(let error): completion(.failure(error)) @@ -258,17 +262,17 @@ public class Flagsmith { } } - /// Return a flag for a flag ID and identity, using either the cache (if enabled) or the default flag when the passed flag is nil - private func getFlagUsingCacheAndDefaults(withID id: String, flag:Flag?, forIdentity identity: String? = nil) -> Flag? { - var returnFlag = flag - if returnFlag == nil && self.useCache { - returnFlag = self.getCache(forIdentity: identity).first(where: {$0.feature.name == id}) + /// Return a flag for a flag ID and identity, using either the cache (if enabled) or the default flags + private func getFlagUsingCacheAndDefaults(withID id: String, forIdentity identity: String? = nil) -> Flag? { + var flag:Flag? + if useCache { + flag = self.getCache(forIdentity: identity).first(where: {$0.feature.name == id}) } - if returnFlag == nil { - returnFlag = self.defaultFlags.first(where: {$0.feature.name == id}) + if flag == nil { + flag = self.defaultFlags.first(where: {$0.feature.name == id}) } - return returnFlag + return flag } /// Return an array of flag for an identity, including the cached flags (if enabled) and the default flags when they are not already present in the passed array From f79f36d86ba4c273efac5b80ba35e72469f6a0b8 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Thu, 8 Jun 2023 16:04:35 +0100 Subject: [PATCH 06/12] Adding skipAPI, TTL and ensuring cache / defaults are only used in the case of failures, not successful calls. --- FlagsmithClient/Classes/Flagsmith.swift | 57 ++++++++++++++++++------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 98a0ad0..a8cf2f4 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -47,20 +47,29 @@ public class Flagsmith { public var defaultFlags: [Flag] = [] private let CACHED_FLAGS_KEY = "cachedFlags" + private let CACHE_LAST_POPULATED_KEY = "cacheLastPopulated" private let NIL_IDENTITY_KEY = "nil-identity" /// Cached flags to fall back on if an API call fails, by identity private var cachedFlags: [String:[Flag]] = [:] + private var cacheLastPopulated:Double = 0.0 /// Use cached flags as a fallback? public var useCache: Bool = true + /// 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 + private init() { if let data = UserDefaults.standard.object(forKey: CACHED_FLAGS_KEY) as? Data { if let cachedFlagsObject = try? JSONDecoder().decode([String:[Flag]].self, from: data) { self.cachedFlags = cachedFlagsObject } } + self.cacheLastPopulated = UserDefaults.standard.double(forKey: CACHE_LAST_POPULATED_KEY) } /// Get all feature flags (flags and remote config) optionally for a specific identity @@ -70,12 +79,18 @@ public class Flagsmith { /// - 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) { + + // Skip the API call if the skipAPI boolean is true, and we have an in-date cache + if useCache && skipAPI && !cachedFlags.isEmpty && (cacheTTL == 0 || (Date.timeIntervalSinceReferenceDate - cacheLastPopulated) < cacheTTL) { + completion(.success(self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity))) + } + if let identity = identity { getIdentity(identity) { (result) in switch result { case .success(let thisIdentity): self.updateCache(flags: thisIdentity.flags, forIdentity: identity) - completion(.success(self.getFlagsUsingCacheAndDefaults(flags: thisIdentity.flags, forIdentity: identity))) + completion(.success(thisIdentity.flags)) case .failure(let error): let fallbackFlags = self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity) if fallbackFlags.isEmpty { @@ -90,8 +105,8 @@ public class Flagsmith { apiManager.request(.getFlags) { (result: Result<[Flag], Error>) in switch result { case .success(let flags): - self.updateCache(flags: flags, forIdentity: identity) - completion(.success(self.getFlagsUsingCacheAndDefaults(flags: flags, forIdentity: identity))) + self.updateCache(flags: flags) + completion(.success(flags)) case .failure(let error): let fallbackFlags = self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity) if fallbackFlags.isEmpty { @@ -119,11 +134,15 @@ public class Flagsmith { switch result { case .success(let flags): let hasFlag = flags.contains(where: {$0.feature.name == id && $0.enabled}) - || (self.useCache && self.getCache(forIdentity: identity).contains(where: {$0.feature.name == id && $0.enabled})) - || self.defaultFlags.contains(where: {$0.feature.name == id && $0.enabled}) completion(.success(hasFlag)) case .failure(let error): - completion(.failure(error)) + if (self.useCache && self.getCache(forIdentity: identity).contains(where: {$0.feature.name == id && $0.enabled})) + || self.defaultFlags.contains(where: {$0.feature.name == id && $0.enabled}) { + completion(.success(true)) + } + else { + completion(.failure(error)) + } } } } @@ -143,13 +162,14 @@ public class Flagsmith { switch result { case .success(let flags): var flag = flags.first(where: {$0.feature.name == id}) - if flag == nil { - flag = self.getFlagUsingCacheAndDefaults(withID: id, forIdentity: identity) - } - completion(.success(flag?.value.stringValue)) case .failure(let error): - completion(.failure(error)) + if let flag = self.getFlagUsingCacheAndDefaults(withID: id, forIdentity: identity) { + completion(.success(flag.value.stringValue)) + } + else { + completion(.failure(error)) + } } } } @@ -168,12 +188,14 @@ public class Flagsmith { switch result { case .success(let flags): var flag = flags.first(where: {$0.feature.name == id}) - if flag == nil { - flag = self.getFlagUsingCacheAndDefaults(withID: id, forIdentity: identity) - } completion(.success(flag?.value)) case .failure(let error): - completion(.failure(error)) + if let flag = self.getFlagUsingCacheAndDefaults(withID: id, forIdentity: identity) { + completion(.success(flag.value)) + } + else { + completion(.failure(error)) + } } } } @@ -311,8 +333,11 @@ public class Flagsmith { } if let data = try? JSONEncoder().encode(cachedFlags) { - UserDefaults.standard.set(data, forKey: CACHED_FLAGS_KEY) + UserDefaults.standard.set(data, forKey: CACHED_FLAGS_KEY) } + + cacheLastPopulated = Date.timeIntervalSinceReferenceDate + UserDefaults.standard.set(cacheLastPopulated, forKey: CACHE_LAST_POPULATED_KEY) } /// Get the cached flags for an identity From 18bd835f07ea296874d08baeb6c42b1d3b4bb778 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Thu, 8 Jun 2023 16:11:48 +0100 Subject: [PATCH 07/12] Finalising TTL logic and adding example code. --- Example/FlagsmithClient/AppDelegate.swift | 8 +++++++- FlagsmithClient/Classes/Flagsmith.swift | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Example/FlagsmithClient/AppDelegate.swift b/Example/FlagsmithClient/AppDelegate.swift index a5e9ba6..aac4504 100644 --- a/Example/FlagsmithClient/AppDelegate.swift +++ b/Example/FlagsmithClient/AppDelegate.swift @@ -26,7 +26,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // set cache on / off (defaults to on) Flagsmith.shared.useCache = true - + + // set skip API on / off (defaults to off) + Flagsmith.shared.skipAPI = false + + // set cache TTL in seconds (defaults to 0, i.e. infinite) + Flagsmith.shared.cacheTTL = 0 + // set analytics on or off Flagsmith.shared.enableAnalytics = true diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index a8cf2f4..ae4e20b 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -81,7 +81,7 @@ public class Flagsmith { completion: @escaping (Result<[Flag], Error>) -> Void) { // Skip the API call if the skipAPI boolean is true, and we have an in-date cache - if useCache && skipAPI && !cachedFlags.isEmpty && (cacheTTL == 0 || (Date.timeIntervalSinceReferenceDate - cacheLastPopulated) < cacheTTL) { + if useCache && skipAPI && !getCache(forIdentity: identity).isEmpty { completion(.success(self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity))) } @@ -342,6 +342,9 @@ public class Flagsmith { /// Get the cached flags for an identity private func getCache(forIdentity identity: String? = nil) -> [Flag] { - return self.cachedFlags[identity ?? NIL_IDENTITY_KEY] ?? [] + if cacheTTL == 0 || (Date.timeIntervalSinceReferenceDate - cacheLastPopulated) < cacheTTL { + return self.cachedFlags[identity ?? NIL_IDENTITY_KEY] ?? [] + } + return [] } } From fdbd067ea4e899398d946c8c863ae9f89499f3b7 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Thu, 8 Jun 2023 18:28:28 +0100 Subject: [PATCH 08/12] Minor logic correction for skipAPI. --- FlagsmithClient/Classes/Flagsmith.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index ae4e20b..71482e7 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -83,6 +83,7 @@ public class Flagsmith { // Skip the API call if the skipAPI boolean is true, and we have an in-date cache if useCache && skipAPI && !getCache(forIdentity: identity).isEmpty { completion(.success(self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity))) + return } if let identity = identity { @@ -161,7 +162,7 @@ public class Flagsmith { getFeatureFlags(forIdentity: identity) { (result) in switch result { case .success(let flags): - var flag = flags.first(where: {$0.feature.name == id}) + let flag = flags.first(where: {$0.feature.name == id}) completion(.success(flag?.value.stringValue)) case .failure(let error): if let flag = self.getFlagUsingCacheAndDefaults(withID: id, forIdentity: identity) { @@ -297,7 +298,7 @@ public class Flagsmith { return flag } - /// Return an array of flag for an identity, including the cached flags (if enabled) and the default flags when they are not already present in the passed array + /// Return an array of flags for an identity, including the cached flags (if enabled) and the default flags when they are not already present in the passed array private func getFlagsUsingCacheAndDefaults(flags:[Flag], forIdentity identity: String? = nil) -> [Flag] { var returnFlags:[Flag] = [] returnFlags.append(contentsOf: flags) From 021a25d5c99cbeecfbc60f6fff882594515a1d75 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Thu, 22 Jun 2023 15:12:47 +0100 Subject: [PATCH 09/12] Moving cache to the networking layer, with ability to customise. --- Example/FlagsmithClient/AppDelegate.swift | 11 ++- Example/Pods/Pods.xcodeproj/project.pbxproj | 6 +- FlagsmithClient/Classes/Flagsmith.swift | 87 +++---------------- .../Classes/Internal/APIManager.swift | 81 ++++++++++++----- .../Classes/Internal/CachedURLResponse.swift | 29 +++++++ 5 files changed, 113 insertions(+), 101 deletions(-) create mode 100644 FlagsmithClient/Classes/Internal/CachedURLResponse.swift diff --git a/Example/FlagsmithClient/AppDelegate.swift b/Example/FlagsmithClient/AppDelegate.swift index aac4504..2f0d5eb 100644 --- a/Example/FlagsmithClient/AppDelegate.swift +++ b/Example/FlagsmithClient/AppDelegate.swift @@ -24,14 +24,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Flag(featureName: "font_size", intValue:12, enabled: true), Flag(featureName: "my_name", stringValue:"Testing", enabled: true)] - // set cache on / off (defaults to on) + // set cache on / off (defaults to off) Flagsmith.shared.useCache = true + + // set custom cache to use (defaults to shared URLCache) + //Flagsmith.shared.cache = // set skip API on / off (defaults to off) Flagsmith.shared.skipAPI = false // set cache TTL in seconds (defaults to 0, i.e. infinite) - Flagsmith.shared.cacheTTL = 0 + Flagsmith.shared.cacheTTL = 90 // set analytics on or off Flagsmith.shared.enableAnalytics = true @@ -40,10 +43,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Flagsmith.shared.analyticsFlushPeriod = 10 Flagsmith.shared.getFeatureFlags() { (result) in - print(result) + print(result) } Flagsmith.shared.hasFeatureFlag(withID: "freeze_delinquent_accounts") { (result) in - print(result) + print(result) } //Flagsmith.shared.setTrait(Trait(key: "", value: ""), forIdentity: "") { (result) in print(result) } //Flagsmith.shared.getIdentity("") { (result) in print(result) } diff --git a/Example/Pods/Pods.xcodeproj/project.pbxproj b/Example/Pods/Pods.xcodeproj/project.pbxproj index 5033b0a..1e52f60 100644 --- a/Example/Pods/Pods.xcodeproj/project.pbxproj +++ b/Example/Pods/Pods.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ B7C44FEAEB3099494B79EA35B9F71DE0 /* FlagsmithClient-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = C7A4CB83310920D7A2F76B1F4F715BA5 /* FlagsmithClient-dummy.m */; }; C5344C505516395035EAB8B9FC111FE2 /* Pods-FlagsmithClient_Example-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 0F66D3EFFD8615AE149DCEEE155C049F /* Pods-FlagsmithClient_Example-dummy.m */; }; DA2D2B182A1BBFF200CD98D9 /* Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2D2B172A1BBFF200CD98D9 /* Traits.swift */; }; + DAB856122A4455B2000520DC /* CachedURLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB856112A4455B2000520DC /* CachedURLResponse.swift */; }; E119200D27E4E5CE00820B87 /* FlagsmithError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200827E4E5CE00820B87 /* FlagsmithError.swift */; }; E119200E27E4E5CE00820B87 /* FlagsmithAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200A27E4E5CE00820B87 /* FlagsmithAnalytics.swift */; }; E119200F27E4E5CE00820B87 /* APIManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E119200B27E4E5CE00820B87 /* APIManager.swift */; }; @@ -68,9 +69,10 @@ C55271DDAE55BE2BA1AFA657FA24971A /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; C7A4CB83310920D7A2F76B1F4F715BA5 /* FlagsmithClient-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "FlagsmithClient-dummy.m"; sourceTree = ""; }; DA2D2B172A1BBFF200CD98D9 /* Traits.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Traits.swift; path = FlagsmithClient/Classes/Traits.swift; sourceTree = ""; }; + DAB856112A4455B2000520DC /* CachedURLResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CachedURLResponse.swift; sourceTree = ""; }; E119200827E4E5CE00820B87 /* FlagsmithError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FlagsmithError.swift; path = FlagsmithClient/Classes/FlagsmithError.swift; sourceTree = ""; }; E119200A27E4E5CE00820B87 /* FlagsmithAnalytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlagsmithAnalytics.swift; sourceTree = ""; }; - E119200B27E4E5CE00820B87 /* APIManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIManager.swift; sourceTree = ""; }; + E119200B27E4E5CE00820B87 /* APIManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = APIManager.swift; sourceTree = ""; tabWidth = 2; }; E119200C27E4E5CE00820B87 /* Router.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; E190202D27E24E7600C5EE2C /* TypedValue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TypedValue.swift; path = FlagsmithClient/Classes/TypedValue.swift; sourceTree = ""; }; E28010F1C58E656FC37588C8A00FEE38 /* Pods-FlagsmithClient_Example-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-FlagsmithClient_Example-Info.plist"; sourceTree = ""; }; @@ -215,6 +217,7 @@ E119200927E4E5CE00820B87 /* Internal */ = { isa = PBXGroup; children = ( + DAB856112A4455B2000520DC /* CachedURLResponse.swift */, E119200A27E4E5CE00820B87 /* FlagsmithAnalytics.swift */, E119200B27E4E5CE00820B87 /* APIManager.swift */, E119200C27E4E5CE00820B87 /* Router.swift */, @@ -341,6 +344,7 @@ 4A510C0AE712CD782D25F2276904F827 /* Flag.swift in Sources */, E190202E27E24E7600C5EE2C /* TypedValue.swift in Sources */, 1845D603B597C651F0EE6080EF459DBB /* Flagsmith.swift in Sources */, + DAB856122A4455B2000520DC /* CachedURLResponse.swift in Sources */, DA2D2B182A1BBFF200CD98D9 /* Traits.swift in Sources */, 2C55F74AE43F34E4F3952F5B86AADBF5 /* Flagsmith+Concurrency.swift in Sources */, E119200F27E4E5CE00820B87 /* APIManager.swift in Sources */, diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 71482e7..bad06c9 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -46,16 +46,11 @@ public class Flagsmith { /// Default flags to fall back on if an API call fails public var defaultFlags: [Flag] = [] - private let CACHED_FLAGS_KEY = "cachedFlags" - private let CACHE_LAST_POPULATED_KEY = "cacheLastPopulated" - private let NIL_IDENTITY_KEY = "nil-identity" - - /// Cached flags to fall back on if an API call fails, by identity - private var cachedFlags: [String:[Flag]] = [:] - private var cacheLastPopulated:Double = 0.0 - + /// 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 = true + public var useCache: Bool = false /// TTL for the cache in seconds, default of 0 means infinite public var cacheTTL: Double = 0 @@ -64,12 +59,6 @@ public class Flagsmith { public var skipAPI: Bool = false private init() { - if let data = UserDefaults.standard.object(forKey: CACHED_FLAGS_KEY) as? Data { - if let cachedFlagsObject = try? JSONDecoder().decode([String:[Flag]].self, from: data) { - self.cachedFlags = cachedFlagsObject - } - } - self.cacheLastPopulated = UserDefaults.standard.double(forKey: CACHE_LAST_POPULATED_KEY) } /// Get all feature flags (flags and remote config) optionally for a specific identity @@ -80,20 +69,13 @@ public class Flagsmith { public func getFeatureFlags(forIdentity identity: String? = nil, completion: @escaping (Result<[Flag], Error>) -> Void) { - // Skip the API call if the skipAPI boolean is true, and we have an in-date cache - if useCache && skipAPI && !getCache(forIdentity: identity).isEmpty { - completion(.success(self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity))) - return - } - if let identity = identity { getIdentity(identity) { (result) in switch result { case .success(let thisIdentity): - self.updateCache(flags: thisIdentity.flags, forIdentity: identity) completion(.success(thisIdentity.flags)) case .failure(let error): - let fallbackFlags = self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity) + let fallbackFlags = self.getFlagsUsingDefaults(flags: [], forIdentity: identity) if fallbackFlags.isEmpty { completion(.failure(error)) } @@ -106,10 +88,9 @@ public class Flagsmith { apiManager.request(.getFlags) { (result: Result<[Flag], Error>) in switch result { case .success(let flags): - self.updateCache(flags: flags) completion(.success(flags)) case .failure(let error): - let fallbackFlags = self.getFlagsUsingCacheAndDefaults(flags: [], forIdentity: identity) + let fallbackFlags = self.getFlagsUsingDefaults(flags: [], forIdentity: identity) if fallbackFlags.isEmpty { completion(.failure(error)) } @@ -137,8 +118,7 @@ public class Flagsmith { let hasFlag = flags.contains(where: {$0.feature.name == id && $0.enabled}) completion(.success(hasFlag)) case .failure(let error): - if (self.useCache && self.getCache(forIdentity: identity).contains(where: {$0.feature.name == id && $0.enabled})) - || self.defaultFlags.contains(where: {$0.feature.name == id && $0.enabled}) { + if self.defaultFlags.contains(where: {$0.feature.name == id && $0.enabled}) { completion(.success(true)) } else { @@ -165,7 +145,7 @@ public class Flagsmith { let flag = flags.first(where: {$0.feature.name == id}) completion(.success(flag?.value.stringValue)) case .failure(let error): - if let flag = self.getFlagUsingCacheAndDefaults(withID: id, forIdentity: identity) { + if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { completion(.success(flag.value.stringValue)) } else { @@ -191,7 +171,7 @@ public class Flagsmith { var flag = flags.first(where: {$0.feature.name == id}) completion(.success(flag?.value)) case .failure(let error): - if let flag = self.getFlagUsingCacheAndDefaults(withID: id, forIdentity: identity) { + if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { completion(.success(flag.value)) } else { @@ -286,33 +266,15 @@ public class Flagsmith { } /// Return a flag for a flag ID and identity, using either the cache (if enabled) or the default flags - private func getFlagUsingCacheAndDefaults(withID id: String, forIdentity identity: String? = nil) -> Flag? { - var flag:Flag? - if useCache { - flag = self.getCache(forIdentity: identity).first(where: {$0.feature.name == id}) - } - if flag == nil { - flag = self.defaultFlags.first(where: {$0.feature.name == id}) - } - - return flag + private func getFlagUsingDefaults(withID id: String, forIdentity identity: String? = nil) -> Flag? { + return self.defaultFlags.first(where: {$0.feature.name == id}) } /// Return an array of flags for an identity, including the cached flags (if enabled) and the default flags when they are not already present in the passed array - private func getFlagsUsingCacheAndDefaults(flags:[Flag], forIdentity identity: String? = nil) -> [Flag] { + private func getFlagsUsingDefaults(flags:[Flag], forIdentity identity: String? = nil) -> [Flag] { var returnFlags:[Flag] = [] returnFlags.append(contentsOf: flags) - if useCache { - for flag in getCache(forIdentity: identity) { - if !returnFlags.contains(where: { $0.feature.name == flag.feature.name }) { - if flag.value != .null { - returnFlags.append(flag) - } - } - } - } - for flag in defaultFlags { if !returnFlags.contains(where: { $0.feature.name == flag.feature.name }) { if flag.value != .null { @@ -323,29 +285,4 @@ public class Flagsmith { return returnFlags } - - /// Update the cache for an identity for a set of flags, and store - private func updateCache(flags:[Flag], forIdentity identity: String? = nil) { - for flag in flags { - var identityCachedFlags = getCache(forIdentity: identity) - identityCachedFlags.removeAll(where: {$0.feature.name == flag.feature.name}) - identityCachedFlags.append(flag) - self.cachedFlags[identity ?? NIL_IDENTITY_KEY] = identityCachedFlags - } - - if let data = try? JSONEncoder().encode(cachedFlags) { - UserDefaults.standard.set(data, forKey: CACHED_FLAGS_KEY) - } - - cacheLastPopulated = Date.timeIntervalSinceReferenceDate - UserDefaults.standard.set(cacheLastPopulated, forKey: CACHE_LAST_POPULATED_KEY) - } - - /// Get the cached flags for an identity - private func getCache(forIdentity identity: String? = nil) -> [Flag] { - if cacheTTL == 0 || (Date.timeIntervalSinceReferenceDate - cacheLastPopulated) < cacheTTL { - return self.cachedFlags[identity ?? NIL_IDENTITY_KEY] ?? [] - } - return [] - } } diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift index a47bafd..8f634a8 100644 --- a/FlagsmithClient/Classes/Internal/APIManager.swift +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -11,20 +11,62 @@ import FoundationNetworking #endif /// Handles interaction with a **Flagsmith** api. -class APIManager { +class APIManager : NSObject, URLSessionDataDelegate { - private let session: URLSession + 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:[URLSessionDataTask:(Result) -> Void] = [:] + private var tasksToData:[URLSessionDataTask:NSMutableData] = [:] - init() { + override init() { + super.init() let configuration = URLSessionConfiguration.default - self.session = URLSession(configuration: configuration) + self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let dataTask = task as? URLSessionDataTask { + if let completion = tasksToCompletionHandlers[dataTask] { + if let error = error { + completion(.failure(FlagsmithError.unhandled(error))) + } + else { + let data = tasksToData[dataTask] ?? NSMutableData() + completion(.success(data as Data)) + } + } + tasksToCompletionHandlers[dataTask] = nil + tasksToData[dataTask] = nil + } + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) { + + // intercept and modify the cache settings for the response + if Flagsmith.shared.useCache { + let newResponse = proposedResponse.response(withExpirationDuration: Int(Flagsmith.shared.cacheTTL)) + completionHandler(newResponse) + } else { + completionHandler(proposedResponse) + } } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + let existingData = tasksToData[dataTask] ?? NSMutableData() + existingData.append(data) + tasksToData[dataTask] = existingData + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + completionHandler(.allow) + } + /// Base request method that handles creating a `URLRequest` and processing /// the `URLSession` response. /// @@ -37,7 +79,7 @@ class APIManager { return } - let request: URLRequest + var request: URLRequest do { request = try router.request(baseUrl: baseURL, apiKey: apiKey) } catch { @@ -45,23 +87,20 @@ class APIManager { return } - session.dataTask(with: request) { data, response, error in - guard error == nil else { - completion(.failure(FlagsmithError.unhandled(error!))) - return - } - - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - guard (200...299).contains(statusCode) else { - completion(.failure(FlagsmithError.statusCode(statusCode))) - return + // set the cache policy based on Flagsmith settings + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + session.configuration.urlCache = Flagsmith.shared.cache + if Flagsmith.shared.useCache { + request.cachePolicy = .useProtocolCachePolicy + if Flagsmith.shared.skipAPI { + request.cachePolicy = .returnCacheDataElseLoad } - - // The documentation indicates the data should be provided - // since error was found to be nil at this point. Either way - // the 'Decodable' variation will handle any invalid `Data`. - completion(.success(data ?? Data())) - }.resume() + } + + // we must use the delegate form here, not the completion handler, to be able to modify the cache + let task = session.dataTask(with: request) + tasksToCompletionHandlers[task] = completion + task.resume() } /// Requests a api route and only relays success or failure of the action. diff --git a/FlagsmithClient/Classes/Internal/CachedURLResponse.swift b/FlagsmithClient/Classes/Internal/CachedURLResponse.swift new file mode 100644 index 0000000..730fce2 --- /dev/null +++ b/FlagsmithClient/Classes/Internal/CachedURLResponse.swift @@ -0,0 +1,29 @@ +// +// File.swift +// CachedURLResponse +// +// Created by Daniel Wichett on 21/06/2023. +// + +import Foundation +#if canImport(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 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 + } +} From 89eb8bccc0b65f82eb45fcf1ebe3542bf61bbfc9 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Thu, 22 Jun 2023 15:41:35 +0100 Subject: [PATCH 10/12] Fixing import issues. --- FlagsmithClient/Classes/Flagsmith.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index bad06c9..e08d228 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -6,6 +6,9 @@ // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Manage feature flags and remote config across multiple projects, /// environments and organisations. From 3bd0af293826ad90c3ef7952fa909f5ce4cc9f39 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Wed, 12 Jul 2023 10:38:28 +0100 Subject: [PATCH 11/12] Moving cache configuration to a separate class. --- Example/FlagsmithClient/AppDelegate.swift | 8 ++--- FlagsmithClient/Classes/Flagsmith.swift | 31 ++++++++++++------- .../Classes/Internal/APIManager.swift | 10 +++--- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/Example/FlagsmithClient/AppDelegate.swift b/Example/FlagsmithClient/AppDelegate.swift index 2f0d5eb..a98b03b 100644 --- a/Example/FlagsmithClient/AppDelegate.swift +++ b/Example/FlagsmithClient/AppDelegate.swift @@ -25,16 +25,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Flag(featureName: "my_name", stringValue:"Testing", enabled: true)] // set cache on / off (defaults to off) - Flagsmith.shared.useCache = true + Flagsmith.shared.cacheConfig.useCache = true // set custom cache to use (defaults to shared URLCache) - //Flagsmith.shared.cache = + //Flagsmith.shared.cacheConfig.cache = // set skip API on / off (defaults to off) - Flagsmith.shared.skipAPI = false + Flagsmith.shared.cacheConfig.skipAPI = false // set cache TTL in seconds (defaults to 0, i.e. infinite) - Flagsmith.shared.cacheTTL = 90 + Flagsmith.shared.cacheConfig.cacheTTL = 90 // set analytics on or off Flagsmith.shared.enableAnalytics = true diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index e08d228..0e8b68a 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -48,18 +48,9 @@ public class Flagsmith { /// Default flags to fall back on if an API call fails public var defaultFlags: [Flag] = [] - - /// 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 - - /// 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 + + /// Configuration class for the cache settings + public var cacheConfig:CacheConfig = CacheConfig() private init() { } @@ -289,3 +280,19 @@ public class Flagsmith { return returnFlags } } + +public class CacheConfig { + + /// 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 + + /// 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 + +} diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift index 8f634a8..2d3626a 100644 --- a/FlagsmithClient/Classes/Internal/APIManager.swift +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -49,8 +49,8 @@ class APIManager : NSObject, URLSessionDataDelegate { func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) { // intercept and modify the cache settings for the response - if Flagsmith.shared.useCache { - let newResponse = proposedResponse.response(withExpirationDuration: Int(Flagsmith.shared.cacheTTL)) + if Flagsmith.shared.cacheConfig.useCache { + let newResponse = proposedResponse.response(withExpirationDuration: Int(Flagsmith.shared.cacheConfig.cacheTTL)) completionHandler(newResponse) } else { completionHandler(proposedResponse) @@ -89,10 +89,10 @@ class APIManager : NSObject, URLSessionDataDelegate { // set the cache policy based on Flagsmith settings request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - session.configuration.urlCache = Flagsmith.shared.cache - if Flagsmith.shared.useCache { + session.configuration.urlCache = Flagsmith.shared.cacheConfig.cache + if Flagsmith.shared.cacheConfig.useCache { request.cachePolicy = .useProtocolCachePolicy - if Flagsmith.shared.skipAPI { + if Flagsmith.shared.cacheConfig.skipAPI { request.cachePolicy = .returnCacheDataElseLoad } } From f0960a2fea31f79418f8fabdbe9b89a4754be7c4 Mon Sep 17 00:00:00 2001 From: Daniel Wichett Date: Wed, 12 Jul 2023 13:40:48 +0100 Subject: [PATCH 12/12] Removing un-necessary method for default flags. --- FlagsmithClient/Classes/Flagsmith.swift | 28 +++++-------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 0e8b68a..7c03c4e 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -69,12 +69,11 @@ public class Flagsmith { case .success(let thisIdentity): completion(.success(thisIdentity.flags)) case .failure(let error): - let fallbackFlags = self.getFlagsUsingDefaults(flags: [], forIdentity: identity) - if fallbackFlags.isEmpty { + if self.defaultFlags.isEmpty { completion(.failure(error)) } else { - completion(.success(fallbackFlags)) + completion(.success(self.defaultFlags)) } } } @@ -84,12 +83,11 @@ public class Flagsmith { case .success(let flags): completion(.success(flags)) case .failure(let error): - let fallbackFlags = self.getFlagsUsingDefaults(flags: [], forIdentity: identity) - if fallbackFlags.isEmpty { + if self.defaultFlags.isEmpty { completion(.failure(error)) } else { - completion(.success(fallbackFlags)) + completion(.success(self.defaultFlags)) } } } @@ -259,26 +257,10 @@ public class Flagsmith { } } - /// Return a flag for a flag ID and identity, using either the cache (if enabled) or the default flags + /// 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}) } - - /// Return an array of flags for an identity, including the cached flags (if enabled) and the default flags when they are not already present in the passed array - private func getFlagsUsingDefaults(flags:[Flag], forIdentity identity: String? = nil) -> [Flag] { - var returnFlags:[Flag] = [] - returnFlags.append(contentsOf: flags) - - for flag in defaultFlags { - if !returnFlags.contains(where: { $0.feature.name == flag.feature.name }) { - if flag.value != .null { - returnFlags.append(flag) - } - } - } - - return returnFlags - } } public class CacheConfig {