diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..a717917 --- /dev/null +++ b/.swiftformat @@ -0,0 +1 @@ +--disable redundantInternal diff --git a/FlagsmithClient/Classes/Feature.swift b/FlagsmithClient/Classes/Feature.swift index 446bb4f..9e244e7 100644 --- a/FlagsmithClient/Classes/Feature.swift +++ b/FlagsmithClient/Classes/Feature.swift @@ -11,27 +11,27 @@ import Foundation A Feature represents a flag or remote configuration value on the server. */ public struct Feature: Codable, Sendable { - enum CodingKeys: String, CodingKey { - case name - case type - case description - } - - /// The name of the feature - public let name: String - public let type: String? - public let description: String? - - init(name: String, type: String?, description: String?) { - self.name = name - self.type = type - self.description = description - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.name, forKey: .name) - try container.encodeIfPresent(self.type, forKey: .type) - try container.encodeIfPresent(self.description, forKey: .description) - } + enum CodingKeys: String, CodingKey { + case name + case type + case description + } + + /// The name of the feature + public let name: String + public let type: String? + public let description: String? + + init(name: String, type: String?, description: String?) { + self.name = name + self.type = type + self.description = description + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(type, forKey: .type) + try container.encodeIfPresent(description, forKey: .description) + } } diff --git a/FlagsmithClient/Classes/Flag.swift b/FlagsmithClient/Classes/Flag.swift index b9a9ebc..27ed2b4 100644 --- a/FlagsmithClient/Classes/Flag.swift +++ b/FlagsmithClient/Classes/Flag.swift @@ -8,49 +8,49 @@ import Foundation /** -A Flag represents a feature flag on the server. -*/ + A Flag represents a feature flag on the server. + */ public struct Flag: Codable, Sendable { - enum CodingKeys: String, CodingKey { - case feature - case value = "feature_state_value" - case enabled - } - - public let feature: Feature - public let value: TypedValue - public let enabled: Bool - - public init(featureName:String, boolValue: Bool, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.bool(boolValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, floatValue: Float, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.float(floatValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, intValue: Int, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.int(intValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, stringValue: String, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.string(stringValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.init(featureName: featureName, value: TypedValue.null, enabled: enabled, featureType: featureType, featureDescription: featureDescription) - } - - public init(featureName:String, value: TypedValue, enabled: Bool, featureType:String? = nil, featureDescription:String? = nil) { - self.feature = Feature(name: featureName, type: featureType, description: featureDescription) - self.value = value - self.enabled = enabled - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.feature, forKey: .feature) - try container.encode(self.value, forKey: .value) - try container.encode(self.enabled, forKey: .enabled) - } + enum CodingKeys: String, CodingKey { + case feature + case value = "feature_state_value" + case enabled + } + + public let feature: Feature + public let value: TypedValue + public let enabled: Bool + + public init(featureName: String, boolValue: Bool, enabled: Bool, featureType: String? = nil, featureDescription: String? = nil) { + self.init(featureName: featureName, value: TypedValue.bool(boolValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, floatValue: Float, enabled: Bool, featureType: String? = nil, featureDescription: String? = nil) { + self.init(featureName: featureName, value: TypedValue.float(floatValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, intValue: Int, enabled: Bool, featureType: String? = nil, featureDescription: String? = nil) { + self.init(featureName: featureName, value: TypedValue.int(intValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, stringValue: String, enabled: Bool, featureType: String? = nil, featureDescription: String? = nil) { + self.init(featureName: featureName, value: TypedValue.string(stringValue), enabled: enabled, featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, enabled: Bool, featureType: String? = nil, featureDescription: String? = nil) { + self.init(featureName: featureName, value: TypedValue.null, enabled: enabled, featureType: featureType, featureDescription: featureDescription) + } + + public init(featureName: String, value: TypedValue, enabled: Bool, featureType: String? = nil, featureDescription: String? = nil) { + feature = Feature(name: featureName, type: featureType, description: featureDescription) + self.value = value + self.enabled = enabled + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(feature, forKey: .feature) + try container.encode(value, forKey: .value) + try container.encode(enabled, forKey: .enabled) + } } diff --git a/FlagsmithClient/Classes/Flagsmith+Concurrency.swift b/FlagsmithClient/Classes/Flagsmith+Concurrency.swift index 0e0c2f1..20171a3 100644 --- a/FlagsmithClient/Classes/Flagsmith+Concurrency.swift +++ b/FlagsmithClient/Classes/Flagsmith+Concurrency.swift @@ -1,5 +1,5 @@ // -// Flagsmith.swift +// Flagsmith+Concurrency.swift // FlagsmithClient // // Created by Richard Piazza on 3/10/22. @@ -9,173 +9,173 @@ import Foundation @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) public extension Flagsmith { - /// Get all feature flags (flags and remote config) optionally for a specific identity - /// - /// - Parameters: - /// - identity: ID of the user (optional) - /// - returns: Collection of Flag objects - func getFeatureFlags(forIdentity identity: String? = nil) async throws -> [Flag] { - try await withCheckedThrowingContinuation { continuation in - getFeatureFlags(forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + /// Get all feature flags (flags and remote config) optionally for a specific identity + /// + /// - Parameters: + /// - identity: ID of the user (optional) + /// - returns: Collection of Flag objects + func getFeatureFlags(forIdentity identity: String? = nil) async throws -> [Flag] { + try await withCheckedThrowingContinuation { continuation in + getFeatureFlags(forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } } - } - - /// Check feature exists and is enabled optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - returns: Bool value of the feature - func hasFeatureFlag(withID id: String, forIdentity identity: String? = nil) async throws -> Bool { - try await withCheckedThrowingContinuation({ continuation in - hasFeatureFlag(withID: id, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + + /// Check feature exists and is enabled optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - returns: Bool value of the feature + func hasFeatureFlag(withID id: String, forIdentity identity: String? = nil) async throws -> Bool { + try await withCheckedThrowingContinuation { continuation in + hasFeatureFlag(withID: id, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } + } + } + + /// Get remote config value optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - returns: String value of the feature if available + @available(*, deprecated, renamed: "getValueForFeature(withID:forIdentity:)") + func getFeatureValue(withID id: String, forIdentity identity: String? = nil) async throws -> String? { + try await withCheckedThrowingContinuation { continuation in + getFeatureValue(withID: id, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Get remote config value optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - returns: String value of the feature if available - @available(*, deprecated, renamed: "getValueForFeature(withID:forIdentity:)") - func getFeatureValue(withID id: String, forIdentity identity: String? = nil) async throws -> String? { - try await withCheckedThrowingContinuation({ continuation in - getFeatureValue(withID: id, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Get remote config value optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - returns: String value of the feature if available + func getValueForFeature(withID id: String, forIdentity identity: String? = nil) async throws -> TypedValue? { + try await withCheckedThrowingContinuation { continuation in + getValueForFeature(withID: id, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Get remote config value optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - returns: String value of the feature if available - func getValueForFeature(withID id: String, forIdentity identity: String? = nil) async throws -> TypedValue? { - try await withCheckedThrowingContinuation({ continuation in - getValueForFeature(withID: id, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Get all user traits for provided identity. Optionally filter results with a list of keys + /// + /// - Parameters: + /// - ids: IDs of the trait (optional) + /// - identity: ID of the user + /// - returns: Collection of Trait objects + func getTraits(withIDS ids: [String]? = nil, forIdentity identity: String) async throws -> [Trait] { + try await withCheckedThrowingContinuation { continuation in + getTraits(withIDS: ids, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Get all user traits for provided identity. Optionally filter results with a list of keys - /// - /// - Parameters: - /// - ids: IDs of the trait (optional) - /// - identity: ID of the user - /// - returns: Collection of Trait objects - func getTraits(withIDS ids: [String]? = nil, forIdentity identity: String) async throws -> [Trait] { - try await withCheckedThrowingContinuation({ continuation in - getTraits(withIDS: ids, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Get user trait for provided identity and trait key + /// + /// - Parameters: + /// - id: ID of the trait + /// - identity: ID of the user + /// - returns: Optional Trait if found. + func getTrait(withID id: String, forIdentity identity: String) async throws -> Trait? { + try await withCheckedThrowingContinuation { continuation in + getTrait(withID: id, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Get user trait for provided identity and trait key - /// - /// - Parameters: - /// - id: ID of the trait - /// - identity: ID of the user - /// - returns: Optional Trait if found. - func getTrait(withID id: String, forIdentity identity: String) async throws -> Trait? { - try await withCheckedThrowingContinuation({ continuation in - getTrait(withID: id, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Set user trait for provided identity + /// + /// - Parameters: + /// - trait: Trait to be created or updated + /// - identity: ID of the user + /// - returns: The Trait requested to be set. + @discardableResult func setTrait(_ trait: Trait, forIdentity identity: String) async throws -> Trait { + try await withCheckedThrowingContinuation { continuation in + setTrait(trait, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } - - /// Set user trait for provided identity - /// - /// - Parameters: - /// - trait: Trait to be created or updated - /// - identity: ID of the user - /// - returns: The Trait requested to be set. - @discardableResult func setTrait(_ trait: Trait, forIdentity identity: String) async throws -> Trait { - try await withCheckedThrowingContinuation({ continuation in - setTrait(trait, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + } + + /// Set user traits in bulk for provided identity + /// + /// - Parameters: + /// - trait: Traits to be created or updated + /// - identity: ID of the user + /// - returns: The Traits requested to be set. + @discardableResult func setTraits(_ traits: [Trait], forIdentity identity: String) async throws -> [Trait] { + try await withCheckedThrowingContinuation { continuation in + setTraits(traits, forIdentity: identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } + } - /// Set user traits in bulk for provided identity - /// - /// - Parameters: - /// - trait: Traits to be created or updated - /// - identity: ID of the user - /// - returns: The Traits requested to be set. - @discardableResult func setTraits(_ traits: [Trait], forIdentity identity: String) async throws -> [Trait] { - try await withCheckedThrowingContinuation({ continuation in - setTraits(traits, forIdentity: identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) - } - } - }) - } - - /// Get both feature flags and user traits for the provided identity - /// - /// - Parameters: - /// - identity: ID of the user - /// - returns: Identity matching the requested ID. - func getIdentity(_ identity: String) async throws -> Identity { - try await withCheckedThrowingContinuation({ continuation in - getIdentity(identity) { result in - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let value): - continuation.resume(returning: value) + /// Get both feature flags and user traits for the provided identity + /// + /// - Parameters: + /// - identity: ID of the user + /// - returns: Identity matching the requested ID. + func getIdentity(_ identity: String) async throws -> Identity { + try await withCheckedThrowingContinuation { continuation in + getIdentity(identity) { result in + switch result { + case let .failure(error): + continuation.resume(throwing: error) + case let .success(value): + continuation.resume(returning: value) + } + } } - } - }) - } + } } diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 495fff6..8af3cb6 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -7,274 +7,274 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Manage feature flags and remote config across multiple projects, /// environments and organisations. public class Flagsmith { - /// Shared singleton client object - public static let shared = Flagsmith() - private let apiManager = APIManager() - private lazy var analytics = FlagsmithAnalytics(apiManager: apiManager) - - /// Base URL - /// - /// The default implementation uses: `https://edge.api.flagsmith.com/api/v1`. - public var baseURL: URL { - set { apiManager.baseURL = newValue } - get { apiManager.baseURL } - } - - /// API Key unique to your organization. - /// - /// This value must be provided before any request can succeed. - public var apiKey: String? { - set { apiManager.apiKey = newValue } - get { apiManager.apiKey } - } + /// Shared singleton client object + public static let shared = Flagsmith() + private let apiManager = APIManager() + private lazy var analytics = FlagsmithAnalytics(apiManager: apiManager) - /// Is flag analytics enabled? - public var enableAnalytics: Bool { - set { analytics.enableAnalytics = newValue } - get { analytics.enableAnalytics } - } + /// Base URL + /// + /// The default implementation uses: `https://edge.api.flagsmith.com/api/v1`. + public var baseURL: URL { + set { apiManager.baseURL = newValue } + get { apiManager.baseURL } + } - /// How often to send the flag analytics, in seconds - public var analyticsFlushPeriod: Int { - set { analytics.flushPeriod = newValue } - get { analytics.flushPeriod } - } - - /// Default flags to fall back on if an API call fails - public var defaultFlags: [Flag] = [] - - /// Configuration class for the cache settings - public var cacheConfig:CacheConfig = CacheConfig() + /// API Key unique to your organization. + /// + /// This value must be provided before any request can succeed. + public var apiKey: String? { + set { apiManager.apiKey = newValue } + get { apiManager.apiKey } + } - private init() { - } - - /// Get all feature flags (flags and remote config) optionally for a specific identity - /// - /// - Parameters: - /// - identity: ID of the user (optional) - /// - completion: Closure with Result which contains array of Flag objects in case of success or Error in case of failure - public func getFeatureFlags(forIdentity identity: String? = nil, - completion: @escaping (Result<[Flag], Error>) -> Void) { - - if let identity = identity { - getIdentity(identity) { (result) in - switch result { - case .success(let thisIdentity): - completion(.success(thisIdentity.flags)) - case .failure(let error): - if self.defaultFlags.isEmpty { - completion(.failure(error)) - } - else { - completion(.success(self.defaultFlags)) - } - } - } - } else { - apiManager.request(.getFlags) { (result: Result<[Flag], Error>) in - switch result { - case .success(let flags): - completion(.success(flags)) - case .failure(let error): - if self.defaultFlags.isEmpty { - completion(.failure(error)) - } - else { - completion(.success(self.defaultFlags)) - } - } - } + /// Is flag analytics enabled? + public var enableAnalytics: Bool { + set { analytics.enableAnalytics = newValue } + get { analytics.enableAnalytics } } - } - - /// Check feature exists and is enabled optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - completion: Closure with Result which contains Bool in case of success or Error in case of failure - public func hasFeatureFlag(withID id: String, - forIdentity identity: String? = nil, - completion: @escaping (Result) -> Void) { - analytics.trackEvent(flagName: id) - getFeatureFlags(forIdentity: identity) { (result) in - switch result { - case .success(let flags): - let hasFlag = flags.contains(where: {$0.feature.name == id && $0.enabled}) - completion(.success(hasFlag)) - case .failure(let error): - if self.defaultFlags.contains(where: {$0.feature.name == id && $0.enabled}) { - completion(.success(true)) - } - else { - completion(.failure(error)) + + /// How often to send the flag analytics, in seconds + public var analyticsFlushPeriod: Int { + 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 = .init() + + private init() {} + + /// Get all feature flags (flags and remote config) optionally for a specific identity + /// + /// - Parameters: + /// - identity: ID of the user (optional) + /// - completion: Closure with Result which contains array of Flag objects in case of success or Error in case of failure + public func getFeatureFlags(forIdentity identity: String? = nil, + completion: @escaping (Result<[Flag], Error>) -> Void) + { + if let identity = identity { + getIdentity(identity) { result in + switch result { + case let .success(thisIdentity): + completion(.success(thisIdentity.flags)) + case let .failure(error): + if self.defaultFlags.isEmpty { + completion(.failure(error)) + } else { + completion(.success(self.defaultFlags)) + } + } + } + } else { + apiManager.request(.getFlags) { (result: Result<[Flag], Error>) in + switch result { + case let .success(flags): + completion(.success(flags)) + case let .failure(error): + if self.defaultFlags.isEmpty { + completion(.failure(error)) + } else { + completion(.success(self.defaultFlags)) + } + } + } } - } } - } - - /// Get remote config value optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - completion: Closure with Result which String in case of success or Error in case of failure - @available(*, deprecated, renamed: "getValueForFeature(withID:forIdentity:completion:)") - public func getFeatureValue(withID id: String, - forIdentity identity: String? = nil, - completion: @escaping (Result) -> Void) { - analytics.trackEvent(flagName: id) - getFeatureFlags(forIdentity: identity) { (result) in - switch result { - case .success(let flags): - let flag = flags.first(where: {$0.feature.name == id}) - completion(.success(flag?.value.stringValue)) - case .failure(let error): - if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { - completion(.success(flag.value.stringValue)) + + /// Check feature exists and is enabled optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - completion: Closure with Result which contains Bool in case of success or Error in case of failure + public func hasFeatureFlag(withID id: String, + forIdentity identity: String? = nil, + completion: @escaping (Result) -> Void) + { + analytics.trackEvent(flagName: id) + getFeatureFlags(forIdentity: identity) { result in + switch result { + case let .success(flags): + let hasFlag = flags.contains(where: { $0.feature.name == id && $0.enabled }) + completion(.success(hasFlag)) + case let .failure(error): + if self.defaultFlags.contains(where: { $0.feature.name == id && $0.enabled }) { + completion(.success(true)) + } else { + completion(.failure(error)) + } + } } - else { - completion(.failure(error)) + } + + /// Get remote config value optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - completion: Closure with Result which String in case of success or Error in case of failure + @available(*, deprecated, renamed: "getValueForFeature(withID:forIdentity:completion:)") + public func getFeatureValue(withID id: String, + forIdentity identity: String? = nil, + completion: @escaping (Result) -> Void) + { + analytics.trackEvent(flagName: id) + getFeatureFlags(forIdentity: identity) { result in + switch result { + case let .success(flags): + let flag = flags.first(where: { $0.feature.name == id }) + completion(.success(flag?.value.stringValue)) + case let .failure(error): + if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { + completion(.success(flag.value.stringValue)) + } else { + completion(.failure(error)) + } + } } - } } - } - - /// Get remote config value optionally for a specific identity - /// - /// - Parameters: - /// - id: ID of the feature - /// - identity: ID of the user (optional) - /// - completion: Closure with Result of `TypedValue` in case of success or `Error` in case of failure - public func getValueForFeature(withID id: String, - forIdentity identity: String? = nil, - completion: @escaping (Result) -> Void) { - analytics.trackEvent(flagName: id) - getFeatureFlags(forIdentity: identity) { (result) in - switch result { - case .success(let flags): - let flag = flags.first(where: {$0.feature.name == id}) - completion(.success(flag?.value)) - case .failure(let error): - if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { - completion(.success(flag.value)) + + /// Get remote config value optionally for a specific identity + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user (optional) + /// - completion: Closure with Result of `TypedValue` in case of success or `Error` in case of failure + public func getValueForFeature(withID id: String, + forIdentity identity: String? = nil, + completion: @escaping (Result) -> Void) + { + analytics.trackEvent(flagName: id) + getFeatureFlags(forIdentity: identity) { result in + switch result { + case let .success(flags): + let flag = flags.first(where: { $0.feature.name == id }) + completion(.success(flag?.value)) + case let .failure(error): + if let flag = self.getFlagUsingDefaults(withID: id, forIdentity: identity) { + completion(.success(flag.value)) + } else { + completion(.failure(error)) + } + } } - else { - completion(.failure(error)) + } + + /// Get all user traits for provided identity. Optionally filter results with a list of keys + /// + /// - Parameters: + /// - ids: IDs of the trait (optional) + /// - identity: ID of the user + /// - completion: Closure with Result which contains array of Trait objects in case of success or Error in case of failure + public func getTraits(withIDS ids: [String]? = nil, + forIdentity identity: String, + completion: @escaping (Result<[Trait], Error>) -> Void) + { + getIdentity(identity) { result in + switch result { + case let .success(identity): + if let ids = ids { + let traits = identity.traits.filter { ids.contains($0.key) } + completion(.success(traits)) + } else { + completion(.success(identity.traits)) + } + case let .failure(error): + completion(.failure(error)) + } } - } } - } - - /// Get all user traits for provided identity. Optionally filter results with a list of keys - /// - /// - Parameters: - /// - ids: IDs of the trait (optional) - /// - identity: ID of the user - /// - completion: Closure with Result which contains array of Trait objects in case of success or Error in case of failure - public func getTraits(withIDS ids: [String]? = nil, - forIdentity identity: String, - completion: @escaping (Result<[Trait], Error>) -> Void) { - getIdentity(identity) { (result) in - switch result { - case .success(let identity): - if let ids = ids { - let traits = identity.traits.filter({ids.contains($0.key)}) - completion(.success(traits)) - } else { - completion(.success(identity.traits)) + + /// Get user trait for provided identity and trait key + /// + /// - Parameters: + /// - id: ID of the trait + /// - identity: ID of the user + /// - completion: Closure with Result which contains Trait in case of success or Error in case of failure + public func getTrait(withID id: String, + forIdentity identity: String, + completion: @escaping (Result) -> Void) + { + getIdentity(identity) { result in + switch result { + case let .success(identity): + let trait = identity.traits.first(where: { $0.key == id }) + completion(.success(trait)) + case let .failure(error): + completion(.failure(error)) + } } - case .failure(let error): - completion(.failure(error)) - } } - } - - /// Get user trait for provided identity and trait key - /// - /// - Parameters: - /// - id: ID of the trait - /// - identity: ID of the user - /// - completion: Closure with Result which contains Trait in case of success or Error in case of failure - public func getTrait(withID id: String, - forIdentity identity: String, - completion: @escaping (Result) -> Void) { - getIdentity(identity) { (result) in - switch result { - case .success(let identity): - let trait = identity.traits.first(where: {$0.key == id}) - completion(.success(trait)) - case .failure(let error): - completion(.failure(error)) - } + + /// Set user trait for provided identity + /// + /// - Parameters: + /// - trait: Trait to be created or updated + /// - identity: ID of the user + /// - completion: Closure with Result which contains Trait in case of success or Error in case of failure + public func setTrait(_ trait: Trait, + forIdentity identity: String, + completion: @escaping (Result) -> Void) + { + apiManager.request(.postTrait(trait: trait, identity: identity)) { (result: Result) in + completion(result) + } } - } - - /// Set user trait for provided identity - /// - /// - Parameters: - /// - trait: Trait to be created or updated - /// - identity: ID of the user - /// - completion: Closure with Result which contains Trait in case of success or Error in case of failure - public func setTrait(_ trait: Trait, - forIdentity identity: String, - completion: @escaping (Result) -> Void) { - apiManager.request(.postTrait(trait: trait, identity: identity)) { (result: Result) in - completion(result) + + /// Set user traits in bulk for provided identity + /// + /// - Parameters: + /// - traits: Traits to be created or updated + /// - identity: ID of the user + /// - completion: Closure with Result which contains Traits in case of success or Error in case of failure + public func setTraits(_ traits: [Trait], + forIdentity identity: String, + completion: @escaping (Result<[Trait], Error>) -> Void) + { + apiManager.request(.postTraits(identity: identity, traits: traits)) { (result: Result) in + completion(result.map(\.traits)) + } } - } - /// Set user traits in bulk for provided identity - /// - /// - Parameters: - /// - traits: Traits to be created or updated - /// - identity: ID of the user - /// - completion: Closure with Result which contains Traits in case of success or Error in case of failure - public func setTraits(_ traits: [Trait], - forIdentity identity: String, - completion: @escaping (Result<[Trait], Error>) -> Void) { - apiManager.request(.postTraits(identity: identity, traits: traits)) { (result: Result) in - completion(result.map(\.traits)) + /// Get both feature flags and user traits for the provided identity + /// + /// - Parameters: + /// - identity: ID of the user + /// - completion: Closure with Result which contains Identity in case of success or Error in case of failure + public func getIdentity(_ identity: String, + completion: @escaping (Result) -> Void) + { + apiManager.request(.getIdentity(identity: identity)) { (result: Result) in + completion(result) + } } - } - - /// Get both feature flags and user traits for the provided identity - /// - /// - Parameters: - /// - identity: ID of the user - /// - completion: Closure with Result which contains Identity in case of success or Error in case of failure - public func getIdentity(_ identity: String, - completion: @escaping (Result) -> Void) { - apiManager.request(.getIdentity(identity: identity)) { (result: Result) in - completion(result) + + /// Return a flag for a flag ID from the default flags. + private func getFlagUsingDefaults(withID id: String, forIdentity _: String? = nil) -> Flag? { + return defaultFlags.first(where: { $0.feature.name == id }) } - } - - /// Return a flag for a flag ID from the default flags. - private func getFlagUsingDefaults(withID id: String, forIdentity identity: String? = nil) -> Flag? { - return self.defaultFlags.first(where: {$0.feature.name == id}) - } } public class CacheConfig { + /// Cache to use when enabled, defaults to the shared app cache + public var cache: URLCache = .shared - /// Cache to use when enabled, defaults to the shared app cache - public var cache: URLCache = URLCache.shared - - /// Use cached flags as a fallback? - public var useCache: Bool = false + /// Use cached flags as a fallback? + public var useCache: Bool = false - /// TTL for the cache in seconds, default of 0 means infinite - public var cacheTTL: Double = 0 + /// TTL for the cache in seconds, default of 0 means infinite + public var cacheTTL: Double = 0 - /// Skip API if there is a cache available - public var skipAPI: Bool = false - + /// Skip API if there is a cache available + public var skipAPI: Bool = false } diff --git a/FlagsmithClient/Classes/FlagsmithError.swift b/FlagsmithClient/Classes/FlagsmithError.swift index fda9675..e946b66 100644 --- a/FlagsmithClient/Classes/FlagsmithError.swift +++ b/FlagsmithClient/Classes/FlagsmithError.swift @@ -21,24 +21,24 @@ public enum FlagsmithError: LocalizedError, Sendable { case decoding(DecodingError) /// Unknown or unhandled error was encountered. case unhandled(Error) - + public var errorDescription: String? { switch self { case .apiKey: return "API Key was not provided or invalid" - case .apiURL(let path): + case let .apiURL(path): return "API URL '\(path)' was invalid" - case .encoding(let error): + case let .encoding(error): return "API Request could not be encoded: \(error.localizedDescription)" - case .statusCode(let code): + case let .statusCode(code): return "API Status Code '\(code)' was not expected." - case .decoding(let error): + case let .decoding(error): return "API Response could not be decoded: \(error.localizedDescription)" - case .unhandled(let error): + case let .unhandled(error): return "An unknown or unhandled error was encountered: \(error.localizedDescription)" } } - + /// Initialize a `FlagsmithError` using an existing `Swift.Error`. /// /// The error provided will be processed in several ways: @@ -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) { + init(_ error: Error) { switch error { case let flagsmithError as FlagsmithError: self = flagsmithError diff --git a/FlagsmithClient/Classes/Identity.swift b/FlagsmithClient/Classes/Identity.swift index 1dfbce3..0f97830 100644 --- a/FlagsmithClient/Classes/Identity.swift +++ b/FlagsmithClient/Classes/Identity.swift @@ -8,14 +8,14 @@ import Foundation /** -An Identity represents a user stored on the server. -*/ + An Identity represents a user stored on the server. + */ public struct Identity: Decodable, Sendable { - enum CodingKeys: String, CodingKey { - case flags - case traits - } - - public let flags: [Flag] - public let traits: [Trait] + enum CodingKeys: String, CodingKey { + case flags + case traits + } + + public let flags: [Flag] + public let traits: [Trait] } diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift index b13b898..6a3a8e4 100644 --- a/FlagsmithClient/Classes/Internal/APIManager.swift +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -7,146 +7,145 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Handles interaction with a **Flagsmith** api. -class APIManager : NSObject, URLSessionDataDelegate { - - private var session: URLSession! - - /// Base `URL` used for requests. - var baseURL = URL(string: "https://edge.api.flagsmith.com/api/v1/")! - /// API Key unique to an organization. - var apiKey: String? - - // store the completion handlers and accumulated data for each task - private var tasksToCompletionHandlers:[Int:(Result) -> Void] = [:] - private var tasksToData:[Int:Data] = [:] - private let serialAccessQueue = DispatchQueue(label: "flagsmithSerialAccessQueue") - - override init() { - super.init() - let configuration = URLSessionConfiguration.default - self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - serialAccessQueue.sync { - if let dataTask = task as? URLSessionDataTask { - if let completion = tasksToCompletionHandlers[dataTask.taskIdentifier] { - if let error = error { - DispatchQueue.main.async { completion(.failure(FlagsmithError.unhandled(error))) } - } - else { - let data = tasksToData[dataTask.taskIdentifier] ?? Data() - DispatchQueue.main.async { completion(.success(data)) } - } +class APIManager: NSObject, URLSessionDataDelegate { + private var session: URLSession! + + /// Base `URL` used for requests. + var baseURL = URL(string: "https://edge.api.flagsmith.com/api/v1/")! + /// API Key unique to an organization. + var apiKey: String? + + // store the completion handlers and accumulated data for each task + private var tasksToCompletionHandlers: [Int: (Result) -> Void] = [:] + private var tasksToData: [Int: Data] = [:] + private let serialAccessQueue = DispatchQueue(label: "flagsmithSerialAccessQueue") + + override init() { + super.init() + let configuration = URLSessionConfiguration.default + session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) + } + + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + serialAccessQueue.sync { + if let dataTask = task as? URLSessionDataTask { + if let completion = tasksToCompletionHandlers[dataTask.taskIdentifier] { + if let error = error { + DispatchQueue.main.async { completion(.failure(FlagsmithError.unhandled(error))) } + } else { + let data = tasksToData[dataTask.taskIdentifier] ?? Data() + DispatchQueue.main.async { completion(.success(data)) } + } + } + tasksToCompletionHandlers[dataTask.taskIdentifier] = nil + tasksToData[dataTask.taskIdentifier] = nil + } } - tasksToCompletionHandlers[dataTask.taskIdentifier] = nil - tasksToData[dataTask.taskIdentifier] = nil - } } - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, - completionHandler: @escaping (CachedURLResponse?) -> Void) { - serialAccessQueue.sync { - // intercept and modify the cache settings for the response - if Flagsmith.shared.cacheConfig.useCache { - let newResponse = proposedResponse.response(withExpirationDuration: Int(Flagsmith.shared.cacheConfig.cacheTTL)) - DispatchQueue.main.async { completionHandler(newResponse) } - } else { - DispatchQueue.main.async { completionHandler(proposedResponse) } - } + + func urlSession(_: URLSession, dataTask _: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, + completionHandler: @escaping (CachedURLResponse?) -> Void) + { + serialAccessQueue.sync { + // intercept and modify the cache settings for the response + if Flagsmith.shared.cacheConfig.useCache { + let newResponse = proposedResponse.response(withExpirationDuration: Int(Flagsmith.shared.cacheConfig.cacheTTL)) + DispatchQueue.main.async { completionHandler(newResponse) } + } else { + DispatchQueue.main.async { completionHandler(proposedResponse) } + } + } } - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - serialAccessQueue.sync { - var existingData = tasksToData[dataTask.taskIdentifier] ?? Data() - existingData.append(data) - tasksToData[dataTask.taskIdentifier] = existingData + + func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + serialAccessQueue.sync { + var existingData = tasksToData[dataTask.taskIdentifier] ?? Data() + existingData.append(data) + tasksToData[dataTask.taskIdentifier] = existingData + } } - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + + func urlSession(_: URLSession, dataTask _: URLSessionDataTask, didReceive _: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { completionHandler(.allow) } - - /// Base request method that handles creating a `URLRequest` and processing - /// the `URLSession` response. - /// - /// - parameters: - /// - router: The path and parameters that should be requested. - /// - completion: Function block executed with the result of the request. - private func request(_ router: Router, completion: @escaping (Result) -> Void) { - guard let apiKey = apiKey, !apiKey.isEmpty else { - completion(.failure(FlagsmithError.apiKey)) - return - } - - var request: URLRequest - do { - request = try router.request(baseUrl: baseURL, apiKey: apiKey) - } catch { - completion(.failure(error)) - return - } - - // set the cache policy based on Flagsmith settings - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - session.configuration.urlCache = Flagsmith.shared.cacheConfig.cache - if Flagsmith.shared.cacheConfig.useCache { - request.cachePolicy = .useProtocolCachePolicy - if Flagsmith.shared.cacheConfig.skipAPI { - request.cachePolicy = .returnCacheDataElseLoad - } - } - - // we must use the delegate form here, not the completion handler, to be able to modify the cache - serialAccessQueue.sync { - let task = session.dataTask(with: request) - tasksToCompletionHandlers[task.taskIdentifier] = completion - task.resume() - } - } - - /// Requests a api route and only relays success or failure of the action. - /// - /// - parameters: - /// - router: The path and parameters that should be requested. - /// - completion: Function block executed with the result of the request. - func request(_ router: Router, completion: @escaping (Result) -> Void) { - request(router) { (result: Result) in - switch result { - case .failure(let error): - completion(.failure(FlagsmithError(error))) - case .success: - completion(.success(())) - } - } - } - - /// Requests a api route and attempts the decode the response. - /// - /// - parameters: - /// - router: The path and parameters that should be requested. - /// - decoder: `JSONDecoder` used to deserialize the response data. - /// - completion: Function block executed with the result of the request. - func request(_ router: Router, using decoder: JSONDecoder = JSONDecoder(), completion: @escaping (Result) -> Void) { - request(router) { (result: Result) in - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let data): + + /// Base request method that handles creating a `URLRequest` and processing + /// the `URLSession` response. + /// + /// - parameters: + /// - router: The path and parameters that should be requested. + /// - completion: Function block executed with the result of the request. + private func request(_ router: Router, completion: @escaping (Result) -> Void) { + guard let apiKey = apiKey, !apiKey.isEmpty else { + completion(.failure(FlagsmithError.apiKey)) + return + } + + var request: URLRequest do { - let value = try decoder.decode(T.self, from: data) - completion(.success(value)) + request = try router.request(baseUrl: baseURL, apiKey: apiKey) } catch { - completion(.failure(FlagsmithError(error))) + completion(.failure(error)) + return + } + + // set the cache policy based on Flagsmith settings + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + session.configuration.urlCache = Flagsmith.shared.cacheConfig.cache + if Flagsmith.shared.cacheConfig.useCache { + request.cachePolicy = .useProtocolCachePolicy + if Flagsmith.shared.cacheConfig.skipAPI { + request.cachePolicy = .returnCacheDataElseLoad + } + } + + // we must use the delegate form here, not the completion handler, to be able to modify the cache + serialAccessQueue.sync { + let task = session.dataTask(with: request) + tasksToCompletionHandlers[task.taskIdentifier] = completion + task.resume() + } + } + + /// Requests a api route and only relays success or failure of the action. + /// + /// - parameters: + /// - router: The path and parameters that should be requested. + /// - completion: Function block executed with the result of the request. + func request(_ router: Router, completion: @escaping (Result) -> Void) { + request(router) { (result: Result) in + switch result { + case let .failure(error): + completion(.failure(FlagsmithError(error))) + case .success: + completion(.success(())) + } + } + } + + /// Requests a api route and attempts the decode the response. + /// + /// - parameters: + /// - router: The path and parameters that should be requested. + /// - decoder: `JSONDecoder` used to deserialize the response data. + /// - completion: Function block executed with the result of the request. + func request(_ router: Router, using decoder: JSONDecoder = JSONDecoder(), completion: @escaping (Result) -> Void) { + request(router) { (result: Result) in + switch result { + case let .failure(error): + completion(.failure(error)) + case let .success(data): + do { + let value = try decoder.decode(T.self, from: data) + completion(.success(value)) + } catch { + completion(.failure(FlagsmithError(error))) + } + } } - } } - } } diff --git a/FlagsmithClient/Classes/Internal/CachedURLResponse.swift b/FlagsmithClient/Classes/Internal/CachedURLResponse.swift index 730fce2..9e13ab2 100644 --- a/FlagsmithClient/Classes/Internal/CachedURLResponse.swift +++ b/FlagsmithClient/Classes/Internal/CachedURLResponse.swift @@ -1,5 +1,5 @@ // -// File.swift +// CachedURLResponse.swift // CachedURLResponse // // Created by Daniel Wichett on 21/06/2023. @@ -7,23 +7,22 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif extension CachedURLResponse { func response(withExpirationDuration duration: Int) -> CachedURLResponse { var cachedResponse = self - if let httpResponse = cachedResponse.response as? HTTPURLResponse, var headers = httpResponse.allHeaderFields as? [String : String], let url = httpResponse.url{ - - //set to 1 year (the max allowed) if the value is 0 - headers["Cache-Control"] = "max-age=\(duration == 0 ? 31536000 : duration)" - headers.removeValue(forKey: "Expires") - headers.removeValue(forKey: "s-maxage") + if let httpResponse = cachedResponse.response as? HTTPURLResponse, var headers = httpResponse.allHeaderFields as? [String: String], let url = httpResponse.url { + // set to 1 year (the max allowed) if the value is 0 + headers["Cache-Control"] = "max-age=\(duration == 0 ? 31_536_000 : duration)" + headers.removeValue(forKey: "Expires") + headers.removeValue(forKey: "s-maxage") - if let newResponse = HTTPURLResponse(url: url, statusCode: httpResponse.statusCode, httpVersion: "HTTP/1.1", headerFields: headers) { - cachedResponse = CachedURLResponse(response: newResponse, data: cachedResponse.data, userInfo: headers, storagePolicy: cachedResponse.storagePolicy) - } - } - return cachedResponse + if let newResponse = HTTPURLResponse(url: url, statusCode: httpResponse.statusCode, httpVersion: "HTTP/1.1", headerFields: headers) { + cachedResponse = CachedURLResponse(response: newResponse, data: cachedResponse.data, userInfo: headers, storagePolicy: cachedResponse.storagePolicy) + } + } + return cachedResponse } } diff --git a/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift b/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift index d172b3b..d803a3e 100644 --- a/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift +++ b/FlagsmithClient/Classes/Internal/FlagsmithAnalytics.swift @@ -9,101 +9,100 @@ import Foundation /// Internal analytics for the **FlagsmithClient** class FlagsmithAnalytics { - - /// Indicates if analytics are enabled. - var enableAnalytics: Bool = true - /// How often analytics events are processed (in seconds). - var flushPeriod: Int = 10 { - didSet { - setupTimer() + /// Indicates if analytics are enabled. + var enableAnalytics: Bool = true + /// How often analytics events are processed (in seconds). + var flushPeriod: Int = 10 { + didSet { + setupTimer() + } } - } - - private unowned let apiManager: APIManager - private let EVENTS_KEY = "events" - private var events:[String:Int] = [:] - private var timer:Timer? - - init(apiManager: APIManager) { - self.apiManager = apiManager - events = UserDefaults.standard.dictionary(forKey: EVENTS_KEY) as? [String:Int] ?? [:] - setupTimer() - } - - /// Counts the instances of a `Flag` being queried. - func trackEvent(flagName:String) { - let current = events[flagName] ?? 0 - events[flagName] = current + 1 - saveEvents() - } - - /// Invalidate and re-schedule timer for processing events - /// - /// On Apple (Darwin) platforms, this uses the Objective-C based - /// target/selector message sending API. - /// - /// Non-Darwin systems will use the corelibs Foundation block-based - /// api. Both platforms could use this approach, but the podspec - /// declares iOS 8.0 as a minimum target, and that api is only - /// available on 10+. (12.0 would be a good base in the future). - private func setupTimer() { - timer?.invalidate() - #if canImport(ObjectiveC) - timer = Timer.scheduledTimer( - timeInterval: TimeInterval(flushPeriod), - target: self, - selector: #selector(postAnalyticsWhenEnabled(_:)), - userInfo: nil, - repeats: true - ) - #else - timer = Timer.scheduledTimer( - withTimeInterval: TimeInterval(flushPeriod), - repeats: true, - block: { [weak self] _ in - self?.postAnalytics() - } - ) - #endif - } - - /// Reset events after successful processing. - private func reset() { - events = [:] - saveEvents() - } - - /// Persist the events to storage. - private func saveEvents() { - UserDefaults.standard.set(events, forKey: EVENTS_KEY) - } - - /// Send analytics to the api when enabled. - private func postAnalytics() { - guard enableAnalytics else { - return + + private unowned let apiManager: APIManager + private let 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() } - - guard !events.isEmpty else { - return + + /// Counts the instances of a `Flag` being queried. + func trackEvent(flagName: String) { + let current = events[flagName] ?? 0 + events[flagName] = current + 1 + saveEvents() } - - apiManager.request(.postAnalytics(events: events)) { [weak self] (result: Result) in - switch result { - case .failure: - print("Upload analytics failed") - case .success: - self?.reset() - } + + /// 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() } - } - - #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 + + /// 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)) { [weak self] (result: Result) in + switch result { + case .failure: + print("Upload analytics failed") + case .success: + self?.reset() + } + } + } + + #if canImport(ObjectiveC) + /// Event triggered when timer fired. + /// + /// Exposed on Apple platforms to relay selector-based events + @objc private func postAnalyticsWhenEnabled(_: Timer) { + postAnalytics() + } + #endif } diff --git a/FlagsmithClient/Classes/Internal/Router.swift b/FlagsmithClient/Classes/Internal/Router.swift index 639be56..d8d76a6 100644 --- a/FlagsmithClient/Classes/Internal/Router.swift +++ b/FlagsmithClient/Classes/Internal/Router.swift @@ -7,98 +7,98 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif enum Router: Sendable { - private enum HTTPMethod: String { - case get = "GET" - case post = "POST" - } - - case getFlags - case getIdentity(identity: String) - case postTrait(trait: Trait, identity: String) - case postTraits(identity: String, traits: [Trait]) - case postAnalytics(events: [String:Int]) - - private var method: HTTPMethod { - switch self { - case .getFlags, .getIdentity: - return .get - case .postTrait, .postTraits, .postAnalytics: - return .post + private enum HTTPMethod: String { + case get = "GET" + case post = "POST" } - } - - private var path: String { - switch self { - case .getFlags: - return "flags/" - case .getIdentity, .postTraits: - return "identities/" - case .postTrait: - return "traits/" - case .postAnalytics: - return "analytics/flags/" - } - } - private var parameters: [URLQueryItem]? { - switch self { - case .getIdentity(let identity), .postTraits(let identity, _): - return [URLQueryItem(name: "identifier", value: identity)] - default: - return nil + case getFlags + case getIdentity(identity: String) + case postTrait(trait: Trait, identity: String) + case postTraits(identity: String, traits: [Trait]) + case postAnalytics(events: [String: Int]) + + private var method: HTTPMethod { + switch self { + case .getFlags, .getIdentity: + return .get + case .postTrait, .postTraits, .postAnalytics: + return .post + } } - } - private func body(using encoder: JSONEncoder) throws -> Data? { - switch self { - case .getFlags, .getIdentity: - return nil - case .postTrait(let trait, let identifier): - let traitWithIdentity = Trait(trait: trait, identifier: identifier) - return try encoder.encode(traitWithIdentity) - case .postTraits(let identifier, let traits): - let traitsWithIdentity = Traits(traits: traits, identifier: identifier) - return try encoder.encode(traitsWithIdentity) - case .postAnalytics(let events): - return try encoder.encode(events) + private var path: String { + switch self { + case .getFlags: + return "flags/" + case .getIdentity, .postTraits: + return "identities/" + case .postTrait: + return "traits/" + case .postAnalytics: + return "analytics/flags/" + } } - } - - /// Generate a `URLRequest` with headers and encoded body. - /// - /// - parameters: - /// - baseUrl: The base URL of the api on which to base the request. - /// - apiKey: The organization key to provide in the request headers. - /// - encoder: `JSONEncoder` used to encode the request body. - func request(baseUrl: URL, - apiKey: String, - using encoder: JSONEncoder = JSONEncoder() - ) throws -> URLRequest { - let urlString = baseUrl.appendingPathComponent(path).absoluteString - var urlComponents = URLComponents(string: urlString) - urlComponents?.queryItems = parameters - guard let url = urlComponents?.url else { - // This is unlikely to ever be hit, but it is safer than - // relying on the forcefully-unwrapped optional. - throw FlagsmithError.apiURL(urlString) + + private var parameters: [URLQueryItem]? { + switch self { + case let .getIdentity(identity), let .postTraits(identity, _): + return [URLQueryItem(name: "identifier", value: identity)] + default: + return nil + } } - - guard !url.isFileURL else { - throw FlagsmithError.apiURL(urlString) + + private func body(using encoder: JSONEncoder) throws -> Data? { + switch self { + case .getFlags, .getIdentity: + return nil + case let .postTrait(trait, identifier): + let traitWithIdentity = Trait(trait: trait, identifier: identifier) + return try encoder.encode(traitWithIdentity) + case let .postTraits(identifier, traits): + let traitsWithIdentity = Traits(traits: traits, identifier: identifier) + return try encoder.encode(traitsWithIdentity) + case let .postAnalytics(events): + return try encoder.encode(events) + } } - - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - if let body = try self.body(using: encoder) { - request.httpBody = body + + /// Generate a `URLRequest` with headers and encoded body. + /// + /// - parameters: + /// - baseUrl: The base URL of the api on which to base the request. + /// - apiKey: The organization key to provide in the request headers. + /// - encoder: `JSONEncoder` used to encode the request body. + func request(baseUrl: URL, + apiKey: String, + using encoder: JSONEncoder = JSONEncoder()) throws -> URLRequest + { + let urlString = baseUrl.appendingPathComponent(path).absoluteString + var urlComponents = URLComponents(string: urlString) + urlComponents?.queryItems = parameters + guard let url = urlComponents?.url else { + // This is unlikely to ever be hit, but it is safer than + // relying on the forcefully-unwrapped optional. + throw FlagsmithError.apiURL(urlString) + } + + guard !url.isFileURL else { + throw FlagsmithError.apiURL(urlString) + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + if let body = try body(using: encoder) { + request.httpBody = body + } + request.addValue(apiKey, forHTTPHeaderField: "X-Environment-Key") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + return request } - request.addValue(apiKey, forHTTPHeaderField: "X-Environment-Key") - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - return request - } } diff --git a/FlagsmithClient/Classes/Trait.swift b/FlagsmithClient/Classes/Trait.swift index 85e9ec6..8195915 100644 --- a/FlagsmithClient/Classes/Trait.swift +++ b/FlagsmithClient/Classes/Trait.swift @@ -8,126 +8,128 @@ import Foundation /** -A Trait represents a value stored against an Identity (user) on the server. -*/ + A Trait represents a value stored against an Identity (user) on the server. + */ public struct Trait: Codable, Sendable { - enum CodingKeys: String, CodingKey { - case key = "trait_key" - case value = "trait_value" - case identity - case identifier - } - - public let key: String - /// The underlying value for the `Trait` - /// - /// - note: In the future, this can be renamed back to 'value' as major/feature-breaking - /// updates are released. - public var typedValue: TypedValue - /// The identity of the `Trait` when creating. - internal let identifier: String? - - public init(key: String, value: TypedValue) { - self.key = key - self.typedValue = value - self.identifier = nil - } - - /// Initializes a `Trait` with an identifier. - /// - /// When a `identifier` is provided, the resulting _encoded_ form of the `Trait` - /// will contain a `identity` key. - internal init(trait: Trait, identifier: String) { - self.key = trait.key - self.typedValue = trait.typedValue - self.identifier = identifier - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - key = try container.decode(String.self, forKey: .key) - typedValue = try container.decode(TypedValue.self, forKey: .value) - identifier = nil - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(key, forKey: .key) - try container.encode(typedValue, forKey: .value) - - if let identifier = identifier { - var identity = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .identity) - try identity.encode(identifier, forKey: .identifier) + enum CodingKeys: String, CodingKey { + case key = "trait_key" + case value = "trait_value" + case identity + case identifier + } + + public let key: String + /// The underlying value for the `Trait` + /// + /// - note: In the future, this can be renamed back to 'value' as major/feature-breaking + /// updates are released. + public var typedValue: TypedValue + /// The identity of the `Trait` when creating. + internal let identifier: String? + + public init(key: String, value: TypedValue) { + self.key = key + typedValue = value + identifier = nil + } + + /// Initializes a `Trait` with an identifier. + /// + /// When a `identifier` is provided, the resulting _encoded_ form of the `Trait` + /// will contain a `identity` key. + internal init(trait: Trait, identifier: String) { + key = trait.key + typedValue = trait.typedValue + self.identifier = identifier + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: .key) + typedValue = try container.decode(TypedValue.self, forKey: .value) + identifier = nil + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(key, forKey: .key) + try container.encode(typedValue, forKey: .value) + + if let identifier = identifier { + var identity = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .identity) + try identity.encode(identifier, forKey: .identifier) + } } - } } // MARK: - Convenience Initializers + public extension Trait { - init(key: String, value: Bool) { - self.key = key - self.typedValue = .bool(value) - self.identifier = nil - } - - init(key: String, value: Float) { - self.key = key - self.typedValue = .float(value) - self.identifier = nil - } - - init(key: String, value: Int) { - self.key = key - self.typedValue = .int(value) - self.identifier = nil - } - - init(key: String, value: String) { - self.key = key - self.typedValue = .string(value) - self.identifier = nil - } + init(key: String, value: Bool) { + self.key = key + typedValue = .bool(value) + identifier = nil + } + + init(key: String, value: Float) { + self.key = key + typedValue = .float(value) + identifier = nil + } + + init(key: String, value: Int) { + self.key = key + typedValue = .int(value) + identifier = nil + } + + init(key: String, value: String) { + self.key = key + typedValue = .string(value) + identifier = nil + } } // MARK: - Deprecations + public extension Trait { - @available(*, deprecated, renamed: "typedValue") - var value: String { - get { typedValue.description } - set { typedValue = .string(newValue) } - } + @available(*, deprecated, renamed: "typedValue") + var value: String { + get { typedValue.description } + set { typedValue = .string(newValue) } + } } /** -A PostTrait represents a structure to set a new trait, with the Trait fields and the identity. -*/ + A PostTrait represents a structure to set a new trait, with the Trait fields and the identity. + */ @available(*, deprecated) public struct PostTrait: Codable { - enum CodingKeys: String, CodingKey { - case key = "trait_key" - case value = "trait_value" - case identity = "identity" - } - - public let key: String - public var value: String - public var identity: IdentityStruct - - public struct IdentityStruct: Codable { - public var identifier: String - - public enum CodingKeys: String, CodingKey { - case identifier = "identifier" + enum CodingKeys: String, CodingKey { + case key = "trait_key" + case value = "trait_value" + case identity } - - public init(identifier: String) { - self.identifier = identifier + + public let key: String + public var value: String + public var identity: IdentityStruct + + public struct IdentityStruct: Codable { + public var identifier: String + + public enum CodingKeys: String, CodingKey { + case identifier + } + + public init(identifier: String) { + self.identifier = identifier + } + } + + public init(key: String, value: String, identifier: String) { + self.key = key + self.value = value + identity = IdentityStruct(identifier: identifier) } - } - - public init(key: String, value: String, identifier:String) { - self.key = key - self.value = value - self.identity = IdentityStruct(identifier: identifier) - } } diff --git a/FlagsmithClient/Classes/Traits.swift b/FlagsmithClient/Classes/Traits.swift index a6e41cb..da8d4ea 100644 --- a/FlagsmithClient/Classes/Traits.swift +++ b/FlagsmithClient/Classes/Traits.swift @@ -1,5 +1,5 @@ // -// Router.swift +// Traits.swift // FlagsmithClient // // Created by Rob Valdes on 07/02/23. @@ -8,8 +8,8 @@ import Foundation /** -A Traits object represent a collection of different `Trait`s stored against the same Identity (user) on the server. -*/ + A Traits object represent a collection of different `Trait`s stored against the same Identity (user) on the server. + */ public struct Traits: Codable, Sendable { public let traits: [Trait] public let identifier: String? diff --git a/FlagsmithClient/Classes/TypedValue.swift b/FlagsmithClient/Classes/TypedValue.swift index 5bd5794..bb6c223 100644 --- a/FlagsmithClient/Classes/TypedValue.swift +++ b/FlagsmithClient/Classes/TypedValue.swift @@ -9,111 +9,111 @@ import Foundation /// A value associated to a `Flag` or `Trait` public enum TypedValue: Equatable, Sendable { - case bool(Bool) - case float(Float) - case int(Int) - case string(String) - case null + case bool(Bool) + case float(Float) + case int(Int) + case string(String) + case null } extension TypedValue: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if let value = try? container.decode(Bool.self) { - self = .bool(value) - return - } - - if let value = try? container.decode(Int.self) { - self = .int(value) - return - } - - if let value = try? container.decode(Float.self) { - self = .float(value) - return - } - - if let value = try? container.decode(String.self) { - self = .string(value) - return - } - - if container.decodeNil() { - self = .null - return + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(Bool.self) { + self = .bool(value) + return + } + + if let value = try? container.decode(Int.self) { + self = .int(value) + return + } + + if let value = try? container.decode(Float.self) { + self = .float(value) + return + } + + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + + if container.decodeNil() { + self = .null + return + } + + let context = DecodingError.Context( + codingPath: [], + debugDescription: "No decodable `TypedValue` value found." + ) + throw DecodingError.valueNotFound(Decodable.self, context) } - - let context = DecodingError.Context( - codingPath: [], - debugDescription: "No decodable `TypedValue` value found." - ) - throw DecodingError.valueNotFound(Decodable.self, context) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .bool(let value): - try container.encode(value) - case .float(let value): - try container.encode(value) - case .int(let value): - try container.encode(value) - case .string(let value): - try container.encode(value) - case .null: - try container.encodeNil() + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .bool(value): + try container.encode(value) + case let .float(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case .null: + try container.encodeNil() + } } - } } extension TypedValue: CustomStringConvertible { - public var description: String { - switch self { - case .bool(let value): return "\(value)" - case .float(let value): return "\(value)" - case .int(let value): return "\(value)" - case .string(let value): return value - case .null: return "" + public var description: String { + switch self { + case let .bool(value): return "\(value)" + case let .float(value): return "\(value)" + case let .int(value): return "\(value)" + case let .string(value): return value + case .null: return "" + } } - } } // Provides backwards compatible API for `UnknownTypeValue` // (eg: `Flag.value.intValue?`, `Flag.value.stringValue?`, `Flag.value.floatValue?`) public extension TypedValue { - /// Attempts to cast the associated value as an `Int` - @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") - var intValue: Int? { - switch self { - case .bool(let value): return (value) ? 1 : 0 - case .float(let value): return Int(value) - case .int(let value): return value - case .string(let value): return Int(value) - case .null: return nil + /// Attempts to cast the associated value as an `Int` + @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") + var intValue: Int? { + switch self { + case let .bool(value): return value ? 1 : 0 + case let .float(value): return Int(value) + case let .int(value): return value + case let .string(value): return Int(value) + case .null: return nil + } } - } - - /// Attempts to cast the associated value as an `Float` - @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") - var floatValue: Float? { - switch self { - case .bool(let value): return (value) ? 1.0 : 0.0 - case .float(let value): return value - case .int(let value): return Float(value) - case .string(let value): return Float(value) - case .null: return nil + + /// Attempts to cast the associated value as an `Float` + @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") + var floatValue: Float? { + switch self { + case let .bool(value): return value ? 1.0 : 0.0 + case let .float(value): return value + case let .int(value): return Float(value) + case let .string(value): return Float(value) + case .null: return nil + } } - } - - /// Attempts to cast the associated value as an `String` - @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") - var stringValue: String? { - switch self { - case .null: return nil - default: return description + + /// Attempts to cast the associated value as an `String` + @available(*, deprecated, message: "Switch on `TypedValue` to retrieve the associated data type.") + var stringValue: String? { + switch self { + case .null: return nil + default: return description + } } - } } diff --git a/FlagsmithClient/Classes/UnknownTypeValue.swift b/FlagsmithClient/Classes/UnknownTypeValue.swift index 7ff2ed0..aebbb50 100644 --- a/FlagsmithClient/Classes/UnknownTypeValue.swift +++ b/FlagsmithClient/Classes/UnknownTypeValue.swift @@ -8,19 +8,18 @@ import Foundation /** -An UnknownTypeValue represents a value which can have a variable type -*/ + An UnknownTypeValue represents a value which can have a variable type + */ @available(*, deprecated, renamed: "TypedValue") public enum UnknownTypeValue: Decodable, Sendable { - case int(Int), string(String), float(Float), null - + public init(from decoder: Decoder) throws { if let int = try? decoder.singleValueContainer().decode(Int.self) { self = .int(int) return } - + if let string = try? decoder.singleValueContainer().decode(String.self) { self = .string(string) return @@ -33,34 +32,34 @@ public enum UnknownTypeValue: Decodable, Sendable { self = .null } - - public enum UnknownTypeError:Error { + + public enum UnknownTypeError: Error { case missingValue } - + public var intValue: Int? { switch self { - case .int(let value): return value - case .string(let value): return Int(value) - case .float(let value): return Int(value) + case let .int(value): return value + case let .string(value): return Int(value) + case let .float(value): return Int(value) case .null: return nil } } public var stringValue: String? { switch self { - case .int(let value): return String(value) - case .string(let value): return value - case .float(let value): return String(value) + case let .int(value): return String(value) + case let .string(value): return value + case let .float(value): return String(value) case .null: return nil } } public var floatValue: Float? { switch self { - case .int(let value): return Float(value) - case .string(let value): return Float(value) - case .float(let value): return value + case let .int(value): return Float(value) + case let .string(value): return Float(value) + case let .float(value): return value case .null: return nil } } diff --git a/FlagsmithClient/Tests/APIManagerTests.swift b/FlagsmithClient/Tests/APIManagerTests.swift index 435e74f..e69b7c4 100644 --- a/FlagsmithClient/Tests/APIManagerTests.swift +++ b/FlagsmithClient/Tests/APIManagerTests.swift @@ -5,87 +5,86 @@ // Created by Richard Piazza on 3/18/22. // -import XCTest @testable import FlagsmithClient +import XCTest final class APIManagerTests: FlagsmithClientTestCase { - let apiManager = APIManager() - + /// Verify that an invalid API key produces the expected error. func testInvalidAPIKey() throws { apiManager.apiKey = nil - + let requestFinished = expectation(description: "Request Finished") var error: FlagsmithError? - + apiManager.request(.getFlags) { (result: Result) in if case let .failure(e) = result { error = e as? FlagsmithError } - + requestFinished.fulfill() } - + wait(for: [requestFinished], timeout: 1.0) - + let flagsmithError = try XCTUnwrap(error) guard case .apiKey = flagsmithError else { XCTFail("Wrong Error") return } } - + /// Verify that an invalid API url produces the expected error. func testInvalidAPIURL() throws { apiManager.apiKey = "8D5ABC87-6BBF-4AE7-BC05-4DC1AFE770DF" apiManager.baseURL = URL(fileURLWithPath: "/dev/null") - + let requestFinished = expectation(description: "Request Finished") var error: FlagsmithError? - + apiManager.request(.getFlags) { (result: Result) in if case let .failure(e) = result { error = e as? FlagsmithError } - + requestFinished.fulfill() } - + wait(for: [requestFinished], timeout: 1.0) - + let flagsmithError = try XCTUnwrap(error) guard case .apiURL = flagsmithError else { XCTFail("Wrong Error") return } } - + func testConcurrentRequests() throws { apiManager.apiKey = "8D5ABC87-6BBF-4AE7-BC05-4DC1AFE770DF" let concurrentQueue = DispatchQueue(label: "concurrentQueue", attributes: .concurrent) - - var expectations:[XCTestExpectation] = []; + + var expectations: [XCTestExpectation] = [] let iterations = 500 var error: FlagsmithError? - - for concurrentIteration in 1...iterations { + + for concurrentIteration in 1 ... iterations { let expectation = XCTestExpectation(description: "Multiple threads can access the APIManager \(concurrentIteration)") expectations.append(expectation) concurrentQueue.async { self.apiManager.request(.getFlags) { (result: Result) in - if case let .failure(e) = result { - error = e as? FlagsmithError - } + if case let .failure(e) = result { + error = e as? FlagsmithError + } expectation.fulfill() } } } - + wait(for: expectations, timeout: 10) // Ensure that we didn't have any errors during the process XCTAssertTrue(error == nil) - + print("Finished!") } } diff --git a/FlagsmithClient/Tests/ComparableJson.swift b/FlagsmithClient/Tests/ComparableJson.swift index 15d85a7..c3564f0 100644 --- a/FlagsmithClient/Tests/ComparableJson.swift +++ b/FlagsmithClient/Tests/ComparableJson.swift @@ -9,7 +9,7 @@ import XCTest extension String { func json(using encoding: String.Encoding) throws -> NSDictionary { - return try self.data(using: encoding).json() + return try data(using: encoding).json() } } @@ -23,7 +23,7 @@ extension Optional where Wrapped == Data { extension Data { func json() throws -> NSDictionary { let json = try JSONSerialization.jsonObject(with: self) - let dict = json as! [String : Any] + let dict = json as! [String: Any] return NSDictionary(dictionary: dict) } } diff --git a/FlagsmithClient/Tests/FlagTests.swift b/FlagsmithClient/Tests/FlagTests.swift index 7763778..d60869b 100644 --- a/FlagsmithClient/Tests/FlagTests.swift +++ b/FlagsmithClient/Tests/FlagTests.swift @@ -5,11 +5,10 @@ // Created by Richard Piazza on 3/16/22. // -import XCTest @testable import FlagsmithClient +import XCTest final class FlagTests: FlagsmithClientTestCase { - func testDecodeFlags() throws { let json = """ [ @@ -31,18 +30,17 @@ final class FlagTests: FlagsmithClientTestCase { } ] """ - + let data = try XCTUnwrap(json.data(using: .utf8)) let flags = try decoder.decode([Flag].self, from: data) XCTAssertEqual(flags.count, 2) - - let enabledFlag = try XCTUnwrap(flags.first(where: { $0.enabled } )) + + let enabledFlag = try XCTUnwrap(flags.first(where: { $0.enabled })) XCTAssertEqual(enabledFlag.feature.name, "app_theme") XCTAssertEqual(enabledFlag.value, .int(4)) - - let disabledFlag = try XCTUnwrap(flags.first(where: { !$0.enabled } )) + + let disabledFlag = try XCTUnwrap(flags.first(where: { !$0.enabled })) XCTAssertEqual(disabledFlag.feature.name, "realtime_diagnostics_level") XCTAssertEqual(disabledFlag.value, .string("debug")) } - } diff --git a/FlagsmithClient/Tests/FlagsmithClientTestCase.swift b/FlagsmithClient/Tests/FlagsmithClientTestCase.swift index b247348..8f684f0 100644 --- a/FlagsmithClient/Tests/FlagsmithClientTestCase.swift +++ b/FlagsmithClient/Tests/FlagsmithClientTestCase.swift @@ -5,11 +5,10 @@ // Created by Richard Piazza on 3/16/22. // -import XCTest @testable import FlagsmithClient +import XCTest class FlagsmithClientTestCase: XCTestCase { - let encoder: JSONEncoder = .init() let decoder: JSONDecoder = .init() } diff --git a/FlagsmithClient/Tests/RouterTests.swift b/FlagsmithClient/Tests/RouterTests.swift index 1dcef82..4dc7f64 100644 --- a/FlagsmithClient/Tests/RouterTests.swift +++ b/FlagsmithClient/Tests/RouterTests.swift @@ -5,14 +5,13 @@ // Created by Richard Piazza on 3/21/22. // -import XCTest @testable import FlagsmithClient +import XCTest final class RouterTests: FlagsmithClientTestCase { - let baseUrl = URL(string: "https://edge.api.flagsmith.com/api/v1") let apiKey = "E71DC632-82BA-4522-82F3-D39FB6DC90AC" - + func testGetFlagsRequest() throws { let url = try XCTUnwrap(baseUrl) let route = Router.getFlags @@ -22,7 +21,7 @@ final class RouterTests: FlagsmithClientTestCase { XCTAssertTrue(request.allHTTPHeaderFields?.contains(where: { $0.key == "X-Environment-Key" }) ?? false) XCTAssertNil(request.httpBody) } - + func testGetIdentityRequest() throws { let url = try XCTUnwrap(baseUrl) let route = Router.getIdentity(identity: "6056BCBF") @@ -32,7 +31,7 @@ final class RouterTests: FlagsmithClientTestCase { XCTAssertTrue(request.allHTTPHeaderFields?.contains(where: { $0.key == "X-Environment-Key" }) ?? false) XCTAssertNil(request.httpBody) } - + func testPostTraitRequest() throws { let trait = Trait(key: "meaning_of_life", value: 42) let url = try XCTUnwrap(baseUrl) @@ -40,7 +39,7 @@ final class RouterTests: FlagsmithClientTestCase { let request = try route.request(baseUrl: url, apiKey: apiKey, using: encoder) XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.url?.absoluteString, "https://edge.api.flagsmith.com/api/v1/traits/") - + let json = try """ { "identity" : { @@ -51,7 +50,7 @@ final class RouterTests: FlagsmithClientTestCase { } """.json(using: .utf8) let body = try request.httpBody.json() - + XCTAssertEqual(body, json) } @@ -82,20 +81,20 @@ final class RouterTests: FlagsmithClientTestCase { let body = try request.httpBody.json() XCTAssertEqual(body, expectedJson) } - + func testPostAnalyticsRequest() throws { let events: [String: Int] = [ "one": 1, - "two": 2 + "two": 2, ] - + let url = try XCTUnwrap(baseUrl) let route = Router.postAnalytics(events: events) let request = try route.request(baseUrl: url, apiKey: apiKey, using: encoder) - + XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.url?.absoluteString, "https://edge.api.flagsmith.com/api/v1/analytics/flags/") - + let json = try """ { "one" : 1, diff --git a/FlagsmithClient/Tests/TraitTests.swift b/FlagsmithClient/Tests/TraitTests.swift index 6b38463..c26bff0 100644 --- a/FlagsmithClient/Tests/TraitTests.swift +++ b/FlagsmithClient/Tests/TraitTests.swift @@ -5,12 +5,11 @@ // Created by Richard Piazza on 3/16/22. // -import XCTest @testable import FlagsmithClient +import XCTest /// Tests `Trait` final class TraitTests: FlagsmithClientTestCase { - func testDecodeTraits() throws { let json = """ [ @@ -36,27 +35,27 @@ final class TraitTests: FlagsmithClientTestCase { } ] """ - + let data = try XCTUnwrap(json.data(using: .utf8)) let traits = try decoder.decode([Trait].self, from: data) XCTAssertEqual(traits.count, 5) - + let boolTrait = try XCTUnwrap(traits.first(where: { $0.key == "is_orange" })) XCTAssertEqual(boolTrait.typedValue, .bool(false)) - + let floatTrait = try XCTUnwrap(traits.first(where: { $0.key == "pi" })) XCTAssertEqual(floatTrait.typedValue, .float(3.14)) - + let intTrait = try XCTUnwrap(traits.first(where: { $0.key == "miles_per_hour" })) XCTAssertEqual(intTrait.typedValue, .int(88)) - + let stringTrait = try XCTUnwrap(traits.first(where: { $0.key == "message" })) XCTAssertEqual(stringTrait.typedValue, .string("Welcome")) - + let nullTrait = try XCTUnwrap(traits.first(where: { $0.key == "deprecated" })) XCTAssertEqual(nullTrait.typedValue, .null) } - + func testEncodeTraits() throws { let wrappedTrait = Trait(key: "dark_mode", value: .bool(true)) let trait = Trait(trait: wrappedTrait, identifier: "theme_settings") diff --git a/FlagsmithClient/Tests/TypedValueTests.swift b/FlagsmithClient/Tests/TypedValueTests.swift index 388c304..a5f60aa 100644 --- a/FlagsmithClient/Tests/TypedValueTests.swift +++ b/FlagsmithClient/Tests/TypedValueTests.swift @@ -5,68 +5,67 @@ // Created by Richard Piazza on 3/16/22. // -import XCTest @testable import FlagsmithClient +import XCTest /// Tests `TypedValue` final class TypedValueTests: FlagsmithClientTestCase { - func testDecodeBool() throws { let json = "true" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .bool(true)) } - + func testDecodeFloat() throws { let json = "3.14" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .float(3.14)) } - + func testDecodeInt() throws { let json = "47" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .int(47)) } - + func testDecodeString() throws { let json = "\"DarkMode\"" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .string("DarkMode")) } - + func testDecodeNull() throws { let json = "null" let data = try XCTUnwrap(json.data(using: .utf8)) let typedValue = try decoder.decode(TypedValue.self, from: data) XCTAssertEqual(typedValue, .null) } - + func testEncodeBool() throws { let typedValue: TypedValue = .bool(false) let data = try encoder.encode(typedValue) let json = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertEqual(json, "false") } - + func testEncodeFloat() throws { let typedValue: TypedValue = .float(1.888) let data = try encoder.encode(typedValue) let json = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertTrue(json.hasPrefix("1.888")) } - + func testEncodeInt() throws { let typedValue: TypedValue = .int(88) let data = try encoder.encode(typedValue) let json = try XCTUnwrap(String(data: data, encoding: .utf8)) XCTAssertEqual(json, "88") } - + func testEncodeString() throws { let typedValue: TypedValue = .string("iOS 15.4") let data = try encoder.encode(typedValue) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..92cd757 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,97 @@ +{ + "object": { + "pins": [ + { + "package": "CollectionConcurrencyKit", + "repositoryURL": "https://github.com/JohnSundell/CollectionConcurrencyKit.git", + "state": { + "branch": null, + "revision": "b4f23e24b5a1bff301efc5e70871083ca029ff95", + "version": "0.2.0" + } + }, + { + "package": "CryptoSwift", + "repositoryURL": "https://github.com/krzyzanowskim/CryptoSwift.git", + "state": { + "branch": null, + "revision": "c9c3df6ab812de32bae61fc0cd1bf6d45170ebf0", + "version": "1.8.2" + } + }, + { + "package": "SourceKitten", + "repositoryURL": "https://github.com/jpsim/SourceKitten.git", + "state": { + "branch": null, + "revision": "b6dc09ee51dfb0c66e042d2328c017483a1a5d56", + "version": "0.34.1" + } + }, + { + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser.git", + "state": { + "branch": null, + "revision": "8f4d2753f0e4778c76d5f05ad16c74f707390531", + "version": "1.2.3" + } + }, + { + "package": "swift-syntax", + "repositoryURL": "https://github.com/apple/swift-syntax.git", + "state": { + "branch": null, + "revision": "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version": "509.0.2" + } + }, + { + "package": "SwiftFormat", + "repositoryURL": "https://github.com/nicklockwood/SwiftFormat", + "state": { + "branch": null, + "revision": "ab238886b8b50f8b678b251f3c28c0c887305407", + "version": "0.53.8" + } + }, + { + "package": "SwiftLint", + "repositoryURL": "https://github.com/realm/SwiftLint.git", + "state": { + "branch": null, + "revision": "f17a4f9dfb6a6afb0408426354e4180daaf49cee", + "version": "0.54.0" + } + }, + { + "package": "SwiftyTextTable", + "repositoryURL": "https://github.com/scottrhoyt/SwiftyTextTable.git", + "state": { + "branch": null, + "revision": "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", + "version": "0.9.0" + } + }, + { + "package": "SWXMLHash", + "repositoryURL": "https://github.com/drmohundro/SWXMLHash.git", + "state": { + "branch": null, + "revision": "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version": "7.0.2" + } + }, + { + "package": "Yams", + "repositoryURL": "https://github.com/jpsim/Yams.git", + "state": { + "branch": null, + "revision": "9234124cff5e22e178988c18d8b95a8ae8007f76", + "version": "5.1.2" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index 2b56322..2d52b66 100644 --- a/Package.swift +++ b/Package.swift @@ -8,18 +8,25 @@ let package = Package( .library(name: "FlagsmithClient", targets: ["FlagsmithClient"]), ], dependencies: [ - .package(url: "https://github.com/realm/SwiftLint.git", from: "0.54.0") + .package(url: "https://github.com/realm/SwiftLint.git", from: "0.54.0"), + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.8"), ], targets: [ .target( name: "FlagsmithClient", dependencies: [], - path: "FlagsmithClient/Classes", - plugins: [ - .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")]), + path: "FlagsmithClient/Classes" + // plugins: [ + // .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")] + ), .testTarget( name: "FlagsmitClientTests", dependencies: ["FlagsmithClient"], path: "FlagsmithClient/Tests"), + // .binaryTarget( + // name: "swiftformat", + // url: "https://github.com/nicklockwood/SwiftFormat/releases/download/0.53.8/swiftformat.artifactbundle.zip", + // checksum: "12c4cd6e1382479cd38bba63c81eb50121f9b2212a8b1f8f5fa9ed1d1c6d07d1" + // ), ] )