diff --git a/.gitignore b/.gitignore index f9658ead..7813b9a7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,7 @@ /**/*.xcodeproj xcuserdata/ DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.swiftpm/ .netrc .build .mockingbird diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift index ea43e68a..da49bcd6 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift @@ -1,45 +1,46 @@ -import ConfidenceProvider import Confidence -import OpenFeature import SwiftUI +class Status: ObservableObject { + enum State { + case unknown + case ready + case error(Error?) + } + + @Published var state: State = .unknown +} + + @main struct ConfidenceDemoApp: App { var body: some Scene { WindowGroup { - ContentView() - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - self.setup() + let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] ?? "" + let confidence = Confidence.Builder(clientSecret: secret) + .withContext(initialContext: ["targeting_key": ConfidenceValue(string: UUID.init().uuidString)]) + .withRegion(region: .europe) + .build() + + let status = Status() + + ContentView(confidence: confidence, status: status) + .task { + do { + try await self.setup(confidence: confidence) + status.state = .ready + } catch { + status.state = .error(error) + print(error.localizedDescription) + } } } } } extension ConfidenceDemoApp { - func setup() { - guard let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] else { - return - } - - // If we have no cache, then do a fetch first. - var initializationStrategy: InitializationStrategy = .activateAndFetchAsync - if ConfidenceFeatureProvider.isStorageEmpty() { - initializationStrategy = .fetchAndActivate - } - - let confidence = Confidence.Builder(clientSecret: secret) - .withRegion(region: .europe) - .withInitializationstrategy(initializationStrategy: initializationStrategy) - .build() - let provider = ConfidenceFeatureProvider(confidence: confidence) - - // NOTE: Using a random UUID for each app start is not advised and can result in getting stale values. - let ctx = MutableContext( - targetingKey: UUID.init().uuidString, - structure: MutableStructure.init(attributes: ["country": .string("SE")])) - Task { - await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: ctx) - } + func setup(confidence: Confidence) async throws { + try await confidence.fetchAndActivate() confidence.track( eventName: "all-types", message: [ diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift index 1c75fb32..c4ed16ae 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift @@ -1,12 +1,19 @@ import SwiftUI -import OpenFeature +import Confidence import Combine struct ContentView: View { - @StateObject var status = Status() + @ObservedObject var status: Status @StateObject var text = DisplayText() @StateObject var color = FlagColor() + private let confidence: Confidence + + init(confidence: Confidence, status: Status) { + self.confidence = confidence + self.status = status + } + var body: some View { if case .ready = status.state { VStack { @@ -16,10 +23,7 @@ struct ContentView: View { .padding(10) Text(text.text) Button("Get remote flag value") { - text.text = OpenFeatureAPI - .shared - .getClient() - .getStringValue(key: "swift-demoapp.color", defaultValue: "ERROR") + text.text = confidence.getValue(flagName: "swift-demoapp.color", defaultValue: "ERROR") if text.text == "Green" { color.color = .green } else if text.text == "Yellow" { @@ -44,33 +48,6 @@ struct ContentView: View { } } -class Status: ObservableObject { - enum State { - case unknown - case ready - case error(Error?) - } - - var cancellable: AnyCancellable? - - @Published var state: State = .unknown - - init() { - cancellable = OpenFeatureAPI.shared.observe().sink { [weak self] event in - if event == .ready { - DispatchQueue.main.async { - self?.state = .ready - } - } - if event == .error { - DispatchQueue.main.async { - self?.state = .error(nil) - } - } - } - } -} - class DisplayText: ObservableObject { @Published var text = "Hello World!" } diff --git a/Package.swift b/Package.swift index 1f26e397..6c98fbc8 100644 --- a/Package.swift +++ b/Package.swift @@ -21,26 +21,16 @@ let package = Package( .package(url: "git@github.com:open-feature/swift-sdk.git", from: "0.1.0"), ], targets: [ - // Internal definitions shared between Confidence and ConfidenceProvider - // These are not exposed to the consumers of Confidence or ConfidenceProvider - .target( - name: "Common", - dependencies: [], - plugins: [] - ), .target( name: "Confidence", - dependencies: [ - "Common" - ], + dependencies: [], plugins: [] ), .target( name: "ConfidenceProvider", dependencies: [ .product(name: "OpenFeature", package: "swift-sdk"), - "Confidence", - "Common" + "Confidence" ], plugins: [] ), @@ -48,7 +38,6 @@ let package = Package( name: "ConfidenceProviderTests", dependencies: [ "ConfidenceProvider", - "Common", ] ), .testTarget( diff --git a/Sources/Common/Sdk.swift b/Sources/Common/Sdk.swift deleted file mode 100644 index c33bc291..00000000 --- a/Sources/Common/Sdk.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -public struct Sdk: Codable { - public init(id: String?, version: String?) { - self.id = id ?? "SDK_ID_SWIFT_PROVIDER" - self.version = version ?? "unknown" - } - - var id: String - var version: String -} diff --git a/Sources/ConfidenceProvider/Cache/Models/CacheData.swift b/Sources/Confidence/Apply/CacheData.swift similarity index 100% rename from Sources/ConfidenceProvider/Cache/Models/CacheData.swift rename to Sources/Confidence/Apply/CacheData.swift diff --git a/Sources/ConfidenceProvider/Cache/CacheDataActor.swift b/Sources/Confidence/Apply/CacheDataActor.swift similarity index 100% rename from Sources/ConfidenceProvider/Cache/CacheDataActor.swift rename to Sources/Confidence/Apply/CacheDataActor.swift diff --git a/Sources/ConfidenceProvider/Cache/CacheDataInteractor.swift b/Sources/Confidence/Apply/CacheDataInteractor.swift similarity index 100% rename from Sources/ConfidenceProvider/Cache/CacheDataInteractor.swift rename to Sources/Confidence/Apply/CacheDataInteractor.swift diff --git a/Sources/ConfidenceProvider/Apply/FlagApplier.swift b/Sources/Confidence/Apply/FlagApplier.swift similarity index 100% rename from Sources/ConfidenceProvider/Apply/FlagApplier.swift rename to Sources/Confidence/Apply/FlagApplier.swift diff --git a/Sources/ConfidenceProvider/Apply/FlagApplierWithRetries.swift b/Sources/Confidence/Apply/FlagApplierWithRetries.swift similarity index 98% rename from Sources/ConfidenceProvider/Apply/FlagApplierWithRetries.swift rename to Sources/Confidence/Apply/FlagApplierWithRetries.swift index cd450c80..306f50ec 100644 --- a/Sources/ConfidenceProvider/Apply/FlagApplierWithRetries.swift +++ b/Sources/Confidence/Apply/FlagApplierWithRetries.swift @@ -1,7 +1,4 @@ import Foundation -import Common -import Confidence -import OpenFeature import os typealias ApplyFlagHTTPResponse = HttpClientResponse @@ -145,7 +142,7 @@ final class FlagApplierWithRetries: FlagApplier { } private func handleError(error: Error) -> Error { - if error is ConfidenceError || error is OpenFeatureError { + if error is ConfidenceError { return error } else { return ConfidenceError.grpcError(message: "\(error)") diff --git a/Sources/ConfidenceProvider/Cache/Models/FlagApply.swift b/Sources/Confidence/Apply/FlagApply.swift similarity index 100% rename from Sources/ConfidenceProvider/Cache/Models/FlagApply.swift rename to Sources/Confidence/Apply/FlagApply.swift diff --git a/Sources/ConfidenceProvider/Cache/Models/ApplyEventStatus.swift b/Sources/Confidence/Apply/FlagApplyStatus.swift similarity index 100% rename from Sources/ConfidenceProvider/Cache/Models/ApplyEventStatus.swift rename to Sources/Confidence/Apply/FlagApplyStatus.swift diff --git a/Sources/ConfidenceProvider/Cache/Models/ResolveApply.swift b/Sources/Confidence/Apply/ResolveApply.swift similarity index 100% rename from Sources/ConfidenceProvider/Cache/Models/ResolveApply.swift rename to Sources/Confidence/Apply/ResolveApply.swift diff --git a/Sources/ConfidenceProvider/Utils/Array+Chunks.swift b/Sources/Confidence/Array+Chunks.swift similarity index 100% rename from Sources/ConfidenceProvider/Utils/Array+Chunks.swift rename to Sources/Confidence/Array+Chunks.swift diff --git a/Sources/Common/Backport.swift b/Sources/Confidence/Backport.swift similarity index 100% rename from Sources/Common/Backport.swift rename to Sources/Confidence/Backport.swift diff --git a/Sources/ConfidenceProvider/Utils/BaseUrlMapper.swift b/Sources/Confidence/BaseUrlMapper.swift similarity index 95% rename from Sources/ConfidenceProvider/Utils/BaseUrlMapper.swift rename to Sources/Confidence/BaseUrlMapper.swift index 9f3da939..44c43a68 100644 --- a/Sources/ConfidenceProvider/Utils/BaseUrlMapper.swift +++ b/Sources/Confidence/BaseUrlMapper.swift @@ -1,5 +1,4 @@ import Foundation -import Confidence public enum BaseUrlMapper { static func from(region: ConfidenceRegion) -> String { diff --git a/Sources/Common/CaseIterableDefaultsLast.swift b/Sources/Confidence/CaseIterableDefaultsLast.swift similarity index 100% rename from Sources/Common/CaseIterableDefaultsLast.swift rename to Sources/Confidence/CaseIterableDefaultsLast.swift diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index 1ef5aeb6..18e2f206 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -4,19 +4,26 @@ import Combine public class Confidence: ConfidenceEventSender { public let clientSecret: String public var region: ConfidenceRegion - public var initializationStrategy: InitializationStrategy private let parent: ConfidenceContextProvider? private let eventSenderEngine: EventSenderEngine - private let contextFlow = CurrentValueSubject([:]) + private let contextSubject = CurrentValueSubject([:]) private var removedContextKeys: Set = Set() private let confidenceQueue = DispatchQueue(label: "com.confidence.queue") + private let remoteFlagResolver: ConfidenceResolveClient + private let flagApplier: FlagApplier + private var cache = FlagResolution.EMPTY + private var storage: Storage + internal let contextReconciliatedChanges = PassthroughSubject() + private var cancellables = Set() + private var currentFetchTask: Task<(), Never>? - /// Internal, the hosting app should use Confidence.Builder instead required init( clientSecret: String, region: ConfidenceRegion, eventSenderEngine: EventSenderEngine, - initializationStrategy: InitializationStrategy, + flagApplier: FlagApplier, + remoteFlagResolver: ConfidenceResolveClient, + storage: Storage, context: ConfidenceStruct = [:], parent: ConfidenceEventSender? = nil, visitorId: String? = nil @@ -24,26 +31,96 @@ public class Confidence: ConfidenceEventSender { self.eventSenderEngine = eventSenderEngine self.clientSecret = clientSecret self.region = region - self.initializationStrategy = initializationStrategy - self.contextFlow.value = context + self.storage = storage + self.contextSubject.value = context self.parent = parent + self.storage = storage + self.flagApplier = flagApplier + self.remoteFlagResolver = remoteFlagResolver if let visitorId { putContext(context: ["visitor_id": ConfidenceValue.init(string: visitorId)]) } + + contextChanges().sink { [weak self] context in + guard let self = self else { + return + } + self.currentFetchTask?.cancel() + self.currentFetchTask = Task { + do { + let context = self.getContext() + try await self.fetchAndActivate() + self.contextReconciliatedChanges.send(context.hash()) + } catch { + } + } + } + .store(in: &cancellables) } - public func track(eventName: String, message: ConfidenceStruct) { - eventSenderEngine.emit(eventName: eventName, message: message, context: getContext()) + public func activate() throws { + let savedFlags = try storage.load(defaultValue: FlagResolution.EMPTY) + self.cache = savedFlags + } + + public func fetchAndActivate() async throws { + try await internalFetch() + try activate() + } + + func internalFetch() async throws { + let context = getContext() + let resolvedFlags = try await remoteFlagResolver.resolve(ctx: context) + let resolution = FlagResolution( + context: context, + flags: resolvedFlags.resolvedValues, + resolveToken: resolvedFlags.resolveToken ?? "" + ) + try storage.save(data: resolution) + } + + public func asyncFetch() { + Task { + try await internalFetch() + } + } + + public func getEvaluation(key: String, defaultValue: T) throws -> Evaluation { + try self.cache.evaluate( + flagName: key, + defaultValue: defaultValue, + context: getContext(), + flagApplier: flagApplier + ) + } + + public func getValue(key: String, defaultValue: T) -> T { + do { + return try getEvaluation(key: key, defaultValue: defaultValue).value + } catch { + return defaultValue + } + } + + func isStorageEmpty() -> Bool { + return storage.isEmpty() } - /// Allows to observe changes in the Context, not meant to be used directly by the hosting app public func contextChanges() -> AnyPublisher { - return contextFlow + return contextSubject .dropFirst() .removeDuplicates() .eraseToAnyPublisher() } + public func track(eventName: String, message: ConfidenceStruct) { + eventSenderEngine.emit( + eventName: eventName, + message: message, + context: getContext() + ) + } + private func withLock(callback: @escaping (Confidence) -> Void) { confidenceQueue.sync { [weak self] in guard let self = self else { @@ -58,7 +135,7 @@ public class Confidence: ConfidenceEventSender { var reconciledCtx = parentContext.filter { !removedContextKeys.contains($0.key) } - self.contextFlow.value.forEach { entry in + self.contextSubject.value.forEach { entry in reconciledCtx.updateValue(entry.value, forKey: entry.key) } return reconciledCtx @@ -66,40 +143,40 @@ public class Confidence: ConfidenceEventSender { public func putContext(key: String, value: ConfidenceValue) { withLock { confidence in - var map = confidence.contextFlow.value + var map = confidence.contextSubject.value map[key] = value - confidence.contextFlow.value = map + confidence.contextSubject.value = map } } - private func putContext(context: ConfidenceStruct) { + public func putContext(context: ConfidenceStruct) { withLock { confidence in - var map = confidence.contextFlow.value + var map = confidence.contextSubject.value for entry in context { map.updateValue(entry.value, forKey: entry.key) } - confidence.contextFlow.value = map + confidence.contextSubject.value = map } } - public func putContext(context: ConfidenceStruct, removeKeys: [String] = []) { + public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { withLock { confidence in - var map = confidence.contextFlow.value - for removedKey in removeKeys { + var map = confidence.contextSubject.value + for removedKey in removedKeys { map.removeValue(forKey: removedKey) } for entry in context { map.updateValue(entry.value, forKey: entry.key) } - confidence.contextFlow.value = map + confidence.contextSubject.value = map } } public func removeKey(key: String) { withLock { confidence in - var map = confidence.contextFlow.value + var map = confidence.contextSubject.value map.removeValue(forKey: key) - confidence.contextFlow.value = map + confidence.contextSubject.value = map confidence.removedContextKeys.insert(key) } } @@ -109,23 +186,29 @@ public class Confidence: ConfidenceEventSender { clientSecret: clientSecret, region: region, eventSenderEngine: eventSenderEngine, - initializationStrategy: initializationStrategy, + flagApplier: flagApplier, + remoteFlagResolver: remoteFlagResolver, + storage: storage, context: context, parent: self) } } -// MARK: Builder - extension Confidence { public class Builder { let clientSecret: String + internal var flagApplier: FlagApplier? + internal var storage: Storage? + internal let eventStorage: EventStorage + internal var flagResolver: ConfidenceResolveClient? var region: ConfidenceRegion = .global - var initializationStrategy: InitializationStrategy = .fetchAndActivate - let eventStorage: EventStorage + var visitorId: String? + var initialContext: ConfidenceStruct = [:] - /// Initializes the builder with the given credentails. + /** + Initializes the builder with the given credentails. + */ public init(clientSecret: String) { self.clientSecret = clientSecret do { @@ -135,6 +218,27 @@ extension Confidence { } } + internal func withFlagResolverClient(flagResolver: ConfidenceResolveClient) -> Builder { + self.flagResolver = flagResolver + return self + } + + + internal func withFlagApplier(flagApplier: FlagApplier) -> Builder { + self.flagApplier = flagApplier + return self + } + + internal func withStorage(storage: Storage) -> Builder { + self.storage = storage + return self + } + + public func withContext(initialContext: ConfidenceStruct) -> Builder { + self.initialContext = initialContext + return self + } + /** Sets the region for the network request to the Confidence backend. The default is `global` and the requests are automatically routed to the closest server. @@ -144,14 +248,6 @@ extension Confidence { return self } - /** - Flag resolve configuration related to how to refresh flags at startup - */ - public func withInitializationstrategy(initializationStrategy: InitializationStrategy) -> Builder { - self.initializationStrategy = initializationStrategy - return self - } - /** The SDK attaches a unique identifier to the Context, which is persisted across restarts of the App but re-generated on every new install @@ -162,13 +258,27 @@ extension Confidence { } public func build() -> Confidence { + let options = ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: clientSecret), + region: region) + let metadata = ConfidenceMetadata( + name: "SDK_ID_SWIFT_CONFIDENCE", + version: "0.1.4") // x-release-please-version let uploader = RemoteConfidenceClient( - options: ConfidenceClientOptions( - credentials: ConfidenceClientCredentials.clientSecret(secret: clientSecret), - region: region), - metadata: ConfidenceMetadata( - name: "SDK_ID_SWIFT_CONFIDENCE", - version: "0.1.4") // x-release-please-version + options: options, + metadata: metadata + ) + let httpClient = NetworkClient(baseUrl: BaseUrlMapper.from(region: options.region)) + let flagApplier = flagApplier ?? FlagApplierWithRetries( + httpClient: httpClient, + storage: DefaultStorage(filePath: "confidence.flags.apply"), + options: options, + metadata: metadata + ) + let flagResolver = flagResolver ?? RemoteConfidenceResolveClient( + options: options, + applyOnResolve: false, + metadata: metadata ) let eventSenderEngine = EventSenderEngineImpl( clientSecret: clientSecret, @@ -179,8 +289,10 @@ extension Confidence { clientSecret: clientSecret, region: region, eventSenderEngine: eventSenderEngine, - initializationStrategy: initializationStrategy, - context: [:], + flagApplier: flagApplier, + remoteFlagResolver: flagResolver, + storage: storage ?? DefaultStorage(filePath: "confidence.flags.resolve"), + context: initialContext, parent: nil, visitorId: visitorId ) diff --git a/Sources/Confidence/ConfidenceClient.swift b/Sources/Confidence/ConfidenceClient.swift new file mode 100644 index 00000000..45f91428 --- /dev/null +++ b/Sources/Confidence/ConfidenceClient.swift @@ -0,0 +1,23 @@ +import Foundation + +protocol ConfidenceClient { + // Returns true if the batch has been correctly processed by the backend + func upload(events: [NetworkEvent]) async throws -> Bool +} + +protocol ConfidenceResolveClient { + // Async + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult +} + +struct ResolvedValue: Codable, Equatable { + var variant: String? + var value: ConfidenceValue? + var flag: String + var resolveReason: ResolveReason +} + +public struct ResolvesResult: Codable, Equatable { + var resolvedValues: [ResolvedValue] + var resolveToken: String? +} diff --git a/Sources/Confidence/ConfidenceClient/ConfidenceClient.swift b/Sources/Confidence/ConfidenceClient/ConfidenceClient.swift deleted file mode 100644 index cec60f45..00000000 --- a/Sources/Confidence/ConfidenceClient/ConfidenceClient.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation -import Common - -protocol ConfidenceClient { - // Returns true if the batch has been correctly processed by the backend - func upload(events: [NetworkEvent]) async throws -> Bool -} diff --git a/Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift b/Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift deleted file mode 100644 index 498148c1..00000000 --- a/Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -public struct ConfidenceClientOptions { - public var credentials: ConfidenceClientCredentials - public var timeout: TimeInterval - public var region: ConfidenceRegion - - public init( - credentials: ConfidenceClientCredentials, - timeout: TimeInterval? = nil, - region: ConfidenceRegion? = nil - ) { - self.credentials = credentials - self.timeout = timeout ?? 10.0 - self.region = region ?? .global - } -} - -public enum ConfidenceClientCredentials { - case clientSecret(secret: String) - - public func getSecret() -> String { - switch self { - case .clientSecret(let secret): - return secret - } - } -} diff --git a/Sources/Confidence/ConfidenceClient/NetworkTypeMapper.swift b/Sources/Confidence/ConfidenceClient/NetworkTypeMapper.swift deleted file mode 100644 index 04237a1f..00000000 --- a/Sources/Confidence/ConfidenceClient/NetworkTypeMapper.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import Common - -public enum NetworkTypeMapper { - public static func from(value: ConfidenceStruct) throws -> NetworkStruct { - NetworkStruct(fields: try value.compactMapValues(convertValue)) - } - - // swiftlint:disable:next cyclomatic_complexity - public static func convertValue(_ value: ConfidenceValue) throws -> NetworkValue? { - switch value.type() { - case .boolean: - guard let value = value.asBoolean() else { - return nil - } - return NetworkValue.boolean(value) - case .string: - guard let value = value.asString() else { - return nil - } - return NetworkValue.string(value) - case .integer: - guard let value = value.asInteger() else { - return nil - } - return NetworkValue.number(Double(value)) - case .double: - guard let value = value.asDouble() else { - return nil - } - return NetworkValue.number(value) - case .date: - let dateFormatter = ISO8601DateFormatter() - dateFormatter.timeZone = TimeZone.current - dateFormatter.formatOptions = [.withFullDate] - guard let value = value.asDateComponents(), let dateString = Calendar.current.date(from: value) else { - throw ConfidenceError.internalError(message: "Could not create date from components") - } - return NetworkValue.string(dateFormatter.string(from: dateString)) - case .timestamp: - guard let value = value.asDate() else { - return nil - } - let timestampFormatter = ISO8601DateFormatter() - timestampFormatter.timeZone = TimeZone.init(identifier: "UTC") - let timestamp = timestampFormatter.string(from: value) - return NetworkValue.string(timestamp) - case .list: - guard let value = value.asList() else { - return nil - } - return try NetworkValue.list(value.compactMap(convertValue)) - case .structure: - guard let value = value.asStructure() else { - return nil - } - return try NetworkValue.structure(NetworkStruct(fields: value.compactMapValues(convertValue))) - case .null: - return nil - } - } -} diff --git a/Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClientOptions.swift b/Sources/Confidence/ConfidenceClientOptions.swift similarity index 85% rename from Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClientOptions.swift rename to Sources/Confidence/ConfidenceClientOptions.swift index 4c8de133..5515c12d 100644 --- a/Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClientOptions.swift +++ b/Sources/Confidence/ConfidenceClientOptions.swift @@ -1,20 +1,16 @@ import Foundation -import Confidence public struct ConfidenceClientOptions { public var credentials: ConfidenceClientCredentials - public var timeout: TimeInterval public var region: ConfidenceRegion public var initializationStrategy: InitializationStrategy public init( credentials: ConfidenceClientCredentials, - timeout: TimeInterval? = nil, region: ConfidenceRegion? = nil, initializationStrategy: InitializationStrategy = .fetchAndActivate ) { self.credentials = credentials - self.timeout = timeout ?? 10.0 self.region = region ?? .global self.initializationStrategy = initializationStrategy } diff --git a/Sources/Common/ConfidenceError.swift b/Sources/Confidence/ConfidenceError.swift similarity index 85% rename from Sources/Common/ConfidenceError.swift rename to Sources/Confidence/ConfidenceError.swift index a523fe4d..e26ebda9 100644 --- a/Sources/Common/ConfidenceError.swift +++ b/Sources/Confidence/ConfidenceError.swift @@ -11,6 +11,7 @@ public enum ConfidenceError: Error, Equatable { case corruptedCache(message: String) /// Flag not found in cache case flagNotFoundInCache + case flagNotFoundError(key: String) /// Value in cache expired case cachedValueExpired /// Apply state transition not allowed @@ -23,6 +24,8 @@ public enum ConfidenceError: Error, Equatable { case badRequest(message: String?) /// Internal error case internalError(message: String) + case parseError(message: String) + case invalidContextError } extension ConfidenceError: CustomStringConvertible { @@ -53,6 +56,12 @@ extension ConfidenceError: CustomStringConvertible { return "Bad request from provider: \(message)" case .internalError(let message): return "An internal error occurred: \(message)" + case .parseError(let message): + return "Parse error occurred: \(message)" + case .flagNotFoundError(let key): + return "Flag not found for key \(key)" + case .invalidContextError: + return "Invalid context error" } } } diff --git a/Sources/Confidence/ConfidenceMetadata.swift b/Sources/Confidence/ConfidenceMetadata.swift index 8a51792a..f2f8c880 100644 --- a/Sources/Confidence/ConfidenceMetadata.swift +++ b/Sources/Confidence/ConfidenceMetadata.swift @@ -1,6 +1,11 @@ import Foundation -struct ConfidenceMetadata { +public struct ConfidenceMetadata { public var name: String public var version: String + + public init(name: String, version: String) { + self.name = name + self.version = version + } } diff --git a/Sources/Confidence/ConfidenceValue.swift b/Sources/Confidence/ConfidenceValue.swift index 01b399a3..7deaeb6f 100644 --- a/Sources/Confidence/ConfidenceValue.swift +++ b/Sources/Confidence/ConfidenceValue.swift @@ -1,5 +1,4 @@ import Foundation -import Common public typealias ConfidenceStruct = [String: ConfidenceValue] @@ -9,6 +8,23 @@ public class ConfidenceValue: Equatable, Codable, CustomStringConvertible { return value.description } + init(from networkValue: NetworkValue) { + switch networkValue { + case .boolean(let value): + self.value = .boolean(value) + case .string(let value): + self.value = .string(value) + case .number(let value): + self.value = .double(value) + case .list(let values): + self.value = .list(values.map { value in ConfidenceValue(from: value).value }) + case .structure(let map): + self.value = .structure(map.fields.mapValues { entryValue in ConfidenceValue(from: entryValue).value }) + case .null: + self.value = .null + } + } + public required init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() self.value = try container.decode(ConfidenceValueInternal.self) @@ -22,7 +38,7 @@ public class ConfidenceValue: Equatable, Codable, CustomStringConvertible { self.value = .string(string) } - public init(integer: Int64) { + public init(integer: Int) { self.value = .integer(integer) } @@ -52,8 +68,11 @@ public class ConfidenceValue: Equatable, Codable, CustomStringConvertible { self.value = .list(stringList.map { .string($0) }) } + internal init(list: [ConfidenceValue]) { + self.value = .list(list.map { $0.value }) + } - public init(integerList: [Int64]) { + public init(integerList: [Int]) { self.value = .list(integerList.map { .integer($0) }) } @@ -101,9 +120,9 @@ public class ConfidenceValue: Equatable, Codable, CustomStringConvertible { return nil } - public func asInteger() -> Int64? { - if case let .integer(int64) = value { - return int64 + public func asInteger() -> Int? { + if case let .integer(int) = value { + return int } return nil @@ -208,7 +227,7 @@ public enum ConfidenceValueType: CaseIterable { private enum ConfidenceValueInternal: Equatable, Codable { case boolean(Bool) case string(String) - case integer(Int64) + case integer(Int) case double(Double) case date(DateComponents) case timestamp(Date) diff --git a/Sources/Common/DefaultStorage.swift b/Sources/Confidence/DefaultStorage.swift similarity index 100% rename from Sources/Common/DefaultStorage.swift rename to Sources/Confidence/DefaultStorage.swift diff --git a/Sources/Confidence/EventSenderEngine.swift b/Sources/Confidence/EventSenderEngine.swift index 3092c6db..11ccac5d 100644 --- a/Sources/Confidence/EventSenderEngine.swift +++ b/Sources/Confidence/EventSenderEngine.swift @@ -1,5 +1,4 @@ import Combine -import Common import Foundation protocol FlushPolicy { @@ -60,12 +59,9 @@ final class EventSenderEngineImpl: EventSenderEngine { for id in ids { let events: [NetworkEvent] = try self.storage.eventsFrom(id: id) .compactMap { event in - let networkPayload = event.payload.compactMapValues { payloadValue in - try? NetworkTypeMapper.convertValue(payloadValue) - } return NetworkEvent( eventDefinition: event.name, - payload: NetworkStruct(fields: networkPayload), + payload: NetworkStruct(fields: TypeMapper.convert(structure: event.payload).fields), eventTime: Date.backport.toISOString(date: event.eventTime)) } let shouldCleanup = try await self.uploader.upload(events: events) diff --git a/Sources/Confidence/EventStorage.swift b/Sources/Confidence/EventStorage.swift index fd492f2b..a5c83b4f 100644 --- a/Sources/Confidence/EventStorage.swift +++ b/Sources/Confidence/EventStorage.swift @@ -1,5 +1,4 @@ import Foundation -import Common import os struct ConfidenceEvent: Codable { diff --git a/Sources/Confidence/FlagEvaluation.swift b/Sources/Confidence/FlagEvaluation.swift new file mode 100644 index 00000000..c69afa93 --- /dev/null +++ b/Sources/Confidence/FlagEvaluation.swift @@ -0,0 +1,144 @@ +import Foundation + +public struct Evaluation { + public let value: T + public let variant: String? + public let reason: ResolveReason + public let errorCode: ErrorCode? + public let errorMessage: String? +} + +public enum ErrorCode { + case providerNotReady + case invalidContext +} + +struct FlagResolution: Encodable, Decodable, Equatable { + let context: ConfidenceStruct + let flags: [ResolvedValue] + let resolveToken: String + static let EMPTY = FlagResolution(context: [:], flags: [], resolveToken: "") +} + +extension FlagResolution { + func evaluate( + flagName: String, + defaultValue: T, + context: ConfidenceStruct, + flagApplier: FlagApplier? = nil + ) throws -> Evaluation { + let parsedKey = try FlagPath.getPath(for: flagName) + if self == FlagResolution.EMPTY { + throw ConfidenceError.flagNotFoundError(key: parsedKey.flag) + } + let resolvedFlag = self.flags.first { resolvedFlag in resolvedFlag.flag == parsedKey.flag } + guard let resolvedFlag = resolvedFlag else { + throw ConfidenceError.flagNotFoundError(key: parsedKey.flag) + } + + if resolvedFlag.resolveReason != .targetingKeyError { + Task { + await flagApplier?.apply(flagName: parsedKey.flag, resolveToken: self.resolveToken) + } + } else { + return Evaluation( + value: defaultValue, + variant: nil, + reason: .targetingKeyError, + errorCode: .invalidContext, + errorMessage: "Invalid targeting key" + ) + } + + guard let value = resolvedFlag.value else { + return Evaluation( + value: defaultValue, + variant: resolvedFlag.variant, + reason: resolvedFlag.resolveReason, + errorCode: nil, + errorMessage: nil + ) + } + + let parsedValue = try getValue(path: parsedKey.path, value: value) + let pathValue: T = getTyped(value: parsedValue) ?? defaultValue + + if resolvedFlag.resolveReason == .match { + var resolveReason: ResolveReason = .match + if self.context != context { + resolveReason = .stale + } + return Evaluation( + value: pathValue, + variant: resolvedFlag.variant, + reason: resolveReason, + errorCode: nil, + errorMessage: nil + ) + } else { + return Evaluation( + value: defaultValue, + variant: resolvedFlag.variant, + reason: resolvedFlag.resolveReason, + errorCode: nil, + errorMessage: nil + ) + } + } + + private func getTyped(value: ConfidenceValue) -> T? { + if let value = self as? T { + return value + } + + switch value.type() { + case .boolean: + return value.asBoolean() as? T + case .string: + return value.asString() as? T + case .integer: + return value.asInteger() as? T + case .double: + return value.asDouble() as? T + case .date: + return value.asDate() as? T + case .timestamp: + return value.asDateComponents() as? T + case .list: + return value.asList() as? T + case .structure: + return value.asStructure() as? T + case .null: + return nil + } + } + + private func getValue(path: [String], value: ConfidenceValue) throws -> ConfidenceValue { + if path.isEmpty { + guard value.asStructure() != nil else { + throw ConfidenceError.parseError( + message: "Flag path must contain path to the field for non-object values") + } + } + + var pathValue = value + if !path.isEmpty { + pathValue = try getValueForPath(path: path, value: value) + } + + return pathValue + } + + private func getValueForPath(path: [String], value: ConfidenceValue) throws -> ConfidenceValue { + var curValue = value + for field in path { + guard let values = curValue.asStructure(), let newValue = values[field] else { + throw ConfidenceError.internalError(message: "Unable to find key '\(field)'") + } + + curValue = newValue + } + + return curValue + } +} diff --git a/Sources/ConfidenceProvider/Utils/FlagPath.swift b/Sources/Confidence/FlagPath.swift similarity index 81% rename from Sources/ConfidenceProvider/Utils/FlagPath.swift rename to Sources/Confidence/FlagPath.swift index 5b89b571..4d976573 100644 --- a/Sources/ConfidenceProvider/Utils/FlagPath.swift +++ b/Sources/Confidence/FlagPath.swift @@ -1,5 +1,4 @@ import Foundation -import OpenFeature public struct FlagPath { var flag: String @@ -9,7 +8,7 @@ public struct FlagPath { let parts = path.components(separatedBy: ".") guard let flag = parts.first else { - throw OpenFeatureError.generalError(message: "Flag value key is empty") + throw ConfidenceError.internalError(message: "Flag value key is empty") } return .init(flag: flag, path: Array(parts.suffix(from: 1))) diff --git a/Sources/Common/Http/HttpClient.swift b/Sources/Confidence/Http/HttpClient.swift similarity index 100% rename from Sources/Common/Http/HttpClient.swift rename to Sources/Confidence/Http/HttpClient.swift diff --git a/Sources/Common/Http/HttpStatusCode.swift b/Sources/Confidence/Http/HttpStatusCode.swift similarity index 100% rename from Sources/Common/Http/HttpStatusCode.swift rename to Sources/Confidence/Http/HttpStatusCode.swift diff --git a/Sources/Common/Http/NetworkClient.swift b/Sources/Confidence/Http/NetworkClient.swift similarity index 96% rename from Sources/Common/Http/NetworkClient.swift rename to Sources/Confidence/Http/NetworkClient.swift index 801891af..4f392715 100644 --- a/Sources/Common/Http/NetworkClient.swift +++ b/Sources/Confidence/Http/NetworkClient.swift @@ -3,7 +3,6 @@ import Foundation final public class NetworkClient: HttpClient { private let headers: [String: String] private let retry: Retry - private let timeout: TimeInterval private let session: URLSession private let baseUrl: String @@ -11,14 +10,12 @@ final public class NetworkClient: HttpClient { session: URLSession? = nil, baseUrl: String, defaultHeaders: [String: String] = [:], - timeout: TimeInterval = 30.0, retry: Retry = .none ) { self.session = session ?? { let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = timeout configuration.httpAdditionalHeaders = defaultHeaders return URLSession(configuration: configuration) @@ -26,7 +23,6 @@ final public class NetworkClient: HttpClient { self.headers = defaultHeaders self.retry = retry - self.timeout = timeout self.baseUrl = baseUrl } diff --git a/Sources/Common/Http/Retry.swift b/Sources/Confidence/Http/Retry.swift similarity index 100% rename from Sources/Common/Http/Retry.swift rename to Sources/Confidence/Http/Retry.swift diff --git a/Sources/ConfidenceProvider/Utils/HttpStatusCode+Error.swift b/Sources/Confidence/HttpStatusCode+Error.swift similarity index 71% rename from Sources/ConfidenceProvider/Utils/HttpStatusCode+Error.swift rename to Sources/Confidence/HttpStatusCode+Error.swift index bfec2a2c..ed3c2257 100644 --- a/Sources/ConfidenceProvider/Utils/HttpStatusCode+Error.swift +++ b/Sources/Confidence/HttpStatusCode+Error.swift @@ -1,16 +1,13 @@ import Foundation -import Common -import Confidence -import OpenFeature extension HTTPURLResponse { func mapStatusToError(error: HttpError?, flag: String = "unknown") -> Error { - let defaultError = OpenFeatureError.generalError( + let defaultError = ConfidenceError.internalError( message: "General error: \(error?.message ?? "Unknown error")") switch self.status { case .notFound: - return OpenFeatureError.flagNotFoundError(key: flag) + return ConfidenceError.flagNotFoundError(key: flag) case .badRequest: return ConfidenceError.badRequest(message: error?.message ?? "") default: diff --git a/Sources/Common/NetowrkValue.swift b/Sources/Confidence/NetowrkValue.swift similarity index 100% rename from Sources/Common/NetowrkValue.swift rename to Sources/Confidence/NetowrkValue.swift diff --git a/Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift b/Sources/Confidence/RemoteConfidenceClient.swift similarity index 99% rename from Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift rename to Sources/Confidence/RemoteConfidenceClient.swift index 2b3d6713..808c96d0 100644 --- a/Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift +++ b/Sources/Confidence/RemoteConfidenceClient.swift @@ -1,5 +1,4 @@ import Foundation -import Common import os public class RemoteConfidenceClient: ConfidenceClient { diff --git a/Sources/ConfidenceProvider/ConfidenceClient/RemoteResolveConfidenceClient.swift b/Sources/Confidence/RemoteResolveConfidenceClient.swift similarity index 80% rename from Sources/ConfidenceProvider/ConfidenceClient/RemoteResolveConfidenceClient.swift rename to Sources/Confidence/RemoteResolveConfidenceClient.swift index ab22f8ce..7521f75a 100644 --- a/Sources/ConfidenceProvider/ConfidenceClient/RemoteResolveConfidenceClient.swift +++ b/Sources/Confidence/RemoteResolveConfidenceClient.swift @@ -1,11 +1,7 @@ import Foundation -import Common -import Confidence -import OpenFeature public class RemoteConfidenceResolveClient: ConfidenceResolveClient { private let targetingKey = "targeting_key" - private let flagApplier: FlagApplier private var options: ConfidenceClientOptions private let metadata: ConfidenceMetadata @@ -15,12 +11,10 @@ public class RemoteConfidenceResolveClient: ConfidenceResolveClient { init( options: ConfidenceClientOptions, session: URLSession? = nil, - applyOnResolve: Bool, - flagApplier: FlagApplier, + applyOnResolve: Bool = false, metadata: ConfidenceMetadata ) { self.options = options - self.flagApplier = flagApplier self.applyOnResolve = applyOnResolve self.metadata = metadata self.httpClient = NetworkClient( @@ -33,7 +27,7 @@ public class RemoteConfidenceResolveClient: ConfidenceResolveClient { public func resolve(flags: [String], ctx: ConfidenceStruct) async throws -> ResolvesResult { let request = ResolveFlagsRequest( flags: flags.map { "flags/\($0)" }, - evaluationContext: try NetworkTypeMapper.from(value: ctx), + evaluationContext: TypeMapper.convert(structure: ctx), clientSecret: options.credentials.getSecret(), apply: applyOnResolve, sdk: Sdk(id: metadata.name, version: metadata.version) @@ -48,7 +42,7 @@ public class RemoteConfidenceResolveClient: ConfidenceResolveClient { throw successData.response.mapStatusToError(error: successData.decodedError) } guard let response = successData.decodedData else { - throw OpenFeatureError.parseError(message: "Unable to parse request response") + throw ConfidenceError.parseError(message: "Unable to parse request response") } let resolvedValues = try response.resolvedFlags.map { resolvedFlag in try convert(resolvedFlag: resolvedFlag) @@ -74,36 +68,29 @@ public class RemoteConfidenceResolveClient: ConfidenceResolveClient { return ResolvedValue( value: nil, flag: try displayName(resolvedFlag: resolvedFlag), - resolveReason: convert(resolveReason: resolvedFlag.reason)) + resolveReason: resolvedFlag.reason) } - let value = try TypeMapper.from(object: responseValue, schema: responseFlagSchema) + let value = ConfidenceValue( + structure: try TypeMapper.convert(structure: responseValue, schema: responseFlagSchema) + ) let variant = resolvedFlag.variant.isEmpty ? nil : resolvedFlag.variant return ResolvedValue( variant: variant, value: value, flag: try displayName(resolvedFlag: resolvedFlag), - resolveReason: convert(resolveReason: resolvedFlag.reason)) + resolveReason: resolvedFlag.reason + ) } private func handleError(error: Error) -> Error { - if error is ConfidenceError || error is OpenFeatureError { + if error is ConfidenceError { return error } else { return ConfidenceError.grpcError(message: "\(error)") } } - - private func convert(resolveReason: ResolveReason) -> ResolvedValue.Reason { - switch resolveReason { - case .error, .unknown, .unspecified: return .generalError - case .noSegmentMatch, .noTreatmentMatch: return .noMatch - case .match: return .match - case .archived: return .disabled - case .targetingKeyError: return .targetingKeyError - } - } } struct ResolveFlagsRequest: Codable { @@ -127,9 +114,10 @@ struct ResolvedFlag: Codable { var reason: ResolveReason } -enum ResolveReason: String, Codable, CaseIterableDefaultsLast { +public enum ResolveReason: String, Codable, CaseIterableDefaultsLast { case unspecified = "RESOLVE_REASON_UNSPECIFIED" case match = "RESOLVE_REASON_MATCH" + case stale = "RESOLVE_REASON_STALE" case noSegmentMatch = "RESOLVE_REASON_NO_SEGMENT_MATCH" case noTreatmentMatch = "RESOLVE_REASON_NO_TREATMENT_MATCH" case archived = "RESOLVE_REASON_FLAG_ARCHIVED" diff --git a/Sources/ConfidenceProvider/ConfidenceClient/Resolver.swift b/Sources/Confidence/Resolver.swift similarity index 92% rename from Sources/ConfidenceProvider/ConfidenceClient/Resolver.swift rename to Sources/Confidence/Resolver.swift index cbd6ef52..0ecdf57e 100644 --- a/Sources/ConfidenceProvider/ConfidenceClient/Resolver.swift +++ b/Sources/Confidence/Resolver.swift @@ -1,5 +1,3 @@ -import OpenFeature - public protocol Resolver { // This throws if the requested flag is not found func resolve(flag: String, contextHash: String) throws -> ResolveResult diff --git a/Sources/Confidence/Sdk.swift b/Sources/Confidence/Sdk.swift new file mode 100644 index 00000000..bc615a2b --- /dev/null +++ b/Sources/Confidence/Sdk.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct Sdk: Codable { + var id: String + var version: String +} diff --git a/Sources/Common/Storage.swift b/Sources/Confidence/Storage.swift similarity index 100% rename from Sources/Common/Storage.swift rename to Sources/Confidence/Storage.swift diff --git a/Sources/ConfidenceProvider/Utils/StructFlagSchema.swift b/Sources/Confidence/StructFlagSchema.swift similarity index 100% rename from Sources/ConfidenceProvider/Utils/StructFlagSchema.swift rename to Sources/Confidence/StructFlagSchema.swift diff --git a/Sources/Common/TestHelpers/Extensions.swift b/Sources/Confidence/TestHelpers/Extensions.swift similarity index 57% rename from Sources/Common/TestHelpers/Extensions.swift rename to Sources/Confidence/TestHelpers/Extensions.swift index 976bdc72..d7b11462 100644 --- a/Sources/Common/TestHelpers/Extensions.swift +++ b/Sources/Confidence/TestHelpers/Extensions.swift @@ -29,3 +29,24 @@ extension URLRequest { } } } + +extension [ResolvedValue] { + func toCacheData(context: ConfidenceStruct, resolveToken: String) -> FlagResolution { + return FlagResolution( + context: context, + flags: self, + resolveToken: resolveToken + ) + } +} + +/// Used for testing +public protocol DispatchQueueType { + func async(execute work: @escaping @convention(block) () -> Void) +} + +extension DispatchQueue: DispatchQueueType { + public func async(execute work: @escaping @convention(block) () -> Void) { + async(group: nil, qos: .unspecified, flags: [], execute: work) + } +} diff --git a/Sources/Common/TestHelpers/GrpcStatusCode.swift b/Sources/Confidence/TestHelpers/GrpcStatusCode.swift similarity index 100% rename from Sources/Common/TestHelpers/GrpcStatusCode.swift rename to Sources/Confidence/TestHelpers/GrpcStatusCode.swift diff --git a/Sources/Confidence/TypeMapper.swift b/Sources/Confidence/TypeMapper.swift new file mode 100644 index 00000000..4e92bc3a --- /dev/null +++ b/Sources/Confidence/TypeMapper.swift @@ -0,0 +1,107 @@ +import Foundation + +public enum TypeMapper { + static internal func convert(structure: ConfidenceStruct) -> NetworkStruct { + return NetworkStruct(fields: structure.compactMapValues(convert)) + } + + static internal func convert(structure: NetworkStruct, schema: StructFlagSchema) throws -> ConfidenceStruct { + return Dictionary(uniqueKeysWithValues: try structure.fields.map { field, value in + (field, try convert(value: value, schema: schema.schema[field])) + }) + } + + // swiftlint:disable:next cyclomatic_complexity + static func convert(value: ConfidenceValue) -> NetworkValue? { + switch value.type() { + case .boolean: + guard let value = value.asBoolean() else { + return nil + } + return NetworkValue.boolean(value) + case .string: + guard let value = value.asString() else { + return nil + } + return NetworkValue.string(value) + case .integer: + guard let value = value.asInteger() else { + return nil + } + return NetworkValue.number(Double(value)) + case .double: + guard let value = value.asDouble() else { + return nil + } + return NetworkValue.number(value) + case .date: + let dateFormatter = ISO8601DateFormatter() + dateFormatter.timeZone = TimeZone.current + dateFormatter.formatOptions = [.withFullDate] + guard let value = value.asDateComponents(), let dateString = Calendar.current.date(from: value) else { + return NetworkValue.string("") // This should never happen + } + return NetworkValue.string(dateFormatter.string(from: dateString)) + case .timestamp: + guard let value = value.asDate() else { + return nil + } + let timestampFormatter = ISO8601DateFormatter() + timestampFormatter.timeZone = TimeZone.init(identifier: "UTC") + let timestamp = timestampFormatter.string(from: value) + return NetworkValue.string(timestamp) + case .list: + guard let value = value.asList() else { + return nil + } + return NetworkValue.list(value.compactMap(convert)) + case .structure: + guard let value = value.asStructure() else { + return nil + } + return NetworkValue.structure(NetworkStruct(fields: value.compactMapValues(convert))) + case .null: + return .null + } + } + + // swiftlint:disable:next cyclomatic_complexity + static private func convert(value: NetworkValue, schema: FlagSchema?) throws -> ConfidenceValue { + guard let fieldType = schema else { + throw ConfidenceError.parseError(message: "Mismatch between schema and value") + } + + switch value { + case .null: + return .init(null: ()) + case .number(let value): + switch fieldType { + case .intSchema: + return .init(integer: Int(value)) + case .doubleSchema: + return .init(double: value) + default: + throw ConfidenceError.parseError(message: "Number field must have schema type int or double") + } + case .string(let value): + return .init(string: value) + case .boolean(let value): + return .init(boolean: value) + case .structure(let mapValue): + guard case .structSchema(let structSchema) = fieldType else { + throw ConfidenceError.parseError(message: "Field is struct in schema but something else in value") + } + return .init(structure: Dictionary( + uniqueKeysWithValues: try mapValue.fields.map { field, fieldValue in + return (field, try convert(value: fieldValue, schema: structSchema.schema[field])) + })) + case .list(let values): + guard case .listSchema(let listSchema) = fieldType else { + throw ConfidenceError.parseError(message: "Field is list in schema but something else in value") + } + return ConfidenceValue.init(list: try values.map { fieldValue in + try convert(value: fieldValue, schema: listSchema) + }) + } + } +} diff --git a/Sources/Confidence/VisitorUtil.swift b/Sources/Confidence/VisitorUtil.swift index b376a113..64c9127d 100644 --- a/Sources/Confidence/VisitorUtil.swift +++ b/Sources/Confidence/VisitorUtil.swift @@ -1,5 +1,4 @@ import Foundation -import Common class VisitorUtil { let defaults = UserDefaults.standard diff --git a/Sources/ConfidenceProvider/Cache/InMemoryProviderCache.swift b/Sources/ConfidenceProvider/Cache/InMemoryProviderCache.swift deleted file mode 100644 index 78be56bd..00000000 --- a/Sources/ConfidenceProvider/Cache/InMemoryProviderCache.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation -import Combine -import Common -import Confidence -import OpenFeature -import os - -public class InMemoryProviderCache: ProviderCache { - private var rwCacheQueue = DispatchQueue(label: "com.confidence.cache.rw", attributes: .concurrent) - static let currentVersion = "0.0.1" - private let cache: [String: ResolvedValue] - - private var storage: Storage - private var curResolveToken: String? - private var curEvalContextHash: String? - - init(storage: Storage, cache: [String: ResolvedValue], curResolveToken: String?, curEvalContextHash: String?) { - self.storage = storage - self.cache = cache - self.curResolveToken = curResolveToken - self.curEvalContextHash = curEvalContextHash - } - - public func getValue(flag: String, contextHash: String) throws -> CacheGetValueResult? { - if let value = self.cache[flag] { - guard let curResolveToken = curResolveToken else { - throw ConfidenceError.noResolveTokenFromCache - } - return .init( - resolvedValue: value, needsUpdate: curEvalContextHash != contextHash, resolveToken: curResolveToken) - } else { - return nil - } - } - - public static func from(storage: Storage) -> InMemoryProviderCache { - do { - let storedCache = try storage.load( - defaultValue: StoredCacheData( - version: currentVersion, cache: [:], curResolveToken: nil, curEvalContextHash: nil)) - return InMemoryProviderCache( - storage: storage, - cache: storedCache.cache, - curResolveToken: storedCache.curResolveToken, - curEvalContextHash: storedCache.curEvalContextHash) - } catch { - Logger(subsystem: "com.confidence.cache", category: "storage").error( - "Error when trying to load resolver cache, clearing cache: \(error)") - - if case .corruptedCache = error as? ConfidenceError { - try? storage.clear() - } - - return InMemoryProviderCache(storage: storage, cache: [:], curResolveToken: nil, curEvalContextHash: nil) - } - } -} - -public struct ResolvedKey: Hashable, Codable { - var flag: String - var targetingKey: String -} - -struct StoredCacheData: Codable { - var version: String - var cache: [String: ResolvedValue] - var curResolveToken: String? - var curEvalContextHash: String? -} diff --git a/Sources/ConfidenceProvider/Cache/ProviderCache.swift b/Sources/ConfidenceProvider/Cache/ProviderCache.swift deleted file mode 100644 index c9d1feca..00000000 --- a/Sources/ConfidenceProvider/Cache/ProviderCache.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation -import OpenFeature - -public protocol ProviderCache { - func getValue(flag: String, contextHash: String) throws -> CacheGetValueResult? -} - -public struct CacheGetValueResult { - var resolvedValue: ResolvedValue - var needsUpdate: Bool - var resolveToken: String -} diff --git a/Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClient.swift b/Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClient.swift deleted file mode 100644 index 6a9fe729..00000000 --- a/Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClient.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation -import Confidence -import OpenFeature - -public protocol ConfidenceResolveClient { - // Async - func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult -} - -public struct ResolvedValue: Codable, Equatable { - var variant: String? - var value: Value? - var flag: String - var resolveReason: Reason - - enum Reason: Int, Codable, Equatable { - case match = 0 - case noMatch = 1 - case targetingKeyError = 2 - case generalError = 3 - case disabled = 4 - case stale = 5 - } -} - -public struct ResolvesResult: Codable, Equatable { - var resolvedValues: [ResolvedValue] - var resolveToken: String? -} diff --git a/Sources/ConfidenceProvider/ConfidenceClient/LocalStorageResolver.swift b/Sources/ConfidenceProvider/ConfidenceClient/LocalStorageResolver.swift deleted file mode 100644 index 6870f9f2..00000000 --- a/Sources/ConfidenceProvider/ConfidenceClient/LocalStorageResolver.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import Common -import Confidence -import OpenFeature - -public class LocalStorageResolver: Resolver { - private var cache: ProviderCache - - init(cache: ProviderCache) { - self.cache = cache - } - - public func resolve(flag: String, contextHash: String) throws -> ResolveResult { - let getResult = try self.cache.getValue(flag: flag, contextHash: contextHash) - guard let getResult = getResult else { - throw OpenFeatureError.flagNotFoundError(key: flag) - } - guard getResult.needsUpdate == false else { - var resolveValueStale = getResult.resolvedValue - resolveValueStale.resolveReason = .stale - return .init(resolvedValue: resolveValueStale, resolveToken: getResult.resolveToken) - } - return .init(resolvedValue: getResult.resolvedValue, resolveToken: getResult.resolveToken) - } -} diff --git a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift index 0ee45dde..fa293db1 100644 --- a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift +++ b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift @@ -1,117 +1,66 @@ import Foundation import Combine -import Common import Confidence import OpenFeature import os +struct Metadata: ProviderMetadata { + var name: String? +} + /// The implementation of the Confidence Feature Provider. This implementation allows to pre-cache evaluations. -/// -/// -/// -// swiftlint:disable type_body_length -// swiftlint:disable file_length public class ConfidenceFeatureProvider: FeatureProvider { public var metadata: ProviderMetadata public var hooks: [any Hook] = [] private let lock = UnfairLock() - private var resolver: Resolver - private let client: ConfidenceResolveClient - private var cache: ProviderCache - private var overrides: [String: LocalOverride] - private let flagApplier: FlagApplier private let initializationStrategy: InitializationStrategy - private let storage: Storage private let eventHandler = EventHandler(ProviderEvent.notReady) - private let confidence: Confidence? + private let confidence: Confidence private var cancellables = Set() private var currentResolveTask: Task? private let confidenceFeatureProviderQueue = DispatchQueue(label: "com.provider.queue") - /// Should not be called externally, use `ConfidenceFeatureProvider.Builder`or init with `Confidence` instead. - init( - metadata: ProviderMetadata, - client: RemoteConfidenceResolveClient, - cache: ProviderCache, - storage: Storage, - overrides: [String: LocalOverride] = [:], - flagApplier: FlagApplier, - initializationStrategy: InitializationStrategy, - confidence: Confidence? - ) { - self.client = client - self.metadata = metadata - self.cache = cache - self.overrides = overrides - self.flagApplier = flagApplier - self.initializationStrategy = initializationStrategy - self.storage = storage - self.confidence = confidence - self.resolver = LocalStorageResolver(cache: cache) - } - /// Initialize the Provider via a `Confidence` object. - public convenience init(confidence: Confidence) { - self.init(confidence: confidence, session: nil, client: nil) + public convenience init(confidence: Confidence, initializationStrategy: InitializationStrategy = .fetchAndActivate) { + self.init(confidence: confidence, session: nil) } - internal init(confidence: Confidence, session: URLSession?, client: ConfidenceResolveClient?) { - let metadata = ConfidenceMetadata(version: "0.1.4") // x-release-please-version - let options = ConfidenceClientOptions( - credentials: ConfidenceClientCredentials.clientSecret(secret: confidence.clientSecret), - region: confidence.region) - self.metadata = metadata - self.cache = InMemoryProviderCache.from(storage: DefaultStorage.resolverFlagsCache()) - self.storage = DefaultStorage.resolverFlagsCache() - self.resolver = LocalStorageResolver(cache: cache) - self.flagApplier = FlagApplierWithRetries( - httpClient: NetworkClient(baseUrl: BaseUrlMapper.from(region: options.region)), - storage: DefaultStorage.applierFlagsCache(), - options: options, - metadata: metadata) - self.client = client ?? RemoteConfidenceResolveClient( - options: options, - session: session, - applyOnResolve: false, - flagApplier: flagApplier, - metadata: metadata) - self.initializationStrategy = confidence.initializationStrategy - self.overrides = [:] + internal init( + confidence: Confidence, + initializationStrategy: InitializationStrategy = .fetchAndActivate, + session: URLSession? + ) { + let metadata = ConfidenceMetadata( + name: "SDK_ID_SWIFT_PROVIDER", + version: "0.1.4") // x-release-please-version + self.metadata = Metadata(name: metadata.name) + self.initializationStrategy = initializationStrategy self.confidence = confidence } public func initialize(initialContext: OpenFeature.EvaluationContext?) { - let convertedInitialContext = ConfidenceTypeMapper.from(ctx: initialContext) - confidence?.putContext(context: convertedInitialContext) - let context = confidence?.getContext() ?? convertedInitialContext + guard let initialContext = initialContext else { + return + } + self.updateConfidenceContext(context: initialContext) if self.initializationStrategy == .activateAndFetchAsync { eventHandler.send(.ready) } - Task { - await resolve(strategy: initializationStrategy, context: context) - } - self.startListentingForContextChanges() - } - private func resolve(strategy: InitializationStrategy, context: ConfidenceStruct) async { do { - let resolveResult = try await client.resolve(ctx: context) - - // update cache with stored values - try await store( - with: context, - resolveResult: resolveResult, - refreshCache: strategy == .fetchAndActivate - ) - - // signal the provider is ready after the network request is done - if strategy == .fetchAndActivate { + if initializationStrategy == .activateAndFetchAsync { + try confidence.activate() eventHandler.send(.ready) + confidence.asyncFetch() + } else { + Task { + try await confidence.fetchAndActivate() + eventHandler.send(.ready) + } } } catch { - // We emit a ready event as the provider is ready, but is using default / cache values. - eventHandler.send(.ready) + eventHandler.send(.error) } } @@ -123,321 +72,56 @@ public class ConfidenceFeatureProvider: FeatureProvider { currentResolveTask?.cancel() } - private func store( - with context: ConfidenceStruct, - resolveResult result: ResolvesResult, - refreshCache: Bool - ) async throws { - guard let resolveToken = result.resolveToken else { - throw ConfidenceError.noResolveTokenFromServer - } - - try self.storage.save(data: result.resolvedValues.toCacheData(context: context, resolveToken: resolveToken)) - - if refreshCache { - withLock { provider in - provider.cache = InMemoryProviderCache.from(storage: self.storage) - provider.resolver = LocalStorageResolver(cache: provider.cache) - } - } - } - public func onContextSet( oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext ) { - if confidence == nil { - currentResolveTask = Task { - await resolve(strategy: .fetchAndActivate, context: ConfidenceTypeMapper.from(ctx: newContext)) - } - return - } - - var removeKeys: [String] = [] + var removedKeys: [String] = [] if let oldContext = oldContext { - removeKeys = Array(oldContext.asMap().filter { key, _ in !newContext.asMap().keys.contains(key) }.keys) - } - confidence?.putContext( - context: ConfidenceTypeMapper.from(ctx: newContext), - removeKeys: removeKeys) - } - - private func startListentingForContextChanges() { - guard let confidence = confidence else { - return + removedKeys = Array(oldContext.asMap().filter { key, _ in !newContext.asMap().keys.contains(key) }.keys) } - confidence.contextChanges() - .sink { [weak self] context in - guard let self = self else { - return - } + self.updateConfidenceContext(context: newContext, removedKeys: removedKeys) + } - currentResolveTask?.cancel() - currentResolveTask = Task { - await self.resolve(strategy: .fetchAndActivate, context: context) - } - } - .store(in: &cancellables) + private func updateConfidenceContext(context: EvaluationContext, removedKeys: [String] = []) { + confidence.putContext(context: ConfidenceTypeMapper.from(ctx: context), removeKeys: removedKeys) } public func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws -> OpenFeature.ProviderEvaluation { - return try errorWrappedResolveFlag( - flag: key, - defaultValue: defaultValue, - ctx: context, - errorPrefix: "Error during boolean evaluation for key \(key)") + try confidence.getEvaluation(key: key, defaultValue: defaultValue).toProviderEvaluation() } public func getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?) throws -> OpenFeature.ProviderEvaluation { - return try errorWrappedResolveFlag( - flag: key, - defaultValue: defaultValue, - ctx: context, - errorPrefix: "Error during string evaluation for key \(key)") + try confidence.getEvaluation(key: key, defaultValue: defaultValue).toProviderEvaluation() } public func getIntegerEvaluation(key: String, defaultValue: Int64, context: EvaluationContext?) throws -> OpenFeature.ProviderEvaluation { - return try errorWrappedResolveFlag( - flag: key, - defaultValue: defaultValue, - ctx: context, - errorPrefix: "Error during integer evaluation for key \(key)") + try confidence.getEvaluation(key: key, defaultValue: defaultValue).toProviderEvaluation() } public func getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?) throws -> OpenFeature.ProviderEvaluation { - return try errorWrappedResolveFlag( - flag: key, - defaultValue: defaultValue, - ctx: context, - errorPrefix: "Error during double evaluation for key \(key)") + try confidence.getEvaluation(key: key, defaultValue: defaultValue).toProviderEvaluation() } public func getObjectEvaluation(key: String, defaultValue: OpenFeature.Value, context: EvaluationContext?) throws -> OpenFeature.ProviderEvaluation { - return try errorWrappedResolveFlag( - flag: key, - defaultValue: defaultValue, - ctx: context, - errorPrefix: "Error during object evaluation for key \(key)") + try confidence.getEvaluation(key: key, defaultValue: defaultValue).toProviderEvaluation() } public func observe() -> AnyPublisher { return eventHandler.observe() } - /// Allows you to override directly on the provider. See `overrides` on ``Builder`` for more information. - /// - /// For example - /// - /// (OpenFeatureAPI.shared.provider as? ConfidenceFeatureProvider)? - /// .overrides(.field(path: "button.size", variant: "control", value: .integer(4))) - public func overrides(_ overrides: LocalOverride...) { - lock.locked { - overrides.forEach { localOverride in - self.overrides[localOverride.key()] = localOverride - } - } - } - - public func errorWrappedResolveFlag(flag: String, defaultValue: T, ctx: EvaluationContext?, errorPrefix: String) - throws -> ProviderEvaluation - { - do { - let path = try FlagPath.getPath(for: flag) - return try resolveFlag(path: path, defaultValue: defaultValue, ctx: ctx) - } catch let error { - if error is OpenFeatureError { - throw error - } else { - throw OpenFeatureError.generalError(message: "\(errorPrefix): \(error)") - } - } - } - - private func resolveFlag(path: FlagPath, defaultValue: T, ctx: EvaluationContext?) throws -> ProviderEvaluation< - T - > { - if let overrideValue: (value: T, variant: String?) = getOverride(path: path) { - return ProviderEvaluation( - value: overrideValue.value, - variant: overrideValue.variant, - reason: Reason.staticReason.rawValue) - } - - let context = confidence?.getContext() ?? ConfidenceTypeMapper.from(ctx: ctx) - - do { - let resolverResult = try resolver.resolve(flag: path.flag, contextHash: context.hash()) - - guard let value = resolverResult.resolvedValue.value else { - return resolveFlagNoValue( - defaultValue: defaultValue, - resolverResult: resolverResult, - ctx: context - ) - } - - let pathValue: Value = try getValue(path: path.path, value: value) - guard let typedValue: T = pathValue == .null ? defaultValue : pathValue.getTyped() else { - throw OpenFeatureError.parseError(message: "Unable to parse flag value: \(pathValue)") - } - - let isStale = resolverResult.resolvedValue.resolveReason == .stale - let evaluationResult = ProviderEvaluation( - value: typedValue, - variant: resolverResult.resolvedValue.variant, - reason: isStale ? Reason.stale.rawValue : Reason.targetingMatch.rawValue - ) - - processResultForApply( - resolverResult: resolverResult, - applyTime: Date.backport.now - ) - - - return evaluationResult - } - } - - private func resolveFlagNoValue(defaultValue: T, resolverResult: ResolveResult, ctx: ConfidenceStruct) - -> ProviderEvaluation - { - switch resolverResult.resolvedValue.resolveReason { - case .noMatch: - processResultForApply( - resolverResult: resolverResult, - applyTime: Date.backport.now) - return ProviderEvaluation( - value: defaultValue, - variant: nil, - reason: Reason.defaultReason.rawValue) - case .match: - return ProviderEvaluation( - value: defaultValue, - variant: nil, - reason: Reason.error.rawValue, - errorCode: ErrorCode.general, - errorMessage: "Rule matched but no value was returned") - case .targetingKeyError: - return ProviderEvaluation( - value: defaultValue, - variant: nil, - reason: Reason.error.rawValue, - errorCode: ErrorCode.invalidContext, - errorMessage: "Invalid targeting key") - case .disabled: - return ProviderEvaluation( - value: defaultValue, - variant: nil, - reason: Reason.disabled.rawValue) - case .generalError: - return ProviderEvaluation( - value: defaultValue, - variant: nil, - reason: Reason.error.rawValue, - errorCode: ErrorCode.general, - errorMessage: "General error in the Confidence backend") - case .stale: - return ProviderEvaluation( - value: defaultValue, - variant: resolverResult.resolvedValue.variant, - reason: Reason.stale.rawValue - ) - } - } - - private func getValue(path: [String], value: Value) throws -> Value { - if path.isEmpty { - guard case .structure = value else { - throw OpenFeatureError.parseError( - message: "Flag path must contain path to the field for non-object values") - } - } - - var pathValue = value - if !path.isEmpty { - pathValue = try getValueForPath(path: path, value: value) - } - - return pathValue - } - - private func getValueForPath(path: [String], value: Value) throws -> Value { - var curValue = value - for field in path { - guard case .structure(let values) = curValue, let newValue = values[field] else { - throw OpenFeatureError.generalError(message: "Unable to find key '\(field)'") - } - - curValue = newValue - } - - return curValue - } - - private func getOverride(path: FlagPath) -> (value: T, variant: String?)? { - let fieldPath = "\(path.flag).\(path.path.joined(separator: "."))" - - guard let overrideValue = self.overrides[fieldPath] ?? self.overrides[path.flag] else { - return nil - } - - switch overrideValue { - case let .flag(_, variant, value): - guard let pathValue = try? getValue(path: path.path, value: .structure(value)) else { - return nil - } - guard let typedValue: T = pathValue.getTyped() else { - return nil - } - - return (typedValue, variant) - - case let .field(_, variant, value): - guard let typedValue: T = value.getTyped() else { - return nil - } - - return (typedValue, variant) - } - } - - private func processResultForApply( - resolverResult: ResolveResult?, - applyTime: Date - ) { - guard let resolverResult = resolverResult, let resolveToken = resolverResult.resolveToken else { - return - } - - let flag = resolverResult.resolvedValue.flag - Task { - await flagApplier.apply(flagName: flag, resolveToken: resolveToken) - } - } - - private func logApplyError(error: Error) { - switch error { - case ConfidenceError.applyStatusTransitionError, ConfidenceError.cachedValueExpired, - ConfidenceError.flagNotFoundInCache: - Logger(subsystem: "com.confidence.provider", category: "apply").debug( - "Cache data for flag was updated while executing \"apply\", aborting") - default: - Logger(subsystem: "com.confidence.provider", category: "apply").error( - "Error while executing \"apply\": \(error)") - } - } - private func withLock(callback: @escaping (ConfidenceFeatureProvider) -> Void) { confidenceFeatureProviderQueue.sync { [weak self] in guard let self = self else { @@ -448,235 +132,14 @@ public class ConfidenceFeatureProvider: FeatureProvider { } } -// MARK: Storage - -extension ConfidenceFeatureProvider { - public static func isStorageEmpty( - storage: Storage = DefaultStorage.resolverFlagsCache() - ) -> Bool { - storage.isEmpty() - } -} - -// MARK: Builder - -extension ConfidenceFeatureProvider { - public struct Builder { - var options: ConfidenceClientOptions - let metadata = ConfidenceMetadata(version: "0.1.4") // x-release-please-version - var session: URLSession? - var localOverrides: [String: LocalOverride] = [:] - var storage: Storage = DefaultStorage.resolverFlagsCache() - var cache: ProviderCache? - var flagApplier: (any FlagApplier)? - var initializationStrategy: InitializationStrategy = .fetchAndActivate - var confidence: Confidence? - - /// DEPRECATED: initialise with a `Confidence` object instead. - /// Initializes the builder with the given credentails. - /// - /// OpenFeatureAPI.shared.setProvider(provider: - /// ConfidenceFeatureProvider.Builder(credentials: .clientSecret(secret: "mysecret")) - /// .build() - public init(credentials: ConfidenceClientCredentials) { - self.options = ConfidenceClientOptions(credentials: credentials) - } - - init( - options: ConfidenceClientOptions, - session: URLSession? = nil, - localOverrides: [String: LocalOverride] = [:], - flagApplier: FlagApplier?, - storage: Storage, - cache: ProviderCache?, - initializationStrategy: InitializationStrategy - ) { - self.options = options - self.session = session - self.localOverrides = localOverrides - self.flagApplier = flagApplier - self.storage = storage - self.cache = cache - self.initializationStrategy = initializationStrategy - } - - /// Allows the `ConfidenceClient` to be configured with a custom URLSession, useful for - /// setting up unit tests. - /// - /// - Parameters: - /// - session: URLSession to use for connections. - public func with(session: URLSession) -> Builder { - return Builder( - options: options, - session: session, - localOverrides: localOverrides, - flagApplier: flagApplier, - storage: storage, - cache: cache, - initializationStrategy: initializationStrategy - ) - } - - /// Inject custom queue for Apply request operations, useful for testing - /// - /// - Parameters: - /// - applyQueue: queue to use for sending Apply requests. - public func with(flagApplier: FlagApplier) -> Builder { - return Builder( - options: options, - session: session, - localOverrides: localOverrides, - flagApplier: flagApplier, - storage: storage, - cache: cache, - initializationStrategy: initializationStrategy - ) - } - - /// Inject custom storage, useful for testing - /// - /// - Parameters: - /// - cache: cache for the provider to use. - public func with(storage: Storage) -> Builder { - return Builder( - options: options, - session: session, - localOverrides: localOverrides, - flagApplier: flagApplier, - storage: storage, - cache: cache, - initializationStrategy: initializationStrategy - ) - } - - /// Inject custom cache, useful for testing - /// - /// - Parameters: - /// - cache: cache for the provider to use. - public func with(cache: ProviderCache) -> Builder { - return Builder( - options: options, - session: session, - localOverrides: localOverrides, - flagApplier: flagApplier, - storage: storage, - cache: cache, - initializationStrategy: initializationStrategy - ) - } - - /// Inject custom storage for apply events, useful for testing - /// - /// - Parameters: - /// - storage: apply storage for the provider to use. - public func with(applyStorage: Storage) -> Builder { - return Builder( - options: options, - session: session, - localOverrides: localOverrides, - flagApplier: flagApplier, - storage: storage, - cache: cache, - initializationStrategy: initializationStrategy - ) - } - - /// Inject custom initialization strategy - /// - /// - Parameters: - /// - storage: apply storage for the provider to use. - public func with(initializationStrategy: InitializationStrategy) -> Builder { - return Builder( - options: options, - session: session, - localOverrides: localOverrides, - flagApplier: flagApplier, - storage: storage, - cache: cache, - initializationStrategy: initializationStrategy - ) - } - - /// Locally overrides resolves for specific flags or even fields within a flag. Field-level overrides are - /// prioritized over flag-level overrides ones. - /// - /// For example, the following will override the size field of a flag called button: - /// - /// OpenFeatureAPI.shared.setProvider(provider: - /// ConfidenceFeatureProvider.Builder(credentials: .clientSecret(secret: "mysecret")) - /// .overrides(.field(path: "button.size", variant: "control", value: .integer(4))) - /// .build() - /// - /// You can alsow override the complete flag by: - /// - /// OpenFeatureAPI.shared.setProvider(provider: - /// ConfidenceFeatureProvider.Builder(credentials: .clientSecret(secret: "mysecret")) - /// .overrides(.flag(name: "button", variant: "control", value: ["size": .integer(4)])) - /// .build() - /// - /// - Parameters: - /// - overrides: the list of local overrides for the provider. - public func overrides(_ overrides: LocalOverride...) -> Builder { - let localOverrides = Dictionary(uniqueKeysWithValues: overrides.map { ($0.key(), $0) }) - - return Builder( - options: options, - session: session, - localOverrides: self.localOverrides.merging(localOverrides) { _, new in new }, - flagApplier: flagApplier, - storage: storage, - cache: cache, - initializationStrategy: initializationStrategy - ) - } - - /// Creates the `ConfidenceFeatureProvider` according to the settings specified in the builder. - public func build() -> ConfidenceFeatureProvider { - let flagApplier = - flagApplier - ?? FlagApplierWithRetries( - httpClient: NetworkClient(baseUrl: BaseUrlMapper.from(region: options.region)), - storage: DefaultStorage.applierFlagsCache(), - options: options, - metadata: metadata - ) - - let cache = cache ?? InMemoryProviderCache.from(storage: storage) - - let client = RemoteConfidenceResolveClient( - options: options, - session: self.session, - applyOnResolve: false, - flagApplier: flagApplier, - metadata: metadata - ) - - return ConfidenceFeatureProvider( - metadata: metadata, - client: client, - cache: cache, - storage: storage, - overrides: localOverrides, - flagApplier: flagApplier, - initializationStrategy: initializationStrategy, - confidence: confidence - ) - } - } -} - -extension DefaultStorage { - public static func resolverFlagsCache() -> DefaultStorage { - DefaultStorage(filePath: "resolver.flags.cache") - } - - public static func resolverApplyCache() -> DefaultStorage { - DefaultStorage(filePath: "resolver.apply.cache") - } - - public static func applierFlagsCache() -> DefaultStorage { - DefaultStorage(filePath: "applier.flags.cache") +extension Evaluation { + func toProviderEvaluation() -> ProviderEvaluation { + ProviderEvaluation( + value: self.value, + variant: self.variant, + reason: self.reason.rawValue, + errorCode: nil, + errorMessage: nil + ) } } -// swiftlint:enable type_body_length -// swiftlint:enable file_length diff --git a/Sources/ConfidenceProvider/Utils/ConfidenceTypeMapper.swift b/Sources/ConfidenceProvider/ConfidenceTypeMapper.swift similarity index 96% rename from Sources/ConfidenceProvider/Utils/ConfidenceTypeMapper.swift rename to Sources/ConfidenceProvider/ConfidenceTypeMapper.swift index b8818a4f..6eea092c 100644 --- a/Sources/ConfidenceProvider/Utils/ConfidenceTypeMapper.swift +++ b/Sources/ConfidenceProvider/ConfidenceTypeMapper.swift @@ -21,7 +21,7 @@ public enum ConfidenceTypeMapper { case .string(let value): return ConfidenceValue(string: value) case .integer(let value): - return ConfidenceValue(integer: value) + return ConfidenceValue(integer: Int(value)) case .double(let value): return ConfidenceValue(double: value) case .date(let value): @@ -37,7 +37,7 @@ public enum ConfidenceTypeMapper { case .string: return ConfidenceValue.init(stringList: values.compactMap { $0.asString() }) case .integer: - return ConfidenceValue.init(integerList: values.compactMap { $0.asInteger() }) + return ConfidenceValue.init(integerList: values.compactMap { $0.asInteger() }.map { Int($0) }) case .double: return ConfidenceValue.init(doubleList: values.compactMap { $0.asDouble() }) // Currently Date Value is converted to Timestamp ConfidenceValue to not lose precision, so this should never happen diff --git a/Sources/ConfidenceProvider/LocalOverride.swift b/Sources/ConfidenceProvider/LocalOverride.swift deleted file mode 100644 index ddb96730..00000000 --- a/Sources/ConfidenceProvider/LocalOverride.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation -import OpenFeature - -public enum LocalOverride { - case flag(name: String, variant: String, value: [String: Value]) - case field(path: String, variant: String, value: Value) - - func key() -> String { - switch self { - case .flag(let name, _, _): - return name - case .field(let path, _, _): - return path - } - } -} diff --git a/Sources/ConfidenceProvider/Utils/UnfairLock.swift b/Sources/ConfidenceProvider/UnfairLock.swift similarity index 100% rename from Sources/ConfidenceProvider/Utils/UnfairLock.swift rename to Sources/ConfidenceProvider/UnfairLock.swift diff --git a/Sources/ConfidenceProvider/Utils/ConfidenceMetadata.swift b/Sources/ConfidenceProvider/Utils/ConfidenceMetadata.swift deleted file mode 100644 index 6fb6a6cc..00000000 --- a/Sources/ConfidenceProvider/Utils/ConfidenceMetadata.swift +++ /dev/null @@ -1,6 +0,0 @@ -import OpenFeature - -public struct ConfidenceMetadata: ProviderMetadata { - public var name: String? = "SDK_ID_SWIFT_PROVIDER" - public var version: String? -} diff --git a/Sources/ConfidenceProvider/Utils/Extensions.swift b/Sources/ConfidenceProvider/Utils/Extensions.swift deleted file mode 100644 index 3165b0af..00000000 --- a/Sources/ConfidenceProvider/Utils/Extensions.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import Confidence - -extension [ResolvedValue] { - func toCacheData(context: ConfidenceStruct, resolveToken: String) -> StoredCacheData { - var cacheValues: [String: ResolvedValue] = [:] - - forEach { value in - cacheValues[value.flag] = value - } - - return StoredCacheData( - version: InMemoryProviderCache.currentVersion, - cache: cacheValues, - curResolveToken: resolveToken, - curEvalContextHash: context.hash() - ) - } -} - -/// Used for testing -public protocol DispatchQueueType { - func async(execute work: @escaping @convention(block) () -> Void) -} - -extension DispatchQueue: DispatchQueueType { - public func async(execute work: @escaping @convention(block) () -> Void) { - async(group: nil, qos: .unspecified, flags: [], execute: work) - } -} diff --git a/Sources/ConfidenceProvider/Utils/TypeMapper.swift b/Sources/ConfidenceProvider/Utils/TypeMapper.swift deleted file mode 100644 index adc0c8b8..00000000 --- a/Sources/ConfidenceProvider/Utils/TypeMapper.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation -import Common -import OpenFeature - -public enum TypeMapper { - static func from(value: Structure) -> NetworkStruct { - return NetworkStruct(fields: value.asMap().compactMapValues(convertValueToStructValue)) - } - - static func from(value: Value) throws -> NetworkStruct { - guard case .structure(let values) = value else { - throw OpenFeatureError.parseError(message: "Value must be a .structure") - } - - return NetworkStruct(fields: values.compactMapValues(convertValueToStructValue)) - } - - static func from( - object: NetworkStruct, schema: StructFlagSchema - ) - throws - -> Value - { - return .structure( - Dictionary( - uniqueKeysWithValues: try object.fields.map { field, value in - (field, try convertStructValueToValue(value, schema: schema.schema[field])) - })) - } - - static private func convertValueToStructValue(_ value: Value) -> NetworkValue? { - switch value { - case .boolean(let value): - return NetworkValue.boolean(value) - case .string(let value): - return NetworkValue.string(value) - case .integer(let value): - return NetworkValue.number(Double(value)) - case .double(let value): - return NetworkValue.number(value) - case .date(let value): - let timestampFormatter = ISO8601DateFormatter() - timestampFormatter.timeZone = TimeZone.init(identifier: "UTC") - let timestamp = timestampFormatter.string(from: value) - return NetworkValue.string(timestamp) - case .list(let values): - return .list(values.compactMap(convertValueToStructValue)) - case .structure(let values): - return .structure(NetworkStruct(fields: values.compactMapValues(convertValueToStructValue))) - case .null: - return NetworkValue.null - } - } - - // swiftlint:disable:next cyclomatic_complexity - static private func convertStructValueToValue( - _ structValue: NetworkValue, schema: FlagSchema? - ) throws -> Value { - guard let fieldType = schema else { - throw OpenFeatureError.parseError(message: "Mismatch between schema and value") - } - - switch structValue { - case .null: - return .null - case .number(let value): - switch fieldType { - case .intSchema: - return .integer(Int64(value)) - case .doubleSchema: - return .double(value) - default: - throw OpenFeatureError.parseError(message: "Number field must have schema type int or double") - } - case .string(let value): - return .string(value) - case .boolean(let value): - return .boolean(value) - case .structure(let mapValue): - guard case .structSchema(let structSchema) = fieldType else { - throw OpenFeatureError.parseError(message: "Field is struct in schema but something else in value") - } - return .structure( - Dictionary( - uniqueKeysWithValues: try mapValue.fields.map { field, fieldValue in - return (field, try convertStructValueToValue(fieldValue, schema: structSchema.schema[field])) - })) - case .list(let listValue): - guard case .listSchema(let listSchema) = fieldType else { - throw OpenFeatureError.parseError(message: "Field is list in schema but something else in value") - } - return .list( - try listValue.map { fieldValue in - try convertStructValueToValue(fieldValue, schema: listSchema) - } - ) - } - } -} diff --git a/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift b/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift deleted file mode 100644 index 045aa92a..00000000 --- a/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift +++ /dev/null @@ -1,1146 +0,0 @@ -// swiftlint:disable type_body_length -// swiftlint:disable file_length -import Foundation -import Confidence -import OpenFeature -import Combine -import XCTest - -@testable import ConfidenceProvider - -@available(macOS 13.0, iOS 16.0, *) -class ConfidenceFeatureProviderTest: XCTestCase { - private var flagApplier = FlagApplierMock() - private let builder = - ConfidenceFeatureProvider - .Builder(credentials: .clientSecret(secret: "test")) - private let storage = StorageMock() - private var readyExpectation = XCTestExpectation(description: "Ready") - override func setUp() { - try? storage.clear() - - MockedResolveClientURLProtocol.reset() - flagApplier = FlagApplierMock() - - super.setUp() - } - - // swiftlint:disable function_body_length - func testSlowFirstResolveWillbeCancelledOnSecondResolve() async throws { - let resolve1Completed = expectation(description: "First resolve completed") - let resolve2Started = expectation(description: "Second resolve has started") - let resolve2Continues = expectation(description: "Unlock second resolve") - let resolve2Cancelled = expectation(description: "Second resolve cancelled") - let resolve3Completed = expectation(description: "Third resolve completed") - - class FakeClient: XCTestCase, ConfidenceResolveClient { - var callCount = 0 - var resolveContexts: [ConfidenceStruct] = [] - let resolve1Completed: XCTestExpectation - let resolve2Started: XCTestExpectation - let resolve2Continues: XCTestExpectation - let resolve2Cancelled: XCTestExpectation - let resolve3Completed: XCTestExpectation - - init( - resolve1Completed: XCTestExpectation, - resolve2Started: XCTestExpectation, - resolve2Continues: XCTestExpectation, - resolve2Cancelled: XCTestExpectation, - resolve3Completed: XCTestExpectation - ) { - self.resolve1Completed = resolve1Completed - self.resolve2Started = resolve2Started - self.resolve2Continues = resolve2Continues - self.resolve2Cancelled = resolve2Cancelled - self.resolve3Completed = resolve3Completed - super.init(invocation: nil) // Workaround to use expectations in FakeClient - } - - func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { - callCount += 1 - switch callCount { - case 1: - if Task.isCancelled { - XCTFail("Resolve one was cancelled unexpectedly") - } else { - resolveContexts.append(ctx) - resolve1Completed.fulfill() - } - case 2: - resolve2Started.fulfill() - await fulfillment(of: [resolve2Continues], timeout: 5.0) - if Task.isCancelled { - resolve2Cancelled.fulfill() - return .init(resolvedValues: [], resolveToken: "") - } - XCTFail("This task should be cancelled and never reach here") - case 3: - if Task.isCancelled { - XCTFail("Resolve three was cancelled unexpectedly") - } else { - resolveContexts.append(ctx) - resolve3Completed.fulfill() - } - default: XCTFail("We expect only 3 resolve calls") - } - return .init(resolvedValues: [], resolveToken: "") - } - } - - let confidence = Confidence.Builder.init(clientSecret: "").build() - let client = FakeClient( - resolve1Completed: resolve1Completed, - resolve2Started: resolve2Started, - resolve2Continues: resolve2Continues, - resolve2Cancelled: resolve2Cancelled, - resolve3Completed: resolve3Completed - ) - let provider = ConfidenceFeatureProvider(confidence: confidence, session: nil, client: client) - // Initialize allows to start listening for context changes in "confidence" - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - // Let the internal "resolve" finish - await fulfillment(of: [resolve1Completed], timeout: 5.0) - confidence.putContext(key: "new", value: ConfidenceValue(string: "value")) - await fulfillment(of: [resolve2Started], timeout: 5.0) // Ensure resolve 2 starts before 3 - confidence.putContext(key: "new2", value: ConfidenceValue(string: "value2")) - await fulfillment(of: [resolve3Completed], timeout: 5.0) - resolve2Continues.fulfill() // Allow second resolve to continue, regardless if cancelled or not - await fulfillment(of: [resolve2Cancelled], timeout: 5.0) // Second resolve is cancelled - XCTAssertEqual(3, client.callCount) - XCTAssertEqual(2, client.resolveContexts.count) - XCTAssertEqual(confidence.getContext(), client.resolveContexts[1]) - } - // swiftlint:enable function_body_length - - func testRefresh() throws { - var session = MockedResolveClientURLProtocol.mockedSession(flags: [:]) - let provider = - builder - .with(session: session) - .with(storage: storage) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - XCTAssertThrowsError( - try provider.getStringEvaluation( - key: "flag.size", - defaultValue: "value", - context: MutableContext(targetingKey: "user1")) - ) { error in - XCTAssertEqual( - error as? OpenFeatureError, - OpenFeatureError.flagNotFoundError(key: "flag")) - } - - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user2": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - readyExpectation = XCTestExpectation(description: "Ready (2)") - session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - provider.onContextSet( - oldContext: MutableContext(targetingKey: "user1"), newContext: MutableContext(targetingKey: "user2")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: MutableContext(targetingKey: "user2")) - - XCTAssertEqual(evaluation.value, 3) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 2) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testResolveIntegerFlag() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(storage: storage) - .with(flagApplier: flagApplier) - .build() - - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, 3) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testResolveAndApplyIntegerFlag() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(storage: storage) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 1, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, 3) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testResolveAndApplyIntegerFlagNoSegmentMatch() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(storage: storage) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - let ctx = MutableContext(targetingKey: "user2") - provider.initialize(initialContext: ctx) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 1, - context: MutableContext(targetingKey: "user2")) - - XCTAssertEqual(evaluation.value, 1) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.defaultReason.rawValue) - XCTAssertEqual(evaluation.variant, nil) - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testResolveAndApplyIntegerFlagTwice() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(storage: storage) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 1, - context: MutableContext(targetingKey: "user1")) - _ = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 1, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, 3) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 2) - } - } - - func testResolveAndApplyIntegerFlagError() throws { - flagApplier = FlagApplierMock(expectedApplies: 2) - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - MockedResolveClientURLProtocol.failFirstApply = true - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(storage: storage) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 1, - context: MutableContext(targetingKey: "user1")) - _ = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 1, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, 3) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 2) - } - } - - func testCreateProviderUsingConfidenceContextResolvesCorrectly() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - - let confidence = Confidence - .Builder(clientSecret: "") - .build() - .withContext(["my_string": ConfidenceValue(string: "my_value")]) - - let provider = ConfidenceFeatureProvider(confidence: confidence, session: session, client: nil) - - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertTrue(MockedResolveClientURLProtocol - .resolveRequestFields.fields.contains { $0.key == "my_string" && $0.value == .string("my_value") } - ) - - XCTAssertTrue(MockedResolveClientURLProtocol - .resolveRequestFields.fields.contains { $0.key == "targeting_key" } - ) - - let requestTargetingKey = MockedResolveClientURLProtocol - .resolveRequestFields - .fields["targeting_key"] - - if case .string(let targetingKey) = requestTargetingKey { - XCTAssertTrue(!targetingKey.isEmpty) - } else { - XCTFail("targeting key could not be found") - } - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, 3) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - } - } - - func testStaleEvaluationContextInCache() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user0": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - - let provider = - builder - .with(session: session) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user0")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, 3) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.variant, "control") - XCTAssertEqual(evaluation.reason, Reason.stale.rawValue) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - // TODO: Check this - how do we check for something not called? - XCTAssertEqual(flagApplier.applyCallCount, 0) - } - } - - func testResolveDoubleFlag() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .double(3.1)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getDoubleEvaluation( - key: "flag.size", - defaultValue: 1.1, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, 3.1) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testResolveBooleanFlag() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["visible": .boolean(false)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getBooleanEvaluation( - key: "flag.visible", - defaultValue: true, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, false) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testResolveObjectFlag() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getObjectEvaluation( - key: "flag", - defaultValue: .structure(["size": .integer(0)]), - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, .structure(["size": .integer(3)])) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testResolveNullValues() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .null])) - ] - - let schemas: [String: StructFlagSchema] = [ - "user1": .init(schema: ["size": .intSchema]) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve, schemas: schemas) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 42, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.value, 42) - XCTAssertNil(evaluation.errorCode) - XCTAssertNil(evaluation.errorMessage) - XCTAssertEqual(evaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(evaluation.variant, "control") - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testProviderThrowsFlagNotFound() throws { - let session = MockedResolveClientURLProtocol.mockedSession(flags: [:]) - let provider = - builder - .with(session: session) - .with(storage: storage) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - XCTAssertThrowsError( - try provider.getObjectEvaluation( - key: "flag", - defaultValue: .structure(["size": .integer(0)]), - context: MutableContext(targetingKey: "user1")) - ) { error in - XCTAssertEqual( - error as? OpenFeatureError, - OpenFeatureError.flagNotFoundError(key: "flag")) - } - - // TODO: Check this - how do we check for something not called? - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 0) - } - } - - func testProviderTargetingKeyError() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - // Note: "custom_targeting_key" is treated specially in the MockedSession - provider.initialize( - initialContext: MutableContext( - targetingKey: "user1", - structure: MutableStructure(attributes: ["custom_targeting_key": Value.integer(2)]))) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 1, - context: MutableContext( - targetingKey: "user1", - structure: MutableStructure(attributes: ["custom_targeting_key": Value.integer(2)]))) - XCTAssertEqual(evaluation.value, 1) - XCTAssertNil(evaluation.variant) - XCTAssertEqual(evaluation.errorCode, ErrorCode.invalidContext) - XCTAssertEqual(evaluation.errorMessage, "Invalid targeting key") - XCTAssertEqual(evaluation.reason, Reason.error.rawValue) - - // TODO: Check this - how do we check for something not called? - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 0) - } - } - - func testProviderCannotParse() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(flagApplier: flagApplier) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - XCTAssertThrowsError( - try provider.getStringEvaluation( - key: "flag.size", - defaultValue: "value", - context: MutableContext(targetingKey: "user1")) - ) { error in - XCTAssertEqual( - error as? OpenFeatureError, OpenFeatureError.parseError(message: "Unable to parse flag value: 3")) - } - - // TODO: Check this - how do we check for something not called? - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - XCTAssertEqual(flagApplier.applyCallCount, 0) - } - } - - func testLocalOverrideReplacesFlag() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = builder.with(session: session) - .with(flagApplier: flagApplier) - .with(cache: AlwaysFailCache()) - .overrides(.flag(name: "flag", variant: "control", value: ["size": .integer(4)])) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.variant, "control") - XCTAssertEqual(evaluation.reason, Reason.staticReason.rawValue) - XCTAssertEqual(evaluation.value, 4) - - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - } - } - - func testLocalOverridePartiallyReplacesFlag() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3), "color": .string("green")])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = builder.with(session: session) - .with(flagApplier: flagApplier) - .overrides(.field(path: "flag.size", variant: "treatment", value: .integer(4))) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let sizeEvaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(sizeEvaluation.variant, "treatment") - XCTAssertEqual(sizeEvaluation.reason, Reason.staticReason.rawValue) - XCTAssertEqual(sizeEvaluation.value, 4) - - let colorEvaluation = try provider.getStringEvaluation( - key: "flag.color", - defaultValue: "blue", - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(colorEvaluation.variant, "control") - XCTAssertEqual(colorEvaluation.reason, Reason.targetingMatch.rawValue) - XCTAssertEqual(colorEvaluation.value, "green") - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testLocalOverrideNoEvaluationContext() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3), "color": .string("green")])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = builder.with(session: session) - .with(flagApplier: flagApplier) - .with(cache: AlwaysFailCache()) - .overrides(.field(path: "flag.size", variant: "treatment", value: .integer(4))) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - let sizeEvaluation1 = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: nil) - - XCTAssertEqual(sizeEvaluation1.variant, "treatment") - XCTAssertEqual(sizeEvaluation1.reason, Reason.staticReason.rawValue) - XCTAssertEqual(sizeEvaluation1.value, 4) - - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let sizeEvaluation2 = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(sizeEvaluation2.variant, "treatment") - XCTAssertEqual(sizeEvaluation2.reason, Reason.staticReason.rawValue) - XCTAssertEqual(sizeEvaluation2.value, 4) - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - } - } - - func testLocalOverrideTwiceTakesSecondOverride() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = builder.with(session: session) - .with(flagApplier: flagApplier) - .with(cache: AlwaysFailCache()) - .overrides(.field(path: "flag.size", variant: "control", value: .integer(4))) - .overrides(.field(path: "flag.size", variant: "treatment", value: .integer(5))) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.variant, "treatment") - XCTAssertEqual(evaluation.reason, Reason.staticReason.rawValue) - XCTAssertEqual(evaluation.value, 5) - - XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) - } - } - - func testRemovedKeyWillbeRemovedFromConfidenceContext() { - let expectationOneCall = expectation(description: "one call is made") - let twoCallsExpectation = expectation(description: "two calls is made") - class FakeClient: ConfidenceResolveClient { - var callCount = 0 - var oneCallExpectation: XCTestExpectation - var twoCallsExpectation: XCTestExpectation - init(oneCallExpectation: XCTestExpectation, twoCallsExpectation: XCTestExpectation) { - self.oneCallExpectation = oneCallExpectation - self.twoCallsExpectation = twoCallsExpectation - } - - func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { - callCount += 1 - if callCount == 1 { - self.oneCallExpectation.fulfill() - } else if callCount == 2 { - self.twoCallsExpectation.fulfill() - } - return .init(resolvedValues: [], resolveToken: "") - } - } - - let confidence = Confidence.Builder.init(clientSecret: "").build() - let client = FakeClient(oneCallExpectation: expectationOneCall, twoCallsExpectation: twoCallsExpectation) - let provider = ConfidenceFeatureProvider(confidence: confidence, session: nil, client: client) - let initialContext = MutableContext(targetingKey: "user1") - .add(key: "hello", value: Value.string("world")) - provider.initialize(initialContext: initialContext) - let expectedInitialContext = [ - "targeting_key": ConfidenceValue(string: "user1"), - "hello": ConfidenceValue(string: "world") - ] - XCTAssertEqual(confidence.getContext(), expectedInitialContext) - let expectedNewContext = [ - "targeting_key": ConfidenceValue(string: "user1"), - "new": ConfidenceValue(string: "west world") - ] - let newContext = MutableContext(targetingKey: "user1") - .add(key: "new", value: Value.string("west world")) - wait(for: [expectationOneCall], timeout: 1) - XCTAssertEqual(1, client.callCount) - provider.onContextSet(oldContext: initialContext, newContext: newContext) - XCTAssertEqual(confidence.getContext(), expectedNewContext) - wait(for: [twoCallsExpectation], timeout: 1) - XCTAssertEqual(2, client.callCount) - } - - func testOverridingInProvider() throws { - let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ - "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) - ] - - let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ - "flags/flag": .init(resolve: resolve) - ] - - let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let provider = - builder - .with(session: session) - .with(flagApplier: flagApplier) - .with(cache: AlwaysFailCache()) - .build() - try withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - - provider.overrides(.field(path: "flag.size", variant: "treatment", value: .integer(5))) - - let evaluation = try provider.getIntegerEvaluation( - key: "flag.size", - defaultValue: 0, - context: MutableContext(targetingKey: "user1")) - - XCTAssertEqual(evaluation.variant, "treatment") - XCTAssertEqual(evaluation.reason, Reason.staticReason.rawValue) - XCTAssertEqual(evaluation.value, 5) - } - } - - func testConfidenceContextOnInitialize() throws { - let confidence = Confidence.Builder.init(clientSecret: "").build() - let provider = ConfidenceFeatureProvider(confidence: confidence) - - withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - provider.initialize(initialContext: MutableContext(targetingKey: "user1")) - wait(for: [readyExpectation], timeout: 5) - let context = confidence.getContext() - let expected = ["targeting_key": ConfidenceValue(string: "user1")] - XCTAssertEqual(context, expected) - } - } - - func testConfidenceContextOnContextChange() throws { - let confidence = Confidence.Builder.init(clientSecret: "").build() - let provider = ConfidenceFeatureProvider(confidence: confidence) - - let readyExpectation = self.expectation(description: "Waiting for init and ctx change to complete") - readyExpectation.expectedFulfillmentCount = 2 - - withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - readyExpectation.fulfill() - } - }) - { - let ctx1 = MutableContext(targetingKey: "user1") - let ctx2 = MutableContext( - targetingKey: "user1", - structure: MutableStructure(attributes: ["active": Value.boolean(true)])) - provider.initialize(initialContext: ctx1) - provider.onContextSet(oldContext: ctx1, newContext: ctx2) - wait(for: [readyExpectation], timeout: 5) - let context = confidence.getContext() - let expected: ConfidenceStruct = [ - "targeting_key": ConfidenceValue(string: "user1"), - "active": ConfidenceValue(boolean: true) - ] - XCTAssertEqual(context, expected) - } - } - - func testConfidenceContextOnContextChangeThroughConfidence() throws { - class FakeClient: ConfidenceResolveClient { - var callCount = 0 - func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { - callCount += 1 - return .init(resolvedValues: [], resolveToken: "") - } - } - - let confidence = Confidence.Builder.init(clientSecret: "").build() - let client = FakeClient() - let provider = ConfidenceFeatureProvider(confidence: confidence, session: nil, client: client) - - let readyExpectation = self.expectation(description: "Waiting for init and ctx change to complete") - readyExpectation.expectedFulfillmentCount = 2 - - withExtendedLifetime( - provider.observe().sink { event in - if event == .ready { - readyExpectation.fulfill() - } - }) - { - let ctx1 = MutableContext(targetingKey: "user1") - provider.initialize(initialContext: ctx1) - confidence.putContext(key: "active", value: ConfidenceValue.init(boolean: true)) - wait(for: [readyExpectation], timeout: 5) - XCTAssertEqual(client.callCount, 2) - } - } -} - -final class DispatchQueueFake: DispatchQueueType { - var count = 0 - - func async(execute work: @escaping @convention(block) () -> Void) { - count += 1 - work() - } -} -// swiftlint:enable type_body_length diff --git a/Tests/ConfidenceProviderTests/ConfidenceIntegrationTest.swift b/Tests/ConfidenceProviderTests/ConfidenceIntegrationTest.swift deleted file mode 100644 index 8b46e4c6..00000000 --- a/Tests/ConfidenceProviderTests/ConfidenceIntegrationTest.swift +++ /dev/null @@ -1,223 +0,0 @@ -import Foundation -import Common -import OpenFeature -import XCTest - -@testable import ConfidenceProvider - -class ConfidenceIntegrationTests: XCTestCase { - let clientToken: String? = ProcessInfo.processInfo.environment["CLIENT_TOKEN"] - let resolveFlag = setResolveFlag() - let storage: Storage = StorageMock() - private var readyExpectation = XCTestExpectation(description: "Ready") - - private static func setResolveFlag() -> String { - if let flag = ProcessInfo.processInfo.environment["TEST_FLAG_NAME"], !flag.isEmpty { - return flag - } - return "swift-test-flag" - } - - override func setUp() async throws { - OpenFeatureAPI.shared.clearProvider() - OpenFeatureAPI.shared.setEvaluationContext(evaluationContext: MutableContext()) - try await super.setUp() - } - - func testConfidenceFeatureIntegration() throws { - guard let clientToken = self.clientToken else { - throw TestError.missingClientToken - } - - withExtendedLifetime( - OpenFeatureAPI.shared.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - OpenFeatureAPI.shared.setProvider( - provider: - ConfidenceFeatureProvider.Builder(credentials: .clientSecret(secret: clientToken)) - .build()) - let client = OpenFeatureAPI.shared.getClient() - wait(for: [readyExpectation], timeout: 5) - - self.readyExpectation = XCTestExpectation(description: "Ready (2)") - let ctx = MutableContext( - targetingKey: "user_foo", - structure: MutableStructure(attributes: ["user": Value.structure(["country": Value.string("SE")])])) - OpenFeatureAPI.shared.setEvaluationContext(evaluationContext: ctx) - wait(for: [readyExpectation], timeout: 5) - - let intResult = client.getIntegerDetails(key: "\(resolveFlag).my-integer", defaultValue: 1) - let boolResult = client.getBooleanDetails(key: "\(resolveFlag).my-boolean", defaultValue: false) - - XCTAssertEqual(intResult.flagKey, "\(resolveFlag).my-integer") - XCTAssertEqual(intResult.reason, Reason.targetingMatch.rawValue) - XCTAssertNotNil(intResult.variant) - XCTAssertNil(intResult.errorCode) - XCTAssertNil(intResult.errorMessage) - XCTAssertEqual(boolResult.flagKey, "\(resolveFlag).my-boolean") - XCTAssertEqual(boolResult.reason, Reason.targetingMatch.rawValue) - XCTAssertNotNil(boolResult.variant) - XCTAssertNil(boolResult.errorCode) - XCTAssertNil(boolResult.errorMessage) - } - } - - func testConfidenceFeatureApplies() throws { - guard let clientToken = self.clientToken else { - throw TestError.missingClientToken - } - - let flagApplier = FlagApplierMock() - - let confidenceFeatureProvider = ConfidenceFeatureProvider.Builder( - credentials: .clientSecret(secret: clientToken) - ) - .with(flagApplier: flagApplier) - .with(storage: storage) - .build() - - withExtendedLifetime( - OpenFeatureAPI.shared.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - OpenFeatureAPI.shared.setProvider(provider: confidenceFeatureProvider) - wait(for: [readyExpectation], timeout: 5) - - self.readyExpectation = XCTestExpectation(description: "Ready (2)") - let ctx = MutableContext( - targetingKey: "user_foo", - structure: MutableStructure(attributes: ["user": Value.structure(["country": Value.string("SE")])])) - OpenFeatureAPI.shared.setEvaluationContext(evaluationContext: ctx) - wait(for: [readyExpectation], timeout: 5) - - let client = OpenFeatureAPI.shared.getClient() - OpenFeatureAPI.shared.setEvaluationContext(evaluationContext: ctx) - - let result = client.getIntegerDetails(key: "\(resolveFlag).my-integer", defaultValue: 1) - - XCTAssertEqual(result.reason, Reason.targetingMatch.rawValue) - XCTAssertNotNil(result.variant) - XCTAssertNil(result.errorCode) - XCTAssertNil(result.errorMessage) - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testConfidenceFeatureApplies_dateSupport() throws { - guard let clientToken = self.clientToken else { - throw TestError.missingClientToken - } - - let flagApplier = FlagApplierMock() - - let confidenceFeatureProvider = ConfidenceFeatureProvider.Builder( - credentials: .clientSecret(secret: clientToken) - ) - .with(flagApplier: flagApplier) - .with(storage: storage) - .build() - try withExtendedLifetime( - OpenFeatureAPI.shared.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - OpenFeatureAPI.shared.setProvider(provider: confidenceFeatureProvider) - wait(for: [readyExpectation], timeout: 5) - let date = try XCTUnwrap(convertStringToDate("2023-07-24T09:00:00Z")) - - // Given mutable context with date - let ctx = MutableContext( - targetingKey: "user_foo", - structure: MutableStructure(attributes: [ - "date": Value.date(date) - ]) - ) - - self.readyExpectation = XCTestExpectation(description: "Ready (2)") - OpenFeatureAPI.shared.setEvaluationContext(evaluationContext: ctx) - wait(for: [readyExpectation], timeout: 5) - - let client = OpenFeatureAPI.shared.getClient() - - // When evaluation of the flag happens using date context - let result = client.getIntegerDetails(key: "\(resolveFlag).my-integer", defaultValue: 1) - - // Then there is targeting match (non-default targeting) - XCTAssertEqual(result.reason, Reason.targetingMatch.rawValue) - XCTAssertNotNil(result.variant) - XCTAssertNil(result.errorCode) - XCTAssertNil(result.errorMessage) - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - func testConfidenceFeatureNoSegmentMatch() throws { - guard let clientToken = self.clientToken else { - throw TestError.missingClientToken - } - - let flagApplier = FlagApplierMock() - - let confidenceFeatureProvider = ConfidenceFeatureProvider.Builder( - credentials: .clientSecret(secret: clientToken) - ) - .with(flagApplier: flagApplier) - .with(storage: storage) - .build() - withExtendedLifetime( - OpenFeatureAPI.shared.observe().sink { event in - if event == .ready { - self.readyExpectation.fulfill() - } - }) - { - OpenFeatureAPI.shared.setProvider(provider: confidenceFeatureProvider) - wait(for: [readyExpectation], timeout: 5) - - self.readyExpectation = XCTestExpectation(description: "Ready (2)") - let ctx = MutableContext( - targetingKey: "user_foo", - structure: MutableStructure(attributes: ["user": Value.structure(["country": Value.string("IT")])])) - OpenFeatureAPI.shared.setEvaluationContext(evaluationContext: ctx) - wait(for: [readyExpectation], timeout: 5) - - let client = OpenFeatureAPI.shared.getClient() - - let result = client.getIntegerDetails(key: "\(resolveFlag).my-integer", defaultValue: 1) - - XCTAssertEqual(result.value, 1) - XCTAssertNil(result.variant) - XCTAssertEqual(result.reason, Reason.defaultReason.rawValue) - XCTAssertNil(result.errorCode) - XCTAssertNil(result.errorMessage) - - wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(flagApplier.applyCallCount, 1) - } - } - - // MARK: Helper - - private func convertStringToDate(_ dateString: String) -> Date? { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - return dateFormatter.date(from: dateString) - } -} - -enum TestError: Error { - case missingClientToken -} diff --git a/Tests/ConfidenceProviderTests/Helpers/AlwaysFailCache.swift b/Tests/ConfidenceProviderTests/Helpers/AlwaysFailCache.swift deleted file mode 100644 index 6e67ec97..00000000 --- a/Tests/ConfidenceProviderTests/Helpers/AlwaysFailCache.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation -import Common -import Confidence -import OpenFeature - -@testable import ConfidenceProvider - -public class AlwaysFailCache: ProviderCache { - public func getValue( - flag: String, contextHash: String - ) throws -> CacheGetValueResult? { - throw ConfidenceError.cacheError(message: "Always Fails (getValue)") - } - - public func clearAndSetValues( - values: [ResolvedValue], ctx: EvaluationContext, resolveToken: String - ) throws { - // no-op - } -} diff --git a/Tests/ConfidenceProviderTests/LocalStorageResolverTest.swift b/Tests/ConfidenceProviderTests/LocalStorageResolverTest.swift deleted file mode 100644 index 7bc970c1..00000000 --- a/Tests/ConfidenceProviderTests/LocalStorageResolverTest.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation -import Common -import Confidence -import OpenFeature -import XCTest - -@testable import ConfidenceProvider - -class LocalStorageResolverTest: XCTestCase { - func testStaleValueFromCache() throws { - let cache = TestCache(returnType: .oldValue) - let resolver = LocalStorageResolver(cache: cache) - - let ctx = MutableContext(targetingKey: "key", structure: MutableStructure()) - XCTAssertNoThrow( - try resolver.resolve(flag: "test", contextHash: ConfidenceTypeMapper.from(ctx: ctx).hash()) - ) - } - - func testMissingValueFromCache() throws { - let cache = TestCache(returnType: .noValue) - let resolver = LocalStorageResolver(cache: cache) - - let ctx = MutableContext(targetingKey: "key", structure: MutableStructure()) - XCTAssertThrowsError( - try resolver.resolve(flag: "test", contextHash: ConfidenceTypeMapper.from(ctx: ctx).hash()) - ) { error in - XCTAssertEqual( - error as? OpenFeatureError, OpenFeatureError.flagNotFoundError(key: "test")) - } - } -} - -class TestCache: ProviderCache { - private let returnType: ReturnType - private let mockedResolvedValue = ResolvedValue(flag: "flag1", resolveReason: .match) - - init(returnType: ReturnType) { - self.returnType = returnType - } - - func getValue(flag: String, contextHash: String) -> ConfidenceProvider.CacheGetValueResult? { - switch returnType { - case .noValue: - return nil - case .oldValue: - return CacheGetValueResult(resolvedValue: mockedResolvedValue, needsUpdate: true, resolveToken: "tok1") - } - } - - func clearAndSetValues( - values: [ConfidenceProvider.ResolvedValue], ctx: OpenFeature.EvaluationContext, resolveToken: String - ) {} - - func getCurResolveToken() -> String? { - return nil - } -} - -extension TestCache { - enum ReturnType { - case noValue - case oldValue - } -} diff --git a/Tests/ConfidenceProviderTests/PersistentProviderCacheTest.swift b/Tests/ConfidenceProviderTests/PersistentProviderCacheTest.swift deleted file mode 100644 index ee43ddf7..00000000 --- a/Tests/ConfidenceProviderTests/PersistentProviderCacheTest.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import Common -import OpenFeature -import XCTest - -@testable import ConfidenceProvider - -class PersistentProviderCacheTest: XCTestCase { - lazy var cache = InMemoryProviderCache.from(storage: storage) - let storage = DefaultStorage(filePath: "resolver.flags.cache") - - override func setUp() { - try? storage.clear() - - super.setUp() - } - - func testCacheStoresValues() throws { - let flag = "flag" - let resolveToken = "resolveToken1" - let ctx = MutableContext(targetingKey: "key", structure: MutableStructure()) - let value = ResolvedValue( - value: Value.double(3.14), - flag: flag, - resolveReason: .match) - - let context = ConfidenceTypeMapper.from(ctx: ctx) - try storage.save(data: [value].toCacheData(context: context, resolveToken: resolveToken)) - cache = InMemoryProviderCache.from(storage: storage) - - let cachedValue = try cache.getValue(flag: flag, contextHash: ConfidenceTypeMapper.from(ctx: ctx).hash()) - XCTAssertEqual(cachedValue?.resolvedValue, value) - XCTAssertFalse(cachedValue?.needsUpdate ?? true) - XCTAssertFalse(cachedValue?.needsUpdate ?? true) - XCTAssertEqual(cachedValue?.resolveToken, resolveToken) - } - - func testCachePersistsData() throws { - let flag1 = "flag1" - let flag2 = "flag2" - let resolveToken = "resolveToken1" - let ctx = MutableContext(targetingKey: "key", structure: MutableStructure()) - let value1 = ResolvedValue( - value: Value.double(3.14), - flag: "flag1", - resolveReason: .match) - let value2 = ResolvedValue( - value: Value.string("test"), - flag: "flag2", - resolveReason: .match) - XCTAssertFalse(try FileManager.default.fileExists(atPath: storage.getConfigUrl().backport.path)) - - let context = ConfidenceTypeMapper.from(ctx: ctx) - try storage.save(data: [value1, value2].toCacheData(context: context, resolveToken: resolveToken)) - cache = InMemoryProviderCache.from(storage: storage) - - expectToEventually( - (try? FileManager.default.fileExists(atPath: storage.getConfigUrl().backport.path)) ?? false) - - let newCache = InMemoryProviderCache.from( - storage: DefaultStorage(filePath: "resolver.flags.cache")) - let contextHash = ConfidenceTypeMapper.from(ctx: ctx).hash() - let cachedValue1 = try newCache.getValue(flag: flag1, contextHash: contextHash) - let cachedValue2 = try newCache.getValue(flag: flag2, contextHash: contextHash) - XCTAssertEqual(cachedValue1?.resolvedValue, value1) - XCTAssertEqual(cachedValue2?.resolvedValue, value2) - XCTAssertEqual(cachedValue1?.needsUpdate, false) - XCTAssertEqual(cachedValue2?.needsUpdate, false) - XCTAssertEqual(cachedValue1?.resolveToken, resolveToken) - XCTAssertEqual(cachedValue2?.resolveToken, resolveToken) - } - - func testNoValueFound() throws { - let ctx = MutableContext(targetingKey: "key", structure: MutableStructure()) - - try storage.clear() - - let contextHash = ConfidenceTypeMapper.from(ctx: ctx).hash() - let cachedValue = try cache.getValue(flag: "flag", contextHash: contextHash) - XCTAssertNil(cachedValue?.resolvedValue.value) - } - - func testChangedContextRequiresUpdate() throws { - let flag = "flag" - let resolveToken = "resolveToken1" - let ctx1 = MutableContext(targetingKey: "key", structure: MutableStructure(attributes: ["test": .integer(3)])) - let ctx2 = MutableContext(targetingKey: "key", structure: MutableStructure(attributes: ["test": .integer(4)])) - - let value = ResolvedValue( - value: Value.double(3.14), - flag: flag, - resolveReason: .match) - let context = ConfidenceTypeMapper.from(ctx: ctx1) - try storage.save(data: [value].toCacheData(context: context, resolveToken: resolveToken)) - cache = InMemoryProviderCache.from(storage: storage) - - let contextHash = ConfidenceTypeMapper.from(ctx: ctx2).hash() - let cachedValue = try cache.getValue(flag: flag, contextHash: contextHash) - XCTAssertEqual(cachedValue?.resolvedValue, value) - XCTAssertTrue(cachedValue?.needsUpdate ?? false) - } -} diff --git a/Tests/ConfidenceProviderTests/CacheDataInteractorTests.swift b/Tests/ConfidenceTests/CacheDataInteractorTests.swift similarity index 98% rename from Tests/ConfidenceProviderTests/CacheDataInteractorTests.swift rename to Tests/ConfidenceTests/CacheDataInteractorTests.swift index e9425a69..3c750617 100644 --- a/Tests/ConfidenceProviderTests/CacheDataInteractorTests.swift +++ b/Tests/ConfidenceTests/CacheDataInteractorTests.swift @@ -2,7 +2,7 @@ import Foundation import OpenFeature import XCTest -@testable import ConfidenceProvider +@testable import Confidence final class CacheDataInteractorTests: XCTestCase { func testCacheDataInteractor_loadsEventsFromStorage() async throws { diff --git a/Tests/ConfidenceProviderTests/CacheDataTests.swift b/Tests/ConfidenceTests/CacheDataTests.swift similarity index 99% rename from Tests/ConfidenceProviderTests/CacheDataTests.swift rename to Tests/ConfidenceTests/CacheDataTests.swift index 38ef7789..e102619a 100644 --- a/Tests/ConfidenceProviderTests/CacheDataTests.swift +++ b/Tests/ConfidenceTests/CacheDataTests.swift @@ -2,7 +2,7 @@ import Foundation import OpenFeature import XCTest -@testable import ConfidenceProvider +@testable import Confidence final class CacheDataTests: XCTestCase { func testCacheData_addEvent_emptyCache() throws { diff --git a/Tests/ConfidenceTests/ConfidenceFeatureProviderTest.swift b/Tests/ConfidenceTests/ConfidenceFeatureProviderTest.swift new file mode 100644 index 00000000..ea666157 --- /dev/null +++ b/Tests/ConfidenceTests/ConfidenceFeatureProviderTest.swift @@ -0,0 +1,583 @@ +// swiftlint:disable type_body_length +// swiftlint:disable file_length +import Foundation +import Combine +import XCTest + +@testable import Confidence + +@available(macOS 13.0, iOS 16.0, *) +class ConfidenceFeatureProviderTest: XCTestCase { + private var flagApplier = FlagApplierMock() + private let storage = StorageMock() + private var readyExpectation = XCTestExpectation(description: "Ready") + override func setUp() { + try? storage.clear() + + MockedResolveClientURLProtocol.reset() + flagApplier = FlagApplierMock() + + super.setUp() + } + + // swiftlint:disable function_body_length + func testSlowFirstResolveWillbeCancelledOnSecondResolve() async throws { + let resolve1Completed = expectation(description: "First resolve completed") + let resolve2Started = expectation(description: "Second resolve has started") + let resolve2Continues = expectation(description: "Unlock second resolve") + let resolve2Cancelled = expectation(description: "Second resolve cancelled") + let resolve3Completed = expectation(description: "Third resolve completed") + + class FakeClient: XCTestCase, ConfidenceResolveClient { + var callCount = 0 + var resolveContexts: [ConfidenceStruct] = [] + let resolve1Completed: XCTestExpectation + let resolve2Started: XCTestExpectation + let resolve2Continues: XCTestExpectation + let resolve2Cancelled: XCTestExpectation + let resolve3Completed: XCTestExpectation + + init( + resolve1Completed: XCTestExpectation, + resolve2Started: XCTestExpectation, + resolve2Continues: XCTestExpectation, + resolve2Cancelled: XCTestExpectation, + resolve3Completed: XCTestExpectation + ) { + self.resolve1Completed = resolve1Completed + self.resolve2Started = resolve2Started + self.resolve2Continues = resolve2Continues + self.resolve2Cancelled = resolve2Cancelled + self.resolve3Completed = resolve3Completed + super.init(invocation: nil) // Workaround to use expectations in FakeClient + } + + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + callCount += 1 + switch callCount { + case 1: + if Task.isCancelled { + XCTFail("Resolve one was cancelled unexpectedly") + } else { + resolveContexts.append(ctx) + resolve1Completed.fulfill() + } + case 2: + resolve2Started.fulfill() + await fulfillment(of: [resolve2Continues], timeout: 5.0) + if Task.isCancelled { + resolve2Cancelled.fulfill() + return .init(resolvedValues: [], resolveToken: "") + } + XCTFail("This task should be cancelled and never reach here") + case 3: + if Task.isCancelled { + XCTFail("Resolve three was cancelled unexpectedly") + } else { + resolveContexts.append(ctx) + resolve3Completed.fulfill() + } + default: XCTFail("We expect only 3 resolve calls") + } + return .init(resolvedValues: [], resolveToken: "") + } + } + let client = FakeClient( + resolve1Completed: resolve1Completed, + resolve2Started: resolve2Started, + resolve2Continues: resolve2Continues, + resolve2Cancelled: resolve2Cancelled, + resolve3Completed: resolve3Completed + ) + let confidence = Confidence.Builder.init(clientSecret: "") + .withContext(initialContext: ["targeting_key": .init(string: "user1")]) + .withFlagResolverClient(flagResolver: client) + .build() + + try await confidence.fetchAndActivate() + // Initialize allows to start listening for context changes in "confidence" + // Let the internal "resolve" finish + await fulfillment(of: [resolve1Completed], timeout: 5.0) + confidence.putContext(key: "new", value: ConfidenceValue(string: "value")) + await fulfillment(of: [resolve2Started], timeout: 5.0) // Ensure resolve 2 starts before 3 + confidence.putContext(key: "new2", value: ConfidenceValue(string: "value2")) + await fulfillment(of: [resolve3Completed], timeout: 5.0) + resolve2Continues.fulfill() // Allow second resolve to continue, regardless if cancelled or not + await fulfillment(of: [resolve2Cancelled], timeout: 5.0) // Second resolve is cancelled + XCTAssertEqual(3, client.callCount) + XCTAssertEqual(2, client.resolveContexts.count) + XCTAssertEqual(confidence.getContext(), client.resolveContexts[1]) + } + // swiftlint:enable function_body_length + + func testRefresh() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + let client = FakeClient() + let confidence = Confidence.Builder(clientSecret: "test") + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + + XCTAssertThrowsError( + try confidence.getEvaluation( + key: "flag.size", + defaultValue: "value" + )) { error in + XCTAssertEqual( + error as? ConfidenceError, + ConfidenceError.flagNotFoundError(key: "flag")) + } + + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let expectation = expectation(description: "context is synced") + let cancellable = confidence.contextReconciliatedChanges.sink { _ in + expectation.fulfill() + } + confidence.putContext(context: ["targeting_key": .init(string: "user2")]) + await fulfillment(of: [expectation], timeout: 1) + cancellable.cancel() + + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: 0) + + XCTAssertEqual(client.resolveStats, 2) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 2) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testResolveIntegerFlag() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: 0) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + + func testResolveAndApplyIntegerFlagNoSegmentMatch() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .noSegmentMatch) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user1")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: 0) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, 0) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .noSegmentMatch) + XCTAssertNil(evaluation.variant) + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testResolveAndApplyIntegerFlagTwice() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: 0) + + _ = try confidence.getEvaluation( + key: "flag.size", + defaultValue: 0) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 2) + } + + func testStaleEvaluationContextInCache() async throws { + class FakeClient: XCTestCase, ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + if self.resolveStats == 1 { + let expectation = expectation(description: "never fullfil") + await fulfillment(of: [expectation]) + } + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + confidence.putContext(context: ["hello": .init(string: "world")]) + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: 0) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .stale) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testResolveDoubleFlag() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(double: 3.14)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: 0.0) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, 3.14) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testResolveBooleanFlag() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(boolean: true)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: false) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, true) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testResolveObjectFlag() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + let value = ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(structure: ["boolean": .init(boolean: true)])]), + flag: "flag", + resolveReason: .match + ) + client.resolvedValues = [value] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: [:]) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value as? ConfidenceStruct, ["boolean": .init(boolean: true)]) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testResolveNullValues() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(null: ())]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: 42) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, 42) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 5) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testProviderThrowsFlagNotFound() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + XCTAssertThrowsError( + try confidence.getEvaluation( + key: "flag.size", + defaultValue: 42 + ) + ) { error in + XCTAssertEqual(error as? ConfidenceError, ConfidenceError.flagNotFoundError(key: "flag")) + } + + XCTAssertEqual(client.resolveStats, 1) + } + + func testProviderTargetingKeyError() async throws { + class FakeClient: ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = + [ResolvedValue(flag: "flag", resolveReason: .targetingKeyError)] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + let evaluation = try confidence.getEvaluation( + key: "flag.size", + defaultValue: 42) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, 42) + XCTAssertEqual(evaluation.errorCode, .invalidContext) + XCTAssertEqual(evaluation.errorMessage, "Invalid targeting key") + XCTAssertEqual(evaluation.reason, .targetingKeyError) + XCTAssertEqual(evaluation.variant, nil) + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(flagApplier.applyCallCount, 0) + } +} + +final class DispatchQueueFake: DispatchQueueType { + var count = 0 + + func async(execute work: @escaping @convention(block) () -> Void) { + count += 1 + work() + } +} +// swiftlint:enable type_body_length diff --git a/Tests/ConfidenceTests/ConfidenceIntegrationTest.swift b/Tests/ConfidenceTests/ConfidenceIntegrationTest.swift new file mode 100644 index 00000000..eeae2371 --- /dev/null +++ b/Tests/ConfidenceTests/ConfidenceIntegrationTest.swift @@ -0,0 +1,147 @@ +import Foundation +import XCTest + +@testable import Confidence + +class ConfidenceIntegrationTests: XCTestCase { + let clientToken: String? = ProcessInfo.processInfo.environment["CLIENT_TOKEN"] + let resolveFlag = setResolveFlag() + let storage: Storage = StorageMock() + private var readyExpectation = XCTestExpectation(description: "Ready") + + private static func setResolveFlag() -> String { + if let flag = ProcessInfo.processInfo.environment["TEST_FLAG_NAME"], !flag.isEmpty { + return flag + } + return "swift-test-flag" + } + + func testConfidenceFeatureIntegration() async throws { + guard let clientToken = self.clientToken else { + throw TestError.missingClientToken + } + + let ctx: ConfidenceStruct = [ + "targeting_key": .init(string: "user_foo"), + "user": .init(structure: ["country": .init(string: "SE")]) + ] + + let confidence = Confidence.Builder(clientSecret: clientToken) + .withContext(initialContext: ctx) + .build() + try await confidence.fetchAndActivate() + let intResult = try confidence.getEvaluation(key: "\(resolveFlag).my-integer", defaultValue: "1") + let boolResult = try confidence.getEvaluation(key: "\(resolveFlag).my-boolean", defaultValue: false) + + + XCTAssertEqual(intResult.reason, .match) + XCTAssertNotNil(intResult.variant) + XCTAssertNil(intResult.errorCode) + XCTAssertNil(intResult.errorMessage) + XCTAssertEqual(boolResult.reason, .match) + XCTAssertNotNil(boolResult.variant) + XCTAssertNil(boolResult.errorCode) + XCTAssertNil(boolResult.errorMessage) + } + + func testConfidenceFeatureApplies() async throws { + guard let clientToken = self.clientToken else { + throw TestError.missingClientToken + } + + let flagApplier = FlagApplierMock() + + let ctx: ConfidenceStruct = [ + "targeting_key": .init(string: "user_foo"), + "user": .init(structure: ["country": .init(string: "SE")]) + ] + + let confidence = Confidence.Builder(clientSecret: clientToken) + .withFlagApplier(flagApplier: flagApplier) + .withStorage(storage: storage) + .withContext(initialContext: ctx) + .build() + try await confidence.fetchAndActivate() + + let result = try confidence.getEvaluation(key: "\(resolveFlag).my-integer", defaultValue: 1) + + XCTAssertEqual(result.reason, .match) + XCTAssertNotNil(result.variant) + XCTAssertNil(result.errorCode) + XCTAssertNil(result.errorMessage) + + await fulfillment(of: [flagApplier.applyExpectation], timeout: 5) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testConfidenceFeatureApplies_dateSupport() async throws { + guard let clientToken = self.clientToken else { + throw TestError.missingClientToken + } + + let flagApplier = FlagApplierMock() + let ctx: ConfidenceStruct = [ + "targeting_key": .init(string: "user_foo"), + "user": .init(structure: ["country": .init(string: "SE")]) + ] + let confidence = Confidence.Builder(clientSecret: clientToken) + .withFlagApplier(flagApplier: flagApplier) + .withContext(initialContext: ctx) + .withStorage(storage: storage) + .build() + try await confidence.fetchAndActivate() + // When evaluation of the flag happens using date context + let result = try confidence.getEvaluation(key: "\(resolveFlag).my-integer", defaultValue: 1) + // Then there is targeting match (non-default targeting) + XCTAssertEqual(result.reason, .match) + XCTAssertNotNil(result.variant) + XCTAssertNil(result.errorCode) + XCTAssertNil(result.errorMessage) + + await fulfillment(of: [flagApplier.applyExpectation], timeout: 5) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testConfidenceFeatureNoSegmentMatch() async throws { + guard let clientToken = self.clientToken else { + throw TestError.missingClientToken + } + + let flagApplier = FlagApplierMock() + + let ctx: ConfidenceStruct = [ + "targeting_key": .init(string: "user_foo"), + "user": .init(structure: ["country": .init(string: "IT")]) + ] + + let confidence = Confidence.Builder(clientSecret: clientToken) + .withFlagApplier(flagApplier: flagApplier) + .withStorage(storage: storage) + .withContext(initialContext: ctx) + .build() + try await confidence.fetchAndActivate() + // When evaluation of the flag happens using date context + let result = try confidence.getEvaluation(key: "\(resolveFlag).my-integer", defaultValue: 1) + // Then there is targeting match (non-default targeting) + XCTAssertEqual(result.value, 1) + XCTAssertEqual(result.reason, .noSegmentMatch) + XCTAssertNil(result.variant) + XCTAssertNil(result.errorCode) + XCTAssertNil(result.errorMessage) + + await fulfillment(of: [flagApplier.applyExpectation], timeout: 5) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + // MARK: Helper + + private func convertStringToDate(_ dateString: String) -> Date? { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + return dateFormatter.date(from: dateString) + } +} + +enum TestError: Error { + case missingClientToken +} diff --git a/Tests/ConfidenceTests/ConfidenceTests.swift b/Tests/ConfidenceTests/ConfidenceTests.swift index c32d02fb..61a951be 100644 --- a/Tests/ConfidenceTests/ConfidenceTests.swift +++ b/Tests/ConfidenceTests/ConfidenceTests.swift @@ -1,13 +1,22 @@ import XCTest @testable import Confidence +// swiftlint:disable type_body_length final class ConfidenceTests: XCTestCase { func testWithContext() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidenceParent = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), context: ["k1": ConfidenceValue(string: "v1")] ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( @@ -21,11 +30,19 @@ final class ConfidenceTests: XCTestCase { } func testWithContextUpdateParent() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidenceParent = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), context: ["k1": ConfidenceValue(string: "v1")], parent: nil ) @@ -44,11 +61,19 @@ final class ConfidenceTests: XCTestCase { } func testUpdateLocalContext() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidence = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), context: ["k1": ConfidenceValue(string: "v1")], parent: nil ) @@ -62,11 +87,19 @@ final class ConfidenceTests: XCTestCase { } func testUpdateLocalContextWithoutOverride() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidenceParent = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), context: ["k1": ConfidenceValue(string: "v1")], parent: nil ) @@ -84,11 +117,19 @@ final class ConfidenceTests: XCTestCase { } func testUpdateParentContextWithOverride() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidenceParent = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), context: ["k1": ConfidenceValue(string: "v1")], parent: nil ) @@ -106,15 +147,20 @@ final class ConfidenceTests: XCTestCase { } func testRemoveContextEntry() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidence = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, - context: [ - "k1": ConfidenceValue(string: "v1"), - "k2": ConfidenceValue(string: "v2") - ], + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), + context: ["k1": ConfidenceValue(string: "v1")], parent: nil ) confidence.removeKey(key: "k2") @@ -125,11 +171,19 @@ final class ConfidenceTests: XCTestCase { } func testRemoveContextEntryFromParent() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidenceParent = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), context: ["k1": ConfidenceValue(string: "v1")], parent: nil ) @@ -144,11 +198,19 @@ final class ConfidenceTests: XCTestCase { } func testRemoveContextEntryFromParentAndChild() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidenceParent = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), context: ["k1": ConfidenceValue(string: "v1")], parent: nil ) @@ -166,11 +228,19 @@ final class ConfidenceTests: XCTestCase { } func testRemoveContextEntryFromParentAndChildThenUpdate() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidenceParent = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), context: ["k1": ConfidenceValue(string: "v1")], parent: nil ) @@ -190,12 +260,21 @@ final class ConfidenceTests: XCTestCase { } func testVisitorId() { + let client = RemoteConfidenceResolveClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + let confidence = Confidence.init( clientSecret: "", region: .europe, eventSenderEngine: EventSenderEngineMock(), - initializationStrategy: .activateAndFetchAsync, + flagApplier: FlagApplierMock(), + remoteFlagResolver: client, + storage: StorageMock(), context: ["k1": ConfidenceValue(string: "v1")], + parent: nil, visitorId: "uuid" ) let expected = [ @@ -236,3 +315,4 @@ final class ConfidenceTests: XCTestCase { XCTAssertNil(confidence.getContext()["visitor_id"]) } } +// swiftlint:enable type_body_length diff --git a/Tests/ConfidenceProviderTests/DefaultStorageTest.swift b/Tests/ConfidenceTests/DefaultStorageTest.swift similarity index 68% rename from Tests/ConfidenceProviderTests/DefaultStorageTest.swift rename to Tests/ConfidenceTests/DefaultStorageTest.swift index 8e37b69d..916e9f34 100644 --- a/Tests/ConfidenceProviderTests/DefaultStorageTest.swift +++ b/Tests/ConfidenceTests/DefaultStorageTest.swift @@ -1,9 +1,7 @@ import Foundation -import Common -import OpenFeature import XCTest -@testable import ConfidenceProvider +@testable import Confidence class DefaultStorageTest: XCTestCase { var storage = DefaultStorage(filePath: "resolver.cache") @@ -24,14 +22,14 @@ class DefaultStorageTest: XCTestCase { } func testSaveConfig() throws { - let value: Value = .structure([ - "int": .integer(3), - "string": .string("test"), + let value = ConfidenceValue(structure: [ + "int": ConfidenceValue(integer: 3), + "string": ConfidenceValue(string: "test"), ]) try storage.save(data: value) - let restoredValue: Value = try storage.load(defaultValue: .null) + let restoredValue: ConfidenceValue = try storage.load(defaultValue: .init(structure: [:])) XCTAssertEqual(restoredValue, value) } @@ -42,17 +40,17 @@ class DefaultStorageTest: XCTestCase { try FileManager.default.removeItem(atPath: url.backport.path) } - let value: Value = try storage.load(defaultValue: .integer(3)) + let value: ConfidenceValue = try storage.load(defaultValue: .init(integer: 3)) - XCTAssertEqual(value, Value.integer(3)) + XCTAssertEqual(value, ConfidenceValue(integer: 3)) } func testSupportMultipleFiles() throws { // Given non empty resolve storage let cacheStorage = storage - let value: Value = .structure([ - "int": .integer(3), - "string": .string("test"), + let value = ConfidenceValue(structure: [ + "int": .init(integer: 3), + "string": .init(string: "test"), ]) try cacheStorage.save(data: value) @@ -62,7 +60,7 @@ class DefaultStorageTest: XCTestCase { try applyStorage.save(data: cacheData) // Then it does not override any of the files - let readCacheValue: Value = try cacheStorage.load(defaultValue: .integer(3)) + let readCacheValue: ConfidenceValue = try cacheStorage.load(defaultValue: .init(integer: 3)) let readApplyValue: CacheData = try applyStorage.load(defaultValue: CacheData.empty()) XCTAssertEqual(readCacheValue, value) XCTAssertEqual(readApplyValue.resolveEvents.first?.resolveToken, "token") @@ -71,9 +69,9 @@ class DefaultStorageTest: XCTestCase { func testClearStorage() throws { // Given non empty storage - let value: Value = .structure([ - "int": .integer(3), - "string": .string("test"), + let value: ConfidenceValue = .init(structure: [ + "int": .init(integer: 3), + "string": .init(string: "test"), ]) try storage.save(data: value) @@ -81,7 +79,7 @@ class DefaultStorageTest: XCTestCase { try storage.clear() // Then storage return default value on read - let readValue: Value = try storage.load(defaultValue: Value.null) - XCTAssertEqual(readValue, Value.null) + let readValue: ConfidenceValue = try storage.load(defaultValue: ConfidenceValue.init(null: ())) + XCTAssertEqual(readValue, .init(null: ())) } } diff --git a/Tests/ConfidenceTests/EventSenderEngineTest.swift b/Tests/ConfidenceTests/EventSenderEngineTest.swift index 07dfa0b2..0b936c67 100644 --- a/Tests/ConfidenceTests/EventSenderEngineTest.swift +++ b/Tests/ConfidenceTests/EventSenderEngineTest.swift @@ -1,5 +1,4 @@ import Foundation -import Common import XCTest @testable import Confidence diff --git a/Tests/ConfidenceProviderTests/FlagApplierWithRetriesTest.swift b/Tests/ConfidenceTests/FlagApplierWithRetriesTest.swift similarity index 99% rename from Tests/ConfidenceProviderTests/FlagApplierWithRetriesTest.swift rename to Tests/ConfidenceTests/FlagApplierWithRetriesTest.swift index 30bac189..5f781e5f 100644 --- a/Tests/ConfidenceProviderTests/FlagApplierWithRetriesTest.swift +++ b/Tests/ConfidenceTests/FlagApplierWithRetriesTest.swift @@ -4,7 +4,7 @@ import Foundation import OpenFeature import XCTest -@testable import ConfidenceProvider +@testable import Confidence @available(macOS 13.0, iOS 16.0, *) class FlagApplierWithRetriesTest: XCTestCase { diff --git a/Tests/ConfidenceProviderTests/Helpers/CacheDataInteractorMock.swift b/Tests/ConfidenceTests/Helpers/CacheDataInteractorMock.swift similarity index 88% rename from Tests/ConfidenceProviderTests/Helpers/CacheDataInteractorMock.swift rename to Tests/ConfidenceTests/Helpers/CacheDataInteractorMock.swift index 9d4216e1..1ebef3c1 100644 --- a/Tests/ConfidenceProviderTests/Helpers/CacheDataInteractorMock.swift +++ b/Tests/ConfidenceTests/Helpers/CacheDataInteractorMock.swift @@ -1,11 +1,11 @@ import Foundation -@testable import ConfidenceProvider +@testable import Confidence final actor CacheDataInteractorMock: CacheDataActor { var cache = CacheData.empty() - func add(resolveToken: String, flagName: String, applyTime: Date) -> (ConfidenceProvider.CacheData, Bool) { + func add(resolveToken: String, flagName: String, applyTime: Date) -> (CacheData, Bool) { return (cache, true) } diff --git a/Tests/ConfidenceProviderTests/Helpers/CacheDataUtility.swift b/Tests/ConfidenceTests/Helpers/CacheDataUtility.swift similarity index 97% rename from Tests/ConfidenceProviderTests/Helpers/CacheDataUtility.swift rename to Tests/ConfidenceTests/Helpers/CacheDataUtility.swift index 025dee3a..966d617d 100644 --- a/Tests/ConfidenceProviderTests/Helpers/CacheDataUtility.swift +++ b/Tests/ConfidenceTests/Helpers/CacheDataUtility.swift @@ -1,6 +1,6 @@ import Foundation -@testable import ConfidenceProvider +@testable import Confidence enum CacheDataUtility { /// Helper method for unit testing code that involves cache data. diff --git a/Tests/ConfidenceProviderTests/Helpers/ClientMock.swift b/Tests/ConfidenceTests/Helpers/ClientMock.swift similarity index 87% rename from Tests/ConfidenceProviderTests/Helpers/ClientMock.swift rename to Tests/ConfidenceTests/Helpers/ClientMock.swift index 14416c54..659d4c2a 100644 --- a/Tests/ConfidenceProviderTests/Helpers/ClientMock.swift +++ b/Tests/ConfidenceTests/Helpers/ClientMock.swift @@ -1,9 +1,6 @@ import Foundation -import Common -import Confidence -import OpenFeature -@testable import ConfidenceProvider +@testable import Confidence class ClientMock: ConfidenceResolveClient { var applyCount = 0 @@ -47,7 +44,7 @@ class ClientMock: ConfidenceResolveClient { } } - func resolve(flag: String, ctx: EvaluationContext) throws -> ResolveResult { + func resolve(flag: String, ctx: ConfidenceStruct) throws -> ResolveResult { return ResolveResult( resolvedValue: ResolvedValue(flag: "flag1", resolveReason: .match), resolveToken: "" diff --git a/Tests/ConfidenceProviderTests/Helpers/Extensions.swift b/Tests/ConfidenceTests/Helpers/Extensions.swift similarity index 100% rename from Tests/ConfidenceProviderTests/Helpers/Extensions.swift rename to Tests/ConfidenceTests/Helpers/Extensions.swift diff --git a/Tests/ConfidenceProviderTests/Helpers/FlagApplierMock.swift b/Tests/ConfidenceTests/Helpers/FlagApplierMock.swift similarity index 92% rename from Tests/ConfidenceProviderTests/Helpers/FlagApplierMock.swift rename to Tests/ConfidenceTests/Helpers/FlagApplierMock.swift index 8c5bd02d..0ef2b62f 100644 --- a/Tests/ConfidenceProviderTests/Helpers/FlagApplierMock.swift +++ b/Tests/ConfidenceTests/Helpers/FlagApplierMock.swift @@ -1,7 +1,7 @@ import Foundation import XCTest -@testable import ConfidenceProvider +@testable import Confidence class FlagApplierMock: FlagApplier { var applyCallCount = 0 diff --git a/Tests/ConfidenceProviderTests/Helpers/HttpClientMock.swift b/Tests/ConfidenceTests/Helpers/HttpClientMock.swift similarity index 98% rename from Tests/ConfidenceProviderTests/Helpers/HttpClientMock.swift rename to Tests/ConfidenceTests/Helpers/HttpClientMock.swift index 8d9377cf..5e976e82 100644 --- a/Tests/ConfidenceProviderTests/Helpers/HttpClientMock.swift +++ b/Tests/ConfidenceTests/Helpers/HttpClientMock.swift @@ -1,5 +1,4 @@ import Foundation -import Common import Confidence import XCTest diff --git a/Tests/ConfidenceTests/Helpers/MockedClientURLProtocol.swift b/Tests/ConfidenceTests/Helpers/MockedClientURLProtocol.swift index fbd15dce..06ccc8b7 100644 --- a/Tests/ConfidenceTests/Helpers/MockedClientURLProtocol.swift +++ b/Tests/ConfidenceTests/Helpers/MockedClientURLProtocol.swift @@ -1,5 +1,4 @@ import Foundation -import Common import XCTest @testable import Confidence diff --git a/Tests/ConfidenceProviderTests/Helpers/MockedResolveClientURLProtocol.swift b/Tests/ConfidenceTests/Helpers/MockedResolveClientURLProtocol.swift similarity index 90% rename from Tests/ConfidenceProviderTests/Helpers/MockedResolveClientURLProtocol.swift rename to Tests/ConfidenceTests/Helpers/MockedResolveClientURLProtocol.swift index 1fd87b1e..c29e6ab9 100644 --- a/Tests/ConfidenceProviderTests/Helpers/MockedResolveClientURLProtocol.swift +++ b/Tests/ConfidenceTests/Helpers/MockedResolveClientURLProtocol.swift @@ -1,9 +1,7 @@ import Foundation -import Common -import Confidence import OpenFeature -@testable import ConfidenceProvider +@testable import Confidence class MockedResolveClientURLProtocol: URLProtocol { public static var callStats = 0 @@ -91,14 +89,8 @@ class MockedResolveClientURLProtocol: URLProtocol { guard let resolved = flag.resolve[targetingKey], let schema = flag.schemas[targetingKey] else { return ResolvedFlag(flag: flagName, reason: .noSegmentMatch) } - var responseValue: NetworkStruct? - do { - responseValue = try TypeMapper.from(value: resolved.value) - } catch { - respondWithError(statusCode: 500, code: GrpcStatusCode.internalError.rawValue, message: "\(error)") - } - - if responseValue == nil { + let responseValue = TypeMapper.convert(structure: resolved.value.asStructure() ?? [:]) + if responseValue.fields.isEmpty { respondWithError( statusCode: 400, code: GrpcStatusCode.invalidArgument.rawValue, @@ -151,7 +143,7 @@ class MockedResolveClientURLProtocol: URLProtocol { extension MockedResolveClientURLProtocol { struct ResolvedTestFlag { var variant: String - var value: Value + var value: ConfidenceValue } struct TestFlag { @@ -171,11 +163,10 @@ extension MockedResolveClientURLProtocol { init(resolve: [String: ResolvedTestFlag], isArchived: Bool = false) { self.resolve = resolve - self.schemas = resolve.compactMapValues { value in - guard case .structure(let structure) = value.value else { + self.schemas = resolve.compactMapValues { resolvedValue in + guard let structure = resolvedValue.value.asStructure() else { return nil } - let schema = structure.compactMapValues(TestFlag.toSchema) return StructFlagSchema(schema: schema) @@ -184,8 +175,8 @@ extension MockedResolveClientURLProtocol { } // swiftlint:disable:next cyclomatic_complexity - private static func toSchema(value: Value) -> FlagSchema? { - switch value { + private static func toSchema(value: ConfidenceValue) -> FlagSchema? { + switch value.type() { case .boolean: return FlagSchema.boolSchema case .string: @@ -196,7 +187,10 @@ extension MockedResolveClientURLProtocol { return FlagSchema.doubleSchema case .date: return nil - case .list(let list): + case .list: + guard let list = value.asList() else { + return nil + } if list.isEmpty { return nil } @@ -211,7 +205,10 @@ extension MockedResolveClientURLProtocol { } return FlagSchema.listSchema(firstSchema) - case .structure(let structure): + case .structure: + guard let structure = value.asStructure() else { + return nil + } if structure.isEmpty { return nil } @@ -221,6 +218,8 @@ extension MockedResolveClientURLProtocol { return FlagSchema.structSchema(StructFlagSchema(schema: schemas)) case .null: return nil + case .timestamp: + return nil } } } diff --git a/Tests/ConfidenceProviderTests/Helpers/StorageMock.swift b/Tests/ConfidenceTests/Helpers/StorageMock.swift similarity index 95% rename from Tests/ConfidenceProviderTests/Helpers/StorageMock.swift rename to Tests/ConfidenceTests/Helpers/StorageMock.swift index 961dfcf5..ca123aa4 100644 --- a/Tests/ConfidenceProviderTests/Helpers/StorageMock.swift +++ b/Tests/ConfidenceTests/Helpers/StorageMock.swift @@ -1,9 +1,8 @@ import Foundation -import Common import OpenFeature import XCTest -@testable import ConfidenceProvider +@testable import Confidence class StorageMock: Storage { var data = "" diff --git a/Tests/ConfidenceTests/LocalStorageResolverTest.swift b/Tests/ConfidenceTests/LocalStorageResolverTest.swift new file mode 100644 index 00000000..85bf5f6b --- /dev/null +++ b/Tests/ConfidenceTests/LocalStorageResolverTest.swift @@ -0,0 +1,42 @@ +import Foundation +import XCTest + +@testable import Confidence + +class LocalStorageResolverTest: XCTestCase { + func testStaleValueFromCache() throws { + let resolvedValue = ResolvedValue( + value: .init(structure: ["string": .init(string: "Value")]), + flag: "flag_name", + resolveReason: .match + ) + let flagResolution = FlagResolution( + context: ["hey": ConfidenceValue(string: "old value")], + flags: [resolvedValue], + resolveToken: "" + ) + + XCTAssertNoThrow( + try flagResolution.evaluate( + flagName: "flag_name.string", defaultValue: "default", context: [:]) + ) + } + + func testMissingValueFromCache() throws { + let resolvedValue = ResolvedValue( + value: .init(structure: ["string": .init(string: "Value")]), + flag: "flag_name", + resolveReason: .match + ) + let context = + ["hey": ConfidenceValue(string: "old value")] + let flagResolution = + FlagResolution(context: context, flags: [resolvedValue], resolveToken: "") + XCTAssertThrowsError( + try flagResolution.evaluate(flagName: "new_flag_name", defaultValue: "default", context: context) + ) { error in + XCTAssertEqual( + error as? ConfidenceError, .flagNotFoundError(key: "new_flag_name")) + } + } +} diff --git a/Tests/ConfidenceTests/RemoteConfidenceClientTests.swift b/Tests/ConfidenceTests/RemoteConfidenceClientTests.swift index c50e1202..2d62d03b 100644 --- a/Tests/ConfidenceTests/RemoteConfidenceClientTests.swift +++ b/Tests/ConfidenceTests/RemoteConfidenceClientTests.swift @@ -1,5 +1,4 @@ import Foundation -import Common import XCTest @testable import Confidence diff --git a/Tests/ConfidenceProviderTests/RemoteResolveConfidenceClientTest.swift b/Tests/ConfidenceTests/RemoteResolveConfidenceClientTest.swift similarity index 78% rename from Tests/ConfidenceProviderTests/RemoteResolveConfidenceClientTest.swift rename to Tests/ConfidenceTests/RemoteResolveConfidenceClientTest.swift index 0fdfc1c0..16519b27 100644 --- a/Tests/ConfidenceProviderTests/RemoteResolveConfidenceClientTest.swift +++ b/Tests/ConfidenceTests/RemoteResolveConfidenceClientTest.swift @@ -1,16 +1,15 @@ import Foundation import OpenFeature -import Confidence import XCTest -@testable import ConfidenceProvider +@testable import Confidence class RemoteResolveConfidenceClientTest: XCTestCase { var flags: [String: MockedResolveClientURLProtocol.TestFlag] = [:] let resolvedFlag1 = MockedResolveClientURLProtocol.ResolvedTestFlag( - variant: "control", value: .structure(["size": .integer(3)])) + variant: "control", value: .init(structure: ["size": .init(integer: 3)])) let resolvedFlag2 = MockedResolveClientURLProtocol.ResolvedTestFlag( - variant: "treatment", value: .structure(["size": .integer(2)])) + variant: "treatment", value: .init(structure: ["size": .init(integer: 2)])) override func setUp() { self.flags = [ @@ -25,17 +24,15 @@ class RemoteResolveConfidenceClientTest: XCTestCase { func testResolveMultipleFlagsSucceeds() async throws { let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) - let flagApplier = FlagApplierMock() let client = RemoteConfidenceResolveClient( options: .init(credentials: .clientSecret(secret: "test")), session: session, applyOnResolve: true, - flagApplier: flagApplier, - metadata: ConfidenceMetadata() + metadata: ConfidenceMetadata(name: "", version: "") ) - let context = ConfidenceTypeMapper.from(ctx: MutableContext(targetingKey: "user1")) + let context = ["targeting_key": ConfidenceValue(string: "user1")] let result = try await client.resolve(ctx: context) XCTAssertEqual(result.resolvedValues.count, 2) diff --git a/Tests/ConfidenceTests/TypeMapperTests.swift b/Tests/ConfidenceTests/TypeMapperTests.swift new file mode 100644 index 00000000..ac5168f7 --- /dev/null +++ b/Tests/ConfidenceTests/TypeMapperTests.swift @@ -0,0 +1,95 @@ +import Foundation +import OpenFeature +import XCTest + +@testable import Confidence + +class TypeMapperTests: XCTestCase { + func testNetworkToConfidence() throws { + let networkStruct = NetworkStruct.init(fields: [ + "string": .string("test1"), + "boolean": .boolean(false), + "int": .number(11), + "double": .number(3.14), + "list": .list([.boolean(true)]), + "struct": .structure(NetworkStruct(fields: ["test": .string("value")])), + "null": .null + ]) + let confidenceStruct = try TypeMapper.convert(structure: networkStruct, schema: StructFlagSchema(schema: [ + "string": .stringSchema, + "boolean": .boolSchema, + "int": .intSchema, + "double": .doubleSchema, + "list": .listSchema(FlagSchema.boolSchema), + "struct": .structSchema(StructFlagSchema.init(schema: ["test": .stringSchema])), + "null": .stringSchema + ])) + let expected = [ + "string": ConfidenceValue(string: "test1"), + "boolean": ConfidenceValue(boolean: false), + "int": ConfidenceValue(integer: 11), + "double": ConfidenceValue(double: 3.14), + "list": ConfidenceValue(list: [ConfidenceValue(boolean: true)]), + "struct": ConfidenceValue(structure: ["test": ConfidenceValue(string: "value")]), + "null": ConfidenceValue.init(null: ()) + ] + XCTAssertEqual(confidenceStruct, expected) + } + + func testConfidenceToNetwork() throws { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.timeZone = TimeZone.init(identifier: "UTC+01") + let date = try XCTUnwrap(formatter.date(from: "2022-01-01 12:00:00")) + + let confidenceStruct: ConfidenceStruct = [ + "string": ConfidenceValue(string: "test1"), + "boolean": ConfidenceValue(boolean: false), + "int": ConfidenceValue(integer: 11), + "double": ConfidenceValue(double: 3.14), + "list": ConfidenceValue(list: [ConfidenceValue(boolean: true)]), + "struct": ConfidenceValue(structure: ["test": ConfidenceValue(string: "value")]), + "date": ConfidenceValue(date: DateComponents(year: 1990, month: 4, day: 2)), + "timestamp": ConfidenceValue(timestamp: date), + "null": ConfidenceValue.init(null: ()) + ] + let networkStruct = TypeMapper.convert(structure: confidenceStruct) + let expectedNetworkStruct = NetworkStruct.init(fields: [ + "string": .string("test1"), + "boolean": .boolean(false), + "int": .number(11), + "double": .number(3.14), + "list": .list([.boolean(true)]), + "struct": .structure(NetworkStruct(fields: ["test": .string("value")])), + "date": .string("1990-04-02"), + "timestamp": .string("2022-01-01T11:00:00Z"), + "null": .null + ]) + XCTAssertEqual(networkStruct, expectedNetworkStruct) + } + + func testNetworkToConfidenceLists() throws { + let networkStruct = NetworkStruct.init(fields: [ + "stringList": .list([.string("test1"), .string("test2")]), + "booleanList": .list([.boolean(true), .boolean(false)]), + "integerList": .list([.number(11), .number(0)]), + "doubleList": .list([.number(3.14), .number(1.0)]), + "nullList": .list([.null, .null]) + ]) + let confidenceStruct = try TypeMapper.convert(structure: networkStruct, schema: StructFlagSchema(schema: [ + "stringList": .listSchema(FlagSchema.stringSchema), + "booleanList": .listSchema(FlagSchema.boolSchema), + "integerList": .listSchema(FlagSchema.intSchema), + "doubleList": .listSchema(FlagSchema.doubleSchema), + "nullList": .listSchema(FlagSchema.stringSchema) + ])) + let expected = [ + "stringList": ConfidenceValue(stringList: ["test1", "test2"]), + "booleanList": ConfidenceValue(booleanList: [true, false]), + "integerList": ConfidenceValue(integerList: [11, 0]), + "doubleList": ConfidenceValue(doubleList: [3.14, 1.0]), + "nullList": ConfidenceValue(nullList: [(), ()]), + ] + XCTAssertEqual(confidenceStruct, expected) + } +}