diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index 0c643e71..723a7dea 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -119,7 +119,7 @@ public class Confidence: ConfidenceEventSender { - Parameter key:expects dot-notation to retrieve a specific entry in the flag's value, e.g. "flagname.myentry" - Parameter defaultValue: returned in case of errors or in case of the variant's rule indicating to use the default value. */ - public func getEvaluation(key: String, defaultValue: T) -> Evaluation { + public func getEvaluation(key: String, defaultValue: T) -> Evaluation { cacheQueue.sync { [weak self] in guard let self = self else { return Evaluation( @@ -145,7 +145,7 @@ public class Confidence: ConfidenceEventSender { - Parameter key:expects dot-notation to retrieve a specific entry in the flag's value, e.g. "flagname.myentry" - Parameter defaultValue: returned in case of errors or in case of the variant's rule indicating to use the default value. */ - public func getValue(key: String, defaultValue: T) -> T { + public func getValue(key: String, defaultValue: T) -> T { return getEvaluation(key: key, defaultValue: defaultValue).value } diff --git a/Sources/Confidence/ConfidenceValue.swift b/Sources/Confidence/ConfidenceValue.swift index 9dcbe9ec..7275ca24 100644 --- a/Sources/Confidence/ConfidenceValue.swift +++ b/Sources/Confidence/ConfidenceValue.swift @@ -210,6 +210,81 @@ extension ConfidenceValue { } } +extension ConfidenceValue { + // swiftlint:disable function_body_length + // swiftlint:disable cyclomatic_complexity + // swiftlint:disable identifier_name + public func asJSONData() -> Data? { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + + switch value { + case .boolean(let value): + return try? encoder.encode(value) + case .string(let value): + return try? encoder.encode(value) + case .integer(let value): + return try? encoder.encode(value) + case .double(let value): + return try? encoder.encode(value) + case .date(let value): + return try? encoder.encode(value) + case .timestamp(let value): + return try? encoder.encode(value) + case .structure(let values): + var flattened: [String: Any] = [:] + for (key, value) in values { + switch value { + case .boolean(let v): flattened[key] = v + case .string(let v): flattened[key] = v + case .integer(let v): flattened[key] = v + case .double(let v): flattened[key] = v + case .date(let v): flattened[key] = v + case .timestamp(let v): flattened[key] = v + case .structure(let v): + var nested: [String: Any] = [:] + for (nestedKey, nestedValue) in v { + switch nestedValue { + case .boolean(let v): nested[nestedKey] = v + case .string(let v): nested[nestedKey] = v + case .integer(let v): nested[nestedKey] = v + case .double(let v): nested[nestedKey] = v + case .date(let v): nested[nestedKey] = v + case .timestamp(let v): nested[nestedKey] = v + case .structure(let v): + var innerNested: [String: Any] = [:] + for (innerKey, innerValue) in v { + switch innerValue { + case .boolean(let v): innerNested[innerKey] = v + case .string(let v): innerNested[innerKey] = v + case .integer(let v): innerNested[innerKey] = v + case .double(let v): innerNested[innerKey] = v + case .date(let v): innerNested[innerKey] = v + case .timestamp(let v): innerNested[innerKey] = v + default: break + } + } + nested[nestedKey] = innerNested + default: break + } + } + flattened[key] = nested + default: break + } + } + return try? JSONSerialization.data(withJSONObject: flattened, options: .sortedKeys) + case .null: + return try? JSONSerialization.data(withJSONObject: NSNull()) + default: + return nil + } + } + // swiftlint:enable function_body_length + // swiftlint:enable cyclomatic_complexity + // swiftlint:enable identifier_name +} + + public enum ConfidenceValueType: CaseIterable { case boolean case string diff --git a/Sources/Confidence/FlagEvaluation.swift b/Sources/Confidence/FlagEvaluation.swift index 393dd8e1..247fc322 100644 --- a/Sources/Confidence/FlagEvaluation.swift +++ b/Sources/Confidence/FlagEvaluation.swift @@ -26,7 +26,7 @@ struct FlagResolution: Encodable, Decodable, Equatable { extension FlagResolution { // swiftlint:disable function_body_length // swiftlint:disable cyclomatic_complexity - func evaluate( + func evaluate( flagName: String, defaultValue: T, context: ConfidenceStruct, @@ -55,7 +55,7 @@ extension FlagResolution { } guard let value = resolvedFlag.value else { - // No backend error, but nil value returned. This can happend with "noSegmentMatch" or "archived", for example + // No backend error, but nil value returned. This can happen with "noSegmentMatch" or "archived", for example Task { if resolvedFlag.shouldApply { await flagApplier?.apply(flagName: parsedKey.flag, resolveToken: self.resolveToken) @@ -71,7 +71,7 @@ extension FlagResolution { } let parsedValue = try getValue(path: parsedKey.path, value: value) - let typedValue: T? = getTyped(value: parsedValue) + let typedValue: T? = getTyped(value: parsedValue, debugLogger: debugLogger) if resolvedFlag.resolveReason == .match { var resolveReason: ResolveReason = .match @@ -140,9 +140,9 @@ extension FlagResolution { ) } } + // swiftlint:enable function_body_length // swiftlint:enable cyclomatic_complexity - private func checkBackendErrors(resolvedFlag: ResolvedValue, defaultValue: T) -> Evaluation? { if resolvedFlag.resolveReason == .targetingKeyError { return Evaluation( @@ -153,8 +153,8 @@ extension FlagResolution { errorMessage: "Invalid targeting key" ) } else if resolvedFlag.resolveReason == .error || - resolvedFlag.resolveReason == .unknown || - resolvedFlag.resolveReason == .unspecified { + resolvedFlag.resolveReason == .unknown || + resolvedFlag.resolveReason == .unspecified { return Evaluation( value: defaultValue, variant: nil, @@ -168,7 +168,7 @@ extension FlagResolution { } // swiftlint:disable:next cyclomatic_complexity - private func getTyped(value: ConfidenceValue) -> T? { + private func getTyped(value: ConfidenceValue, debugLogger: DebugLogger?) -> T? { if let value = self as? T { return value } @@ -198,12 +198,44 @@ extension FlagResolution { case .list: return value.asList() as? T case .structure: + // Try to decode as a Codable type if T is not ConfidenceStruct + if T.self != ConfidenceStruct.self { + return tryDecodeCodable(value: value, debugLogger: debugLogger) + } return value.asStructure() as? T case .null: return nil } } + private func tryDecodeCodable(value: ConfidenceValue, debugLogger: DebugLogger?) -> T? { + guard let decodable = T.self as? Decodable.Type else { + debugLogger?.logMessage( + message: "tryDecodeCodable: Type \(T.self) does not conform to Decodable", + isWarning: true) + return nil + } + + guard let data = value.asJSONData() else { + debugLogger?.logMessage( + message: "tryDecodeCodable: Failed to encode ConfidenceValue to JSON", + isWarning: true) + return nil + } + do { + let decoded = try JSONDecoder().decode(decodable, from: data) as? T + if decoded == nil { + debugLogger?.logMessage( + message: "tryDecodeCodable: Failed to cast decoded value to type \(T.self)", + isWarning: true) + } + return decoded + } catch { + debugLogger?.logMessage(message: "tryDecodeCodable: Failed to decode JSON: \(error)", isWarning: true) + return nil + } + } + private func getValue(path: [String], value: ConfidenceValue) throws -> ConfidenceValue { if path.isEmpty { guard value.asStructure() != nil else { diff --git a/Tests/ConfidenceTests/ConfidenceTest.swift b/Tests/ConfidenceTests/ConfidenceTest.swift index 235cd9b2..6b0ec965 100644 --- a/Tests/ConfidenceTests/ConfidenceTest.swift +++ b/Tests/ConfidenceTests/ConfidenceTest.swift @@ -240,6 +240,94 @@ class ConfidenceTest: XCTestCase { XCTAssertEqual(flagApplier.applyCallCount, 1) } + func testResolveObjectFlagWithUnderlyingStruct() 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") + } + } + + // swiftlint:disable:next line_length + let expected: ConfidenceValue = .init(structure: ["blob": .init(structure: ["size": .init(integer: 3), "name": .init(string: "testInner")]), "string": .init(string: "test")]) + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: expected, + flag: "flag", + resolveReason: .match, + shouldApply: false) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + + try await confidence.fetchAndActivate() + + // if the expected output is a struct, it's important the the defaultValue is ConfidenceStruct. + let defaultValue = ConfidenceStruct(uniqueKeysWithValues: []) + let evaluation = confidence.getValue( + key: "flag", + defaultValue: defaultValue) + + XCTAssertEqual(evaluation, expected.asStructure()) + } + + + func testResolveCodable() 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", + // swiftlint:disable:next line_length + value: .init(structure: ["blob": .init(structure: ["size": .init(integer: 3), "name": .init(string: "testInner")]), "string": .init(string: "test")]), + flag: "flag", + resolveReason: .match, + shouldApply: false) + ] + + struct Blob: Codable { + let size: Int + let name: String + } + + struct Flag: Codable { + let string: String + let blob: Blob + } + + 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 defaultValue = Flag(string: "", blob: Blob(size: 0, name: "")) + let evaluation = confidence.getValue( + key: "flag", + defaultValue: defaultValue) + + let expected = Flag(string: "test", blob: Blob(size: 3, name: "testInner")) + XCTAssertEqual(evaluation.string, expected.string) + XCTAssertEqual(evaluation.blob.size, expected.blob.size) + XCTAssertEqual(evaluation.blob.name, expected.blob.name) + } func testResolveAndApplyIntegerFlagNoSegmentMatch() async throws { class FakeClient: ConfidenceResolveClient { @@ -694,7 +782,7 @@ class ConfidenceTest: XCTestCase { try await confidence.fetchAndActivate() let evaluation = confidence.getEvaluation( key: "flag.size", - defaultValue: [:]) + defaultValue: ConfidenceStruct()) XCTAssertEqual(client.resolveStats, 1) XCTAssertEqual(evaluation.value as? ConfidenceStruct, ["boolean": .init(boolean: true)]) diff --git a/Tests/ConfidenceTests/ConfidenceValueTests.swift b/Tests/ConfidenceTests/ConfidenceValueTests.swift index 37c8535c..7c568473 100644 --- a/Tests/ConfidenceTests/ConfidenceValueTests.swift +++ b/Tests/ConfidenceTests/ConfidenceValueTests.swift @@ -134,4 +134,19 @@ final class ConfidenceConfidenceValueTests: XCTestCase { XCTAssertEqual(value, decodedValue) } + + func testAsJSONData() throws { + let value = ConfidenceValue(structure: [ + "field1": ConfidenceValue(integer: 3), + "field2": ConfidenceValue(string: "test"), + "field3": ConfidenceValue(structure: [ + "field4": ConfidenceValue(integer: 4), + "field5": ConfidenceValue(string: "test2") + ]) + ]) + let data = try XCTUnwrap(value.asJSONData()) + let dataAsString = try XCTUnwrap(String(data: data, encoding: .utf8)) + // swiftlint:disable:next line_length + XCTAssertEqual(dataAsString, "{\"field1\":3,\"field2\":\"test\",\"field3\":{\"field4\":4,\"field5\":\"test2\"}}") + } } diff --git a/api/Confidence_public_api.json b/api/Confidence_public_api.json index 8bb7d00a..cd60dd3a 100644 --- a/api/Confidence_public_api.json +++ b/api/Confidence_public_api.json @@ -20,11 +20,11 @@ }, { "name": "getEvaluation(key:defaultValue:)", - "declaration": "public func getEvaluation(key: String, defaultValue: T) -> Evaluation" + "declaration": "public func getEvaluation(key: String, defaultValue: T) -> Evaluation" }, { "name": "getValue(key:defaultValue:)", - "declaration": "public func getValue(key: String, defaultValue: T) -> T" + "declaration": "public func getValue(key: String, defaultValue: T) -> T" }, { "name": "getContext()",