From b79283e924f64fefe5b7fa5b7129336699332c9a Mon Sep 17 00:00:00 2001 From: 3a4oT Date: Tue, 12 Mar 2024 20:01:49 +0200 Subject: [PATCH] - Bumped `swift-tools` to swift 5.9. - Enabled `StrictConcurrency=complete` mode in order to be prepared for upcoming Swift 6. Implemented `Sendable` support for Flagsmith client and friends. - Enabled `ExistentialAny` feature in order to be prepared for upcoming Swift 6. Code adjustments - adjusted tests for `StrictConcurrency=complete` mode . --- FlagsmithClient/Classes/Feature.swift | 2 +- FlagsmithClient/Classes/Flag.swift | 2 +- FlagsmithClient/Classes/Flagsmith.swift | 496 +++++++++--------- FlagsmithClient/Classes/FlagsmithError.swift | 6 +- .../Classes/Internal/APIManager.swift | 281 +++++----- .../Classes/Internal/FlagsmithAnalytics.swift | 226 ++++---- FlagsmithClient/Classes/Trait.swift | 4 +- FlagsmithClient/Classes/TypedValue.swift | 6 +- .../Classes/UnknownTypeValue.swift | 2 +- FlagsmithClient/Tests/APIManagerTests.swift | 49 +- Package.swift | 10 +- 11 files changed, 589 insertions(+), 495 deletions(-) diff --git a/FlagsmithClient/Classes/Feature.swift b/FlagsmithClient/Classes/Feature.swift index 446bb4f..adf3473 100644 --- a/FlagsmithClient/Classes/Feature.swift +++ b/FlagsmithClient/Classes/Feature.swift @@ -28,7 +28,7 @@ public struct Feature: Codable, Sendable { self.description = description } - public func encode(to encoder: Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.name, forKey: .name) try container.encodeIfPresent(self.type, forKey: .type) diff --git a/FlagsmithClient/Classes/Flag.swift b/FlagsmithClient/Classes/Flag.swift index b9a9ebc..1bd63f5 100644 --- a/FlagsmithClient/Classes/Flag.swift +++ b/FlagsmithClient/Classes/Flag.swift @@ -47,7 +47,7 @@ public struct Flag: Codable, Sendable { self.enabled = enabled } - public func encode(to encoder: Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.feature, forKey: .feature) try container.encode(self.value, forKey: .value) diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 495fff6..777d489 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -12,269 +12,291 @@ import FoundationNetworking /// 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 } - } +public final class Flagsmith: @unchecked Sendable { + /// Shared singleton client object + public static let shared: Flagsmith = Flagsmith() + private let apiManager: APIManager + private let analytics: FlagsmithAnalytics - /// 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 { + 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 } + } + + /// Is flag analytics enabled? + public var enableAnalytics: Bool { + set { analytics.enableAnalytics = newValue } + get { analytics.enableAnalytics } + } - /// 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() + /// How often to send the flag analytics, in seconds + public var analyticsFlushPeriod: Int { + set { analytics.flushPeriod = newValue } + get { analytics.flushPeriod } + } - 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)) - } + /// Default flags to fall back on if an API call fails + private var _defaultFlags: [Flag] = [] + public var defaultFlags: [Flag] { + get { + apiManager.propertiesSerialAccessQueue.sync { _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)) - } + set { + apiManager.propertiesSerialAccessQueue.sync { + _defaultFlags = 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)) + + /// Configuration class for the cache settings + private var _cacheConfig: CacheConfig = CacheConfig() + public var cacheConfig: CacheConfig { + get { + apiManager.propertiesSerialAccessQueue.sync { _cacheConfig } } - else { - completion(.failure(error)) + set { + apiManager.propertiesSerialAccessQueue.sync { + _cacheConfig = newValue + } } - } } - } - - /// 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)) + + private init() { + apiManager = APIManager() + analytics = FlagsmithAnalytics(apiManager: apiManager) + } + + /// 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: @Sendable @escaping (Result<[Flag], any 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)) + } + } + } } - else { - completion(.failure(error)) + } + + /// 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: @Sendable @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)) + } + } } - } } - } - - /// 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 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: @Sendable @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)) + } + 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 of `TypedValue` in case of success or `Error` in case of failure + public func getValueForFeature(withID id: String, + forIdentity identity: String? = nil, + completion: @Sendable @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)) + } + 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 .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 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: @Sendable @escaping (Result<[Trait], any 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)) + } + 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: @Sendable @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)) + } } - 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: @Sendable @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: @Sendable @escaping (Result<[Trait], any 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: @Sendable @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 identity: String? = nil) -> Flag? { + return self.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 { +public final class CacheConfig { + + /// Cache to use when enabled, defaults to the shared app cache + public var cache: URLCache = 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..672d4b2 100644 --- a/FlagsmithClient/Classes/FlagsmithError.swift +++ b/FlagsmithClient/Classes/FlagsmithError.swift @@ -20,8 +20,8 @@ public enum FlagsmithError: LocalizedError, Sendable { /// API Response could not be decoded. case decoding(DecodingError) /// Unknown or unhandled error was encountered. - case unhandled(Error) - + case unhandled(any Error) + public var errorDescription: String? { switch self { case .apiKey: @@ -46,7 +46,7 @@ public enum FlagsmithError: LocalizedError, Sendable { /// * as `EncodingError`: `.encoding()` error will be created. /// * as `DecodingError`: `.decoding()` error will be created. /// * default: `.unhandled()` error will be created. - internal init(_ error: Error) { + internal init(_ error: any Error) { switch error { case let flagsmithError as FlagsmithError: self = flagsmithError diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift index b13b898..d5bb506 100644 --- a/FlagsmithClient/Classes/Internal/APIManager.swift +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -11,142 +11,173 @@ 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)) } - } +final class APIManager : NSObject, URLSessionDataDelegate, @unchecked Sendable { + + private var _session: URLSession! + private var session: URLSession { + get { + propertiesSerialAccessQueue.sync { _session } + } + set { + propertiesSerialAccessQueue.sync(flags: .barrier) { + _session = newValue + } } - 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(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - serialAccessQueue.sync { - var existingData = tasksToData[dataTask.taskIdentifier] ?? Data() - existingData.append(data) - tasksToData[dataTask.taskIdentifier] = existingData + + /// Base `URL` used for requests. + private var _baseURL = URL(string: "https://edge.api.flagsmith.com/api/v1/")! + var baseURL: URL { + get { + propertiesSerialAccessQueue.sync { _baseURL } + } + set { + propertiesSerialAccessQueue.sync { + _baseURL = newValue + } + } } - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - completionHandler(.allow) + /// API Key unique to an organization. + private var _apiKey: String? + var apiKey: String? { + get { + propertiesSerialAccessQueue.sync { _apiKey } + } + set { + propertiesSerialAccessQueue.sync { + _apiKey = newValue + } + } } - - /// 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 + + // store the completion handlers and accumulated data for each task + private var tasksToCompletionHandlers:[Int: @Sendable(Result) -> Void] = [:] + private var tasksToData:[Int:Data] = [:] + private let serialAccessQueue = DispatchQueue(label: "flagsmithSerialAccessQueue") + let propertiesSerialAccessQueue = DispatchQueue(label: "propertiesSerialAccessQueue") + + override init() { + super.init() + let configuration = URLSessionConfiguration.default + self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) } - - var request: URLRequest - do { - request = try router.request(baseUrl: baseURL, apiKey: apiKey) - } catch { - completion(.failure(error)) - return + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any 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 + } + } } - - // 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 - } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, + completionHandler: @Sendable @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) } + } + } } - - // 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() + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + serialAccessQueue.sync { + var existingData = tasksToData[dataTask.taskIdentifier] ?? Data() + existingData.append(data) + tasksToData[dataTask.taskIdentifier] = existingData + } } - } - - /// 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(())) - } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + completionHandler(.allow) } - } - - /// 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: @Sendable @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: @Sendable @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: @Sendable @escaping (Result) -> Void) { + request(router) { (result: Result) in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let 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/FlagsmithAnalytics.swift b/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift index d172b3b..1d6c3f0 100644 --- a/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift +++ b/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift @@ -8,102 +8,140 @@ 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() +final class FlagsmithAnalytics: @unchecked Sendable { + + /// Indicates if analytics are enabled. + private var _enableAnalytics: Bool = true + var enableAnalytics:Bool { + get { + apiManager.propertiesSerialAccessQueue.sync { _enableAnalytics } + } + set { + apiManager.propertiesSerialAccessQueue.sync { + _enableAnalytics = newValue + } + } + } + + private var _flushPeriod: Int = 10 + /// How often analytics events are processed (in seconds). + var flushPeriod: Int { + get { + apiManager.propertiesSerialAccessQueue.sync { _flushPeriod } + } + set { + apiManager.propertiesSerialAccessQueue.sync { + _flushPeriod = newValue + } + setupTimer() + } + } + + private unowned let apiManager: APIManager + private let EVENTS_KEY = "events" + private var _events:[String:Int] = [:] + private var events:[String:Int] { + get { + apiManager.propertiesSerialAccessQueue.sync { _events } + } + set { + apiManager.propertiesSerialAccessQueue.sync { + _events = newValue + } + } } - } - - 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 var _timer:Timer? + private var timer:Timer? { + get { + apiManager.propertiesSerialAccessQueue.sync { _timer } + } + set { + apiManager.propertiesSerialAccessQueue.sync { + _timer = newValue + } + } } - - guard !events.isEmpty else { - return + + 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 } - - apiManager.request(.postAnalytics(events: events)) { [weak self] (result: Result) in - switch result { - case .failure: - print("Upload analytics failed") - case .success: - self?.reset() - } + + /// 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 + } + + guard !events.isEmpty else { + return + } + + apiManager.request(.postAnalytics(events: events)) { @Sendable [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: Timer) { + postAnalytics() } - } - - #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 +#endif } diff --git a/FlagsmithClient/Classes/Trait.swift b/FlagsmithClient/Classes/Trait.swift index 85e9ec6..eda908e 100644 --- a/FlagsmithClient/Classes/Trait.swift +++ b/FlagsmithClient/Classes/Trait.swift @@ -43,14 +43,14 @@ public struct Trait: Codable, Sendable { self.identifier = identifier } - public init(from decoder: Decoder) throws { + public init(from decoder: any 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 { + public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(key, forKey: .key) try container.encode(typedValue, forKey: .value) diff --git a/FlagsmithClient/Classes/TypedValue.swift b/FlagsmithClient/Classes/TypedValue.swift index 5bd5794..b97ef4f 100644 --- a/FlagsmithClient/Classes/TypedValue.swift +++ b/FlagsmithClient/Classes/TypedValue.swift @@ -17,7 +17,7 @@ public enum TypedValue: Equatable, Sendable { } extension TypedValue: Codable { - public init(from decoder: Decoder) throws { + public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(Bool.self) { @@ -49,10 +49,10 @@ extension TypedValue: Codable { codingPath: [], debugDescription: "No decodable `TypedValue` value found." ) - throw DecodingError.valueNotFound(Decodable.self, context) + throw DecodingError.valueNotFound((any Decodable).self, context) } - public func encode(to encoder: Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .bool(let value): diff --git a/FlagsmithClient/Classes/UnknownTypeValue.swift b/FlagsmithClient/Classes/UnknownTypeValue.swift index 7ff2ed0..b8f01b6 100644 --- a/FlagsmithClient/Classes/UnknownTypeValue.swift +++ b/FlagsmithClient/Classes/UnknownTypeValue.swift @@ -15,7 +15,7 @@ public enum UnknownTypeValue: Decodable, Sendable { case int(Int), string(String), float(Float), null - public init(from decoder: Decoder) throws { + public init(from decoder: any Decoder) throws { if let int = try? decoder.singleValueContainer().decode(Int.self) { self = .int(int) return diff --git a/FlagsmithClient/Tests/APIManagerTests.swift b/FlagsmithClient/Tests/APIManagerTests.swift index 435e74f..c001b5a 100644 --- a/FlagsmithClient/Tests/APIManagerTests.swift +++ b/FlagsmithClient/Tests/APIManagerTests.swift @@ -17,23 +17,22 @@ final class APIManagerTests: FlagsmithClientTestCase { apiManager.apiKey = nil let requestFinished = expectation(description: "Request Finished") - var error: FlagsmithError? - apiManager.request(.getFlags) { (result: Result) in + apiManager.request(.getFlags) { (result: Result) in if case let .failure(e) = result { - error = e as? FlagsmithError + let error = e as? FlagsmithError + let flagsmithError = try! XCTUnwrap(error) + guard case .apiKey = flagsmithError else { + XCTFail("Wrong Error") + requestFinished.fulfill() + return + } } 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. @@ -42,40 +41,42 @@ final class APIManagerTests: FlagsmithClientTestCase { apiManager.baseURL = URL(fileURLWithPath: "/dev/null") let requestFinished = expectation(description: "Request Finished") - var error: FlagsmithError? - - apiManager.request(.getFlags) { (result: Result) in + + apiManager.request(.getFlags) { (result: Result) in if case let .failure(e) = result { - error = e as? FlagsmithError + let error = e as? FlagsmithError + let flagsmithError: FlagsmithError? = try! XCTUnwrap(error) + guard case .apiURL = flagsmithError else { + XCTFail("Wrong Error") + requestFinished.fulfill() + return + } } requestFinished.fulfill() } wait(for: [requestFinished], timeout: 1.0) - - let flagsmithError = try XCTUnwrap(error) - guard case .apiURL = flagsmithError else { - XCTFail("Wrong Error") - return - } + } - + + @MainActor func testConcurrentRequests() throws { apiManager.apiKey = "8D5ABC87-6BBF-4AE7-BC05-4DC1AFE770DF" let concurrentQueue = DispatchQueue(label: "concurrentQueue", attributes: .concurrent) var expectations:[XCTestExpectation] = []; let iterations = 500 - var error: FlagsmithError? 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 + self.apiManager.request(.getFlags) { (result: Result) in if case let .failure(e) = result { - error = e as? FlagsmithError + let error = e as? FlagsmithError + // Ensure that we didn't have any errors during the process + XCTAssertTrue(error == nil) } expectation.fulfill() } @@ -83,8 +84,6 @@ final class APIManagerTests: FlagsmithClientTestCase { } wait(for: expectations, timeout: 10) - // Ensure that we didn't have any errors during the process - XCTAssertTrue(error == nil) print("Finished!") } diff --git a/Package.swift b/Package.swift index 84a8f31..fc3cb06 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.9 import PackageDescription @@ -11,10 +11,14 @@ let package = Package( .target( name: "FlagsmithClient", dependencies: [], - path: "FlagsmithClient/Classes"), + path: "FlagsmithClient/Classes", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency=complete"), + .enableUpcomingFeature("ExistentialAny"), // https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md + ]), .testTarget( name: "FlagsmitClientTests", dependencies: ["FlagsmithClient"], - path: "FlagsmithClient/Tests"), + path: "FlagsmithClient/Tests") ] )