diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index d3e44cb1..202c56c4 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -264,6 +264,20 @@ A3A8BCD32B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; A3A8BCD42B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; A3A8BCD52B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; + A3BA7CE92BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; + A3BA7CEA2BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; + A3BA7CEB2BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; + A3BA7CEC2BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; + A3BA7CEE2BD059180000DB28 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CED2BD059180000DB28 /* Metadata.swift */; }; + A3BA7CEF2BD059180000DB28 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CED2BD059180000DB28 /* Metadata.swift */; }; + A3BA7CF02BD059180000DB28 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CED2BD059180000DB28 /* Metadata.swift */; }; + A3BA7CF12BD059180000DB28 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CED2BD059180000DB28 /* Metadata.swift */; }; + A3BA7CF32BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CF22BD05A280000DB28 /* EvaluationSeriesContext.swift */; }; + A3BA7CF42BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CF22BD05A280000DB28 /* EvaluationSeriesContext.swift */; }; + A3BA7CF52BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CF22BD05A280000DB28 /* EvaluationSeriesContext.swift */; }; + A3BA7CF62BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CF22BD05A280000DB28 /* EvaluationSeriesContext.swift */; }; + A3BA7D022BD192240000DB28 /* LDClientHookSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7D012BD192240000DB28 /* LDClientHookSpec.swift */; }; + A3BA7D042BD2BD620000DB28 /* TestContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7D032BD2BD620000DB28 /* TestContext.swift */; }; A3C6F7622B7FA803005B3B61 /* SheddingQueueSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */; }; A3C6F7642B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; A3C6F7652B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; @@ -486,6 +500,11 @@ A3799D4429033665008D4A8E /* ObjcLDApplicationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjcLDApplicationInfo.swift; sourceTree = ""; }; A380B0982B60178D00AB64A6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SheddingQueue.swift; sourceTree = ""; }; + A3BA7CE82BD056920000DB28 /* Hook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hook.swift; sourceTree = ""; }; + A3BA7CED2BD059180000DB28 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; + A3BA7CF22BD05A280000DB28 /* EvaluationSeriesContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationSeriesContext.swift; sourceTree = ""; }; + A3BA7D012BD192240000DB28 /* LDClientHookSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientHookSpec.swift; sourceTree = ""; }; + A3BA7D032BD2BD620000DB28 /* TestContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContext.swift; sourceTree = ""; }; A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheddingQueueSpec.swift; sourceTree = ""; }; A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyResult.swift; sourceTree = ""; }; A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDValueDecoderSpec.swift; sourceTree = ""; }; @@ -670,6 +689,7 @@ 8354EFCF1F22491C00C05156 /* LaunchDarklyTests */ = { isa = PBXGroup; children = ( + A3BA7D012BD192240000DB28 /* LDClientHookSpec.swift */, 838F96731FB9F024009CFC45 /* LDClientSpec.swift */, 3D9A12572A73236800698B8D /* UtilSpec.swift */, 83EF67911F9945CE00403126 /* Models */, @@ -680,6 +700,7 @@ 8354EFD21F22491C00C05156 /* Info.plist */, B4265EB024E7390C001CFD2C /* TestUtil.swift */, A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */, + A3BA7D032BD2BD620000DB28 /* TestContext.swift */, ); name = LaunchDarklyTests; path = LaunchDarkly/LaunchDarklyTests; @@ -688,6 +709,7 @@ 8354EFE61F263E4200C05156 /* Models */ = { isa = PBXGroup; children = ( + A3BA7CE72BD056780000DB28 /* Hooks */, A31088132837DC0400184942 /* Context */, C408884823033B7500420721 /* ConnectionInformation.swift */, B4C9D42D2489B5FF004A9B03 /* DiagnosticEvent.swift */, @@ -890,6 +912,16 @@ path = EnvironmentReporting; sourceTree = ""; }; + A3BA7CE72BD056780000DB28 /* Hooks */ = { + isa = PBXGroup; + children = ( + A3BA7CE82BD056920000DB28 /* Hook.swift */, + A3BA7CED2BD059180000DB28 /* Metadata.swift */, + A3BA7CF22BD05A280000DB28 /* EvaluationSeriesContext.swift */, + ); + path = Hooks; + sourceTree = ""; + }; B467790E24D8AECA00897F00 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -1256,6 +1288,7 @@ B4C9D4362489C8FD004A9B03 /* DiagnosticCache.swift in Sources */, A358D6DA2A4DE6A500270C60 /* ApplicationInfoEnvironmentReporter.swift in Sources */, 831188452113ADC500D77CB5 /* LDClient.swift in Sources */, + A3BA7CF12BD059180000DB28 /* Metadata.swift in Sources */, A310881E2837DC0400184942 /* Kind.swift in Sources */, A310881A2837DC0400184942 /* Reference.swift in Sources */, 3D3AB9462A4F16FE003AECF1 /* ReportingConsts.swift in Sources */, @@ -1268,6 +1301,7 @@ 8311886C2113AE6400D77CB5 /* ObjcLDChangedFlag.swift in Sources */, C43C37E8238DF22D003C1624 /* LDEvaluationDetail.swift in Sources */, 8311884C2113ADDE00D77CB5 /* FlagChangeObserver.swift in Sources */, + A3BA7CF62BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */, A3470C3A2B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */, C443A41223186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */, 831188592113AE1200D77CB5 /* FlagStore.swift in Sources */, @@ -1295,6 +1329,7 @@ 8311885B2113AE1D00D77CB5 /* Throttler.swift in Sources */, 8311884E2113ADE500D77CB5 /* Event.swift in Sources */, A36EDFCB2853883400D91B05 /* ObjcLDReference.swift in Sources */, + A3BA7CEC2BD056920000DB28 /* Hook.swift in Sources */, 832D68A5224A38FC005F052A /* CacheConverter.swift in Sources */, A35AD4632A619E45005A8DCB /* SystemCapabilities.swift in Sources */, A358D6FA2A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, @@ -1322,6 +1357,7 @@ 831EF34420655E730001C643 /* LDConfig.swift in Sources */, A31088212837DC0400184942 /* LDContext.swift in Sources */, 831EF34520655E730001C643 /* LDClient.swift in Sources */, + A3BA7CF02BD059180000DB28 /* Metadata.swift in Sources */, 830DB3B02239B54900D65D25 /* URLResponse.swift in Sources */, B4C9D4352489C8FD004A9B03 /* DiagnosticCache.swift in Sources */, A358D6F92A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, @@ -1348,6 +1384,7 @@ 831EF35520655E730001C643 /* FlagSynchronizer.swift in Sources */, A358D6F42A4DEB4C00270C60 /* EnvironmentReporterBuilder.swift in Sources */, B4C9D4302489B5FF004A9B03 /* DiagnosticEvent.swift in Sources */, + A3BA7CF52BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */, 831EF35620655E730001C643 /* FlagChangeNotifier.swift in Sources */, A358D6D92A4DE6A500270C60 /* ApplicationInfoEnvironmentReporter.swift in Sources */, 831EF35720655E730001C643 /* EventReporter.swift in Sources */, @@ -1369,6 +1406,7 @@ 83EBCBB520DABE1B003A7142 /* FlagRequestTracker.swift in Sources */, 8347BB0E21F147E100E56BCD /* LDTimer.swift in Sources */, B495A8A42787762C0051977C /* LDClientVariation.swift in Sources */, + A3BA7CEB2BD056920000DB28 /* Hook.swift in Sources */, A3599E8A2A4B4AD400DB5C67 /* Modifier.swift in Sources */, 831EF36320655E730001C643 /* Date.swift in Sources */, 831EF36520655E730001C643 /* Thread.swift in Sources */, @@ -1395,6 +1433,7 @@ A36EDFCD2853C50B00D91B05 /* ObjcLDContext.swift in Sources */, A358D6D72A4DE6A500270C60 /* ApplicationInfoEnvironmentReporter.swift in Sources */, 8354EFE51F263DAC00C05156 /* FeatureFlag.swift in Sources */, + A3BA7CEE2BD059180000DB28 /* Metadata.swift in Sources */, 8372668C20D4439600BD1088 /* DateFormatter.swift in Sources */, A310881B2837DC0400184942 /* Kind.swift in Sources */, 3D3AB9432A4F16FE003AECF1 /* ReportingConsts.swift in Sources */, @@ -1407,6 +1446,7 @@ 835E1D431F685AC900184DB4 /* ObjcLDChangedFlag.swift in Sources */, 8358F25E1F474E5900ECE1AF /* LDChangedFlag.swift in Sources */, 83D559741FD87CC9002D10C8 /* KeyedValueCache.swift in Sources */, + A3BA7CF32BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */, A3470C372B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */, C43C37E1236BA050003C1624 /* LDEvaluationDetail.swift in Sources */, 831AAE2C20A9E4F600B46DBA /* Throttler.swift in Sources */, @@ -1434,6 +1474,7 @@ 8354AC702243166900CDE602 /* FeatureFlagCache.swift in Sources */, A36EDFC82853883400D91B05 /* ObjcLDReference.swift in Sources */, 8358F2621F47747F00ECE1AF /* FlagChangeObserver.swift in Sources */, + A3BA7CE92BD056920000DB28 /* Hook.swift in Sources */, 832D68A2224A38FC005F052A /* CacheConverter.swift in Sources */, A35AD4602A619E45005A8DCB /* SystemCapabilities.swift in Sources */, A358D6F72A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, @@ -1464,6 +1505,7 @@ 83396BC91F7C3711000E256E /* DarklyServiceSpec.swift in Sources */, 3D9A12582A73236800698B8D /* UtilSpec.swift in Sources */, 83EF67931F9945E800403126 /* EventSpec.swift in Sources */, + A3BA7D042BD2BD620000DB28 /* TestContext.swift in Sources */, 83B6E3F1222EFA3800FF2A6A /* ThreadSpec.swift in Sources */, 831AAE3020A9E75D00B46DBA /* ThrottlerSpec.swift in Sources */, 832D68AC224B3321005F052A /* CacheConverterSpec.swift in Sources */, @@ -1496,6 +1538,7 @@ A3570F5A28527B8200CF241A /* LDContextCodableSpec.swift in Sources */, 837406D421F760640087B22B /* LDTimerSpec.swift in Sources */, 832307A61F7D8D720029815A /* URLRequestSpec.swift in Sources */, + A3BA7D022BD192240000DB28 /* LDClientHookSpec.swift in Sources */, 832307A81F7DA61B0029815A /* LDEventSourceMock.swift in Sources */, 838F967A1FBA551A009CFC45 /* ClientServiceMockFactory.swift in Sources */, A31088292837DCA900184942 /* KindSpec.swift in Sources */, @@ -1516,6 +1559,7 @@ 8372668D20D4439600BD1088 /* DateFormatter.swift in Sources */, A358D6D82A4DE6A500270C60 /* ApplicationInfoEnvironmentReporter.swift in Sources */, 83D9EC7D2062DEAB004D7FA6 /* LDChangedFlag.swift in Sources */, + A3BA7CEF2BD059180000DB28 /* Metadata.swift in Sources */, A310881C2837DC0400184942 /* Kind.swift in Sources */, A31088182837DC0400184942 /* Reference.swift in Sources */, 3D3AB9442A4F16FE003AECF1 /* ReportingConsts.swift in Sources */, @@ -1528,6 +1572,7 @@ 83D9EC832062DEAB004D7FA6 /* KeyedValueCache.swift in Sources */, A358D6F02A4DE9EB00270C60 /* WatchOSEnvironmentReporter.swift in Sources */, 831AAE2D20A9E4F600B46DBA /* Throttler.swift in Sources */, + A3BA7CF42BD05A280000DB28 /* EvaluationSeriesContext.swift in Sources */, A3470C382B7C1ACE00951CEE /* LDValueDecoder.swift in Sources */, C43C37E6238DF22B003C1624 /* LDEvaluationDetail.swift in Sources */, 83D9EC872062DEAB004D7FA6 /* FlagSynchronizer.swift in Sources */, @@ -1555,6 +1600,7 @@ B4C9D4392489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, B468E71124B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, A36EDFC92853883400D91B05 /* ObjcLDReference.swift in Sources */, + A3BA7CEA2BD056920000DB28 /* Hook.swift in Sources */, 83D9EC952062DEAB004D7FA6 /* Date.swift in Sources */, A35AD4612A619E45005A8DCB /* SystemCapabilities.swift in Sources */, A358D6F82A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 616d2ece..be9563a9 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -270,6 +270,7 @@ public class LDClient { let config: LDConfig let service: DarklyServiceProvider + let hooks: [Hook] private(set) var context: LDContext /** @@ -833,6 +834,7 @@ public class LDClient { private init(serviceFactory: ClientServiceCreating, configuration: LDConfig, startContext: LDContext?, completion: (() -> Void)? = nil) { self.serviceFactory = serviceFactory + self.hooks = configuration.hooks environmentReporter = self.serviceFactory.makeEnvironmentReporter(config: configuration) flagCache = self.serviceFactory.makeFeatureFlagCache(mobileKey: configuration.mobileKey, maxCachedContexts: configuration.maxCachedContexts) flagStore = self.serviceFactory.makeFlagStore() diff --git a/LaunchDarkly/LaunchDarkly/LDClientVariation.swift b/LaunchDarkly/LaunchDarkly/LDClientVariation.swift index 49168a9b..36087f9e 100644 --- a/LaunchDarkly/LaunchDarkly/LDClientVariation.swift +++ b/LaunchDarkly/LaunchDarkly/LDClientVariation.swift @@ -12,7 +12,7 @@ extension LDClient { - returns: the variation for the selected context, or `defaultValue` if the flag is not available. */ public func boolVariation(forKey flagKey: LDFlagKey, defaultValue: Bool) -> Bool { - variationDetailInternal(flagKey, defaultValue, needsReason: false).value + variationDetailInternal(flagKey, defaultValue, needsReason: false, methodName: "boolVariation").value } /** @@ -24,7 +24,7 @@ extension LDClient { - returns: an `LDEvaluationDetail` object */ public func boolVariationDetail(forKey flagKey: LDFlagKey, defaultValue: Bool) -> LDEvaluationDetail { - variationDetailInternal(flagKey, defaultValue, needsReason: true) + variationDetailInternal(flagKey, defaultValue, needsReason: true, methodName: "boolVariationDetail") } /** @@ -35,7 +35,7 @@ extension LDClient { - returns: the variation for the selected context, or `defaultValue` if the flag is not available. */ public func intVariation(forKey flagKey: LDFlagKey, defaultValue: Int) -> Int { - variationDetailInternal(flagKey, defaultValue, needsReason: false).value + variationDetailInternal(flagKey, defaultValue, needsReason: false, methodName: "intVariation").value } /** @@ -47,7 +47,7 @@ extension LDClient { - returns: an `LDEvaluationDetail` object */ public func intVariationDetail(forKey flagKey: LDFlagKey, defaultValue: Int) -> LDEvaluationDetail { - variationDetailInternal(flagKey, defaultValue, needsReason: true) + variationDetailInternal(flagKey, defaultValue, needsReason: true, methodName: "intVariationDetail") } /** @@ -58,7 +58,7 @@ extension LDClient { - returns: the variation for the selected context, or `defaultValue` if the flag is not available. */ public func doubleVariation(forKey flagKey: LDFlagKey, defaultValue: Double) -> Double { - variationDetailInternal(flagKey, defaultValue, needsReason: false).value + variationDetailInternal(flagKey, defaultValue, needsReason: false, methodName: "doubleVariation").value } /** @@ -70,7 +70,7 @@ extension LDClient { - returns: an `LDEvaluationDetail` object */ public func doubleVariationDetail(forKey flagKey: LDFlagKey, defaultValue: Double) -> LDEvaluationDetail { - variationDetailInternal(flagKey, defaultValue, needsReason: true) + variationDetailInternal(flagKey, defaultValue, needsReason: true, methodName: "doubleVariationDetail") } /** @@ -81,7 +81,7 @@ extension LDClient { - returns: the variation for the selected context, or `defaultValue` if the flag is not available. */ public func stringVariation(forKey flagKey: LDFlagKey, defaultValue: String) -> String { - variationDetailInternal(flagKey, defaultValue, needsReason: false).value + variationDetailInternal(flagKey, defaultValue, needsReason: false, methodName: "stringVariation").value } /** @@ -93,7 +93,7 @@ extension LDClient { - returns: an `LDEvaluationDetail` object */ public func stringVariationDetail(forKey flagKey: LDFlagKey, defaultValue: String) -> LDEvaluationDetail { - variationDetailInternal(flagKey, defaultValue, needsReason: true) + variationDetailInternal(flagKey, defaultValue, needsReason: true, methodName: "stringVariationDetail") } /** @@ -104,7 +104,7 @@ extension LDClient { - returns: the variation for the selected context, or `defaultValue` if the flag is not available. */ public func jsonVariation(forKey flagKey: LDFlagKey, defaultValue: LDValue) -> LDValue { - variationDetailInternal(flagKey, defaultValue, needsReason: false).value + variationDetailInternal(flagKey, defaultValue, needsReason: false, methodName: "jsonVariation").value } /** @@ -116,7 +116,7 @@ extension LDClient { - returns: an `LDEvaluationDetail` object */ public func jsonVariationDetail(forKey flagKey: LDFlagKey, defaultValue: LDValue) -> LDEvaluationDetail { - variationDetailInternal(flagKey, defaultValue, needsReason: true) + variationDetailInternal(flagKey, defaultValue, needsReason: true, methodName: "jsonVariationDetail") } /** @@ -127,7 +127,7 @@ extension LDClient { - returns: the variation for the selected context, or `defaultValue` if the flag is not available. */ public func variation(forKey flagKey: LDFlagKey, defaultValue: T) -> T where T: LDValueConvertible, T: Decodable { - return variationDetailInternal(flagKey, defaultValue, needsReason: false).value + return variationDetailInternal(flagKey, defaultValue, needsReason: false, methodName: "variation").value } /** @@ -140,35 +140,62 @@ extension LDClient { - returns: an `LDEvaluationDetail` object */ public func variationDetail(forKey flagKey: LDFlagKey, defaultValue: T) -> LDEvaluationDetail where T: LDValueConvertible, T: Decodable { - return variationDetailInternal(flagKey, defaultValue, needsReason: true) + return variationDetailInternal(flagKey, defaultValue, needsReason: true, methodName: "variationDetail") } - private func variationDetailInternal(_ flagKey: LDFlagKey, _ defaultValue: T, needsReason: Bool) -> LDEvaluationDetail where T: Decodable, T: LDValueConvertible { - var result: LDEvaluationDetail - let featureFlag = flagStore.featureFlag(for: flagKey) - if let featureFlag = featureFlag { - if featureFlag.value == .null { - result = LDEvaluationDetail(value: defaultValue, variationIndex: featureFlag.variation, reason: featureFlag.reason) - } else { - do { - let convertedValue = try LDValueDecoder().decode(T.self, from: featureFlag.value) - result = LDEvaluationDetail(value: convertedValue, variationIndex: featureFlag.variation, reason: featureFlag.reason) - } catch let error { - os_log("%s type conversion error %s: failed converting %s to type %s", log: config.logger, type: .debug, typeName(and: #function), String(describing: error), String(describing: featureFlag.value), String(describing: T.self)) - result = LDEvaluationDetail(value: defaultValue, variationIndex: nil, reason: ["kind": "ERROR", "errorKind": "WRONG_TYPE"]) + private func evaluateWithHooks(flagKey: LDFlagKey, defaultValue: D, methodName: String, evaluation: () -> LDEvaluationDetail) -> LDEvaluationDetail where D: LDValueConvertible, D: Decodable { + if self.hooks.isEmpty { + return evaluation() + } + + let seriesContext = EvaluationSeriesContext(flagKey: flagKey, context: self.context, defaultValue: defaultValue.toLDValue(), methodName: methodName) + let hookData = self.execute_before_evaluation(seriesContext: seriesContext) + let evaluationResult = evaluation() + _ = self.execute_after_evaluation(seriesContext: seriesContext, hookData: hookData, evaluationDetail: evaluationResult.map { value in return value.toLDValue()}) + + return evaluationResult + } + + private func execute_before_evaluation(seriesContext: EvaluationSeriesContext) -> [EvaluationSeriesData] { + return self.hooks.map { hook in + hook.beforeEvaluation(seriesContext: seriesContext, seriesData: EvaluationSeriesData()) + } + } + + private func execute_after_evaluation(seriesContext: EvaluationSeriesContext, hookData: [EvaluationSeriesData], evaluationDetail: LDEvaluationDetail) -> [EvaluationSeriesData] { + return zip(self.hooks, hookData).reversed().map { (hook, data) in + return hook.afterEvaluation(seriesContext: seriesContext, seriesData: data, evaluationDetail: evaluationDetail) + } + } + + private func variationDetailInternal(_ flagKey: LDFlagKey, _ defaultValue: T, needsReason: Bool, methodName: String) -> LDEvaluationDetail where T: Decodable, T: LDValueConvertible { + return evaluateWithHooks(flagKey: flagKey, defaultValue: defaultValue, methodName: methodName) { + var result: LDEvaluationDetail + let featureFlag = flagStore.featureFlag(for: flagKey) + if let featureFlag = featureFlag { + if featureFlag.value == .null { + result = LDEvaluationDetail(value: defaultValue, variationIndex: featureFlag.variation, reason: featureFlag.reason) + } else { + do { + let convertedValue = try LDValueDecoder().decode(T.self, from: featureFlag.value) + result = LDEvaluationDetail(value: convertedValue, variationIndex: featureFlag.variation, reason: featureFlag.reason) + } catch let error { + os_log("%s type conversion error %s: failed converting %s to type %s", log: config.logger, type: .debug, typeName(and: #function), String(describing: error), String(describing: featureFlag.value), String(describing: T.self)) + result = LDEvaluationDetail(value: defaultValue, variationIndex: nil, reason: ["kind": "ERROR", "errorKind": "WRONG_TYPE"]) + } } + } else { + os_log("%s Unknown feature flag %s; returning default value", log: config.logger, type: .debug, typeName(and: #function), flagKey.description) + result = LDEvaluationDetail(value: defaultValue, variationIndex: nil, reason: ["kind": "ERROR", "errorKind": "FLAG_NOT_FOUND"]) } - } else { - os_log("%s Unknown feature flag %s; returning default value", log: config.logger, type: .debug, typeName(and: #function), flagKey.description) - result = LDEvaluationDetail(value: defaultValue, variationIndex: nil, reason: ["kind": "ERROR", "errorKind": "FLAG_NOT_FOUND"]) + eventReporter.recordFlagEvaluationEvents(flagKey: flagKey, + value: result.value.toLDValue(), + defaultValue: defaultValue.toLDValue(), + featureFlag: featureFlag, + context: context, + includeReason: needsReason) + return result } - eventReporter.recordFlagEvaluationEvents(flagKey: flagKey, - value: result.value.toLDValue(), - defaultValue: defaultValue.toLDValue(), - featureFlag: featureFlag, - context: context, - includeReason: needsReason) - return result } } diff --git a/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/LDEvaluationDetail.swift b/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/LDEvaluationDetail.swift index 5ccc9675..3f31f94e 100644 --- a/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/LDEvaluationDetail.swift +++ b/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/LDEvaluationDetail.swift @@ -17,4 +17,13 @@ public final class LDEvaluationDetail { self.variationIndex = variationIndex self.reason = reason } + + /// Apply the `transform` function to the detail's inner value property, converting an + /// `LDEvaluationDetail` to an `LDEvaluationDetail`. + public func map(transform: ((_: T) -> U)) -> LDEvaluationDetail { + return LDEvaluationDetail( + value: transform(self.value), + variationIndex: self.variationIndex, + reason: self.reason) + } } diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift new file mode 100644 index 00000000..a375e37e --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Contextual information that will be provided to handlers during evaluation series. +public class EvaluationSeriesContext { + private let flagKey: String + private let context: LDContext + private let defaultValue: LDValue + private let methodName: String + + init(flagKey: String, context: LDContext, defaultValue: LDValue, methodName: String) { + self.flagKey = flagKey + self.context = context + self.defaultValue = defaultValue + self.methodName = methodName + } +} diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift new file mode 100644 index 00000000..d2a75ebc --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Implementation specific hook data for evaluation stages. +/// +/// Hook implementations can use this to store data needed between stages. +public typealias EvaluationSeriesData = [String: Any] + +/// Protocol for extending SDK functionality via hooks. +public protocol Hook { + /// Get metadata about the hook implementation. + func metadata() -> Metadata + /// The before method is called during the execution of a variation method before the flag value has been + /// determined. The method is executed synchronously. + func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> EvaluationSeriesData + /// The after method is called during the execution of the variation method after the flag value has been + /// determined. The method is executed synchronously. + func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail) -> EvaluationSeriesData +} diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/Metadata.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/Metadata.swift new file mode 100644 index 00000000..323d3e20 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/Metadata.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Metadata data class used for annotating hook implementations. +public class Metadata { + private let name: String + + init(name: String) { + self.name = name + } +} diff --git a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift index 3b09475d..beb9fe56 100644 --- a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift +++ b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift @@ -248,6 +248,8 @@ public struct LDConfig { /// The default behavior for environment attributes is to not modify any provided context UNLESS the developer specifically opts-in. static let autoEnvAttributes: Bool = false + static let hooks: [Hook] = [] + /// The default logger for the SDK. Can be overridden to provide customization. static let logger: OSLog = OSLog(subsystem: "com.launchdarkly", category: "ios-client-sdk") } @@ -423,6 +425,11 @@ public struct LDConfig { /// An NSObject wrapper for the Swift LDConfig struct. Intended for use in mixed apps when Swift code needs to pass a config into an Objective-C method. public var objcLdConfig: ObjcLDConfig { ObjcLDConfig(self) } + /// Initial set of hooks for the client. + /// + /// Hooks provide entry points which allow for observation of SDK functions. + public var hooks: [Hook] = Defaults.hooks + /// A Dictionary of identifying names to unique mobile keys for all environments private var mobileKeys: [String: String] { var internalMobileKeys = getSecondaryMobileKeys() diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientHookSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientHookSpec.swift new file mode 100644 index 00000000..3e032be9 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/LDClientHookSpec.swift @@ -0,0 +1,124 @@ +import Foundation +import OSLog +import Quick +import Nimble +import LDSwiftEventSource +import XCTest +@testable import LaunchDarkly + +final class LDClientHookSpec: XCTestCase { + func testRegistration() { + var count = 0 + let hook = MockHook(before: { _, data in count += 1; return data }, after: { _, data, _ in count += 2; return data }) + var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + config.hooks = [hook] + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + XCTAssertEqual(count, 3) + } + + func testEvaluationOrder() { + var callRecord: [String] = [] + let firstHook = MockHook(before: { _, data in callRecord.append("first before"); return data }, after: { _, data, _ in callRecord.append("first after"); return data }) + let secondHook = MockHook(before: { _, data in callRecord.append("second before"); return data }, after: { _, data, _ in callRecord.append("second after"); return data }) + var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + config.hooks = [firstHook, secondHook] + + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + XCTAssertEqual(callRecord.count, 4) + XCTAssertEqual(callRecord[0], "first before") + XCTAssertEqual(callRecord[1], "second before") + XCTAssertEqual(callRecord[2], "second after") + XCTAssertEqual(callRecord[3], "first after") + } + + func testEvaluationDetailIsCaptured() { + var detail: LDEvaluationDetail? = nil + let hook = MockHook(before: { _, data in return data }, after: { _, data, d in detail = d; return data }) + var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + config.hooks = [hook] + + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + + testContext.flagStoreMock.replaceStore(newStoredItems: FlagMaintainingMock.stubStoredItems()) + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + + guard let det = detail + else { + fail("Details were never set by closure.") + return + } + + XCTAssertEqual(det.value, true) + XCTAssertEqual(det.variationIndex, 2) + } + + func testBeforeHookPassesDataToAfterHook() { + var seriesData: EvaluationSeriesData? = nil + let beforeHook: BeforeHook = { _, seriesData in + var modified = seriesData + modified["before"] = "was called" + + return modified + } + let hook = MockHook(before: beforeHook, after: { _, sd, _ in seriesData = sd; return sd }) + var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + config.hooks = [hook] + + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + + testContext.flagStoreMock.replaceStore(newStoredItems: FlagMaintainingMock.stubStoredItems()) + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + + guard let data = seriesData + else { + fail("seriesData was never set by closure.") + return + } + + XCTAssertEqual(data["before"] as! String, "was called") + } + + typealias BeforeHook = (_: EvaluationSeriesContext, _: EvaluationSeriesData) -> EvaluationSeriesData + typealias AfterHook = (_: EvaluationSeriesContext, _: EvaluationSeriesData, _: LDEvaluationDetail) -> EvaluationSeriesData + + class MockHook: Hook { + let before: BeforeHook + let after: AfterHook + + init(before: @escaping BeforeHook, after: @escaping AfterHook) { + self.before = before + self.after = after + } + + func metadata() -> LaunchDarkly.Metadata { + return Metadata(name: "counting-hook") + } + + func beforeEvaluation(seriesContext: LaunchDarkly.EvaluationSeriesContext, seriesData: LaunchDarkly.EvaluationSeriesData) -> LaunchDarkly.EvaluationSeriesData { + return self.before(seriesContext, seriesData) + } + + func afterEvaluation(seriesContext: LaunchDarkly.EvaluationSeriesContext, seriesData: LaunchDarkly.EvaluationSeriesData, evaluationDetail: LaunchDarkly.LDEvaluationDetail) -> LaunchDarkly.EvaluationSeriesData { + return self.after(seriesContext, seriesData, evaluationDetail) + } + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index c11575a7..c9791f08 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -13,127 +13,6 @@ final class LDClientSpec: QuickSpec { fileprivate static let updateThreshold: TimeInterval = 0.05 } - struct DefaultFlagValues { - static let bool = false - static let int = 5 - static let double = 2.71828 - static let string = "default string value" - static let array: LDValue = [-1, -2] - static let dictionary: LDValue = ["sub-flag-x": true, "sub-flag-y": 1, "sub-flag-z": 42.42] - } - - class TestContext { - var config: LDConfig! - var context: LDContext! - var subject: LDClient! - let serviceFactoryMock: ClientServiceMockFactory - // mock getters based on setting up the context & subject - var serviceMock: DarklyServiceMock! { - subject.service as? DarklyServiceMock - } - var featureFlagCachingMock: FeatureFlagCachingMock! { - subject.flagCache as? FeatureFlagCachingMock - } - var flagStoreMock: FlagMaintainingMock! { - subject.flagStore as? FlagMaintainingMock - } - var flagSynchronizerMock: LDFlagSynchronizingMock! { - subject.flagSynchronizer as? LDFlagSynchronizingMock - } - var eventReporterMock: EventReportingMock! { - subject.eventReporter as? EventReportingMock - } - var changeNotifierMock: FlagChangeNotifyingMock! { - subject.flagChangeNotifier as? FlagChangeNotifyingMock - } - var environmentReporterMock: EnvironmentReportingMock! { - subject.environmentReporter as? EnvironmentReportingMock - } - var makeFlagSynchronizerStreamingMode: LDStreamingMode? { - serviceFactoryMock.makeFlagSynchronizerReceivedParameters?.streamingMode - } - var makeFlagSynchronizerPollingInterval: TimeInterval? { - serviceFactoryMock.makeFlagSynchronizerReceivedParameters?.pollingInterval - } - var makeFlagSynchronizerService: DarklyServiceProvider? { - serviceFactoryMock.makeFlagSynchronizerReceivedParameters?.service - } - var onSyncComplete: FlagSyncCompleteClosure? { - serviceFactoryMock.onFlagSyncComplete - } - var recordedEvent: LaunchDarkly.Event? { - eventReporterMock.recordReceivedEvent - } - var throttlerMock: ThrottlingMock? { - subject.throttler as? ThrottlingMock - } - - private(set) var cachedFlags: [String: [String: [LDFlagKey: FeatureFlag]]] = [:] - - init(newConfig: LDConfig? = nil, - startOnline: Bool = false, - streamingMode: LDStreamingMode = .streaming, - enableBackgroundUpdates: Bool = true) { - config = newConfig ?? LDConfig.stub(mobileKey: LDConfig.Constants.mockMobileKey, autoEnvAttributes: .disabled, isDebugBuild: false) - config.startOnline = startOnline - config.streamingMode = streamingMode - config.enableBackgroundUpdates = enableBackgroundUpdates - config.eventFlushInterval = 300.0 // 5 min...don't want this to trigger - - context = LDContext.stub() - - serviceFactoryMock = ClientServiceMockFactory(config: config) - serviceFactoryMock.makeFlagChangeNotifierReturnValue = FlagChangeNotifier(logger: OSLog(subsystem: "com.launchdarkly", category: "tests")) - - serviceFactoryMock.makeFeatureFlagCacheCallback = { - let mobileKey = self.serviceFactoryMock.makeFeatureFlagCacheReceivedParameters!.mobileKey - let mockCache = FeatureFlagCachingMock() - mockCache.getCachedDataCallback = { - mockCache.getCachedDataReturnValue = (items: StoredItems(items: self.cachedFlags[mobileKey]?[mockCache.getCachedDataReceivedCacheKey!] ?? [:]), etag: nil, lastUpdated: nil) - } - self.serviceFactoryMock.makeFeatureFlagCacheReturnValue = mockCache - } - } - - func withContext(_ context: LDContext?) -> TestContext { - self.context = context - return self - } - - func withCached(flags: [LDFlagKey: FeatureFlag]?) -> TestContext { - withCached(contextKey: context.contextHash(), flags: flags) - } - - func withCached(contextKey: String, flags: [LDFlagKey: FeatureFlag]?) -> TestContext { - var forEnv = cachedFlags[config.mobileKey] ?? [:] - forEnv[contextKey] = flags - cachedFlags[config.mobileKey] = forEnv - return self - } - - func start(runMode: LDClientRunMode = .foreground, completion: (() -> Void)? = nil) { - LDClient.start(serviceFactory: serviceFactoryMock, config: config, context: context) { - self.subject = LDClient.get() - if runMode == .background { - self.subject.setRunMode(.background) - } - completion?() - } - subject = LDClient.get() - } - - func start(runMode: LDClientRunMode = .foreground, timeOut: TimeInterval, timeOutCompletion: ((_ timedOut: Bool) -> Void)? = nil) { - LDClient.start(serviceFactory: serviceFactoryMock, config: config, context: context, startWaitSeconds: timeOut) { timedOut in - self.subject = LDClient.get() - if runMode == .background { - self.subject.setRunMode(.background) - } - timeOutCompletion?(timedOut) - } - subject = LDClient.get() - } - } - override func spec() { startSpec() moveToBackgroundSpec() diff --git a/LaunchDarkly/LaunchDarklyTests/TestContext.swift b/LaunchDarkly/LaunchDarklyTests/TestContext.swift new file mode 100644 index 00000000..65547987 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/TestContext.swift @@ -0,0 +1,124 @@ +import Foundation +import OSLog +@testable import LaunchDarkly + +class TestContext { + var config: LDConfig! + var context: LDContext! + var subject: LDClient! + let serviceFactoryMock: ClientServiceMockFactory + // mock getters based on setting up the context & subject + var serviceMock: DarklyServiceMock! { + subject.service as? DarklyServiceMock + } + var featureFlagCachingMock: FeatureFlagCachingMock! { + subject.flagCache as? FeatureFlagCachingMock + } + var flagStoreMock: FlagMaintainingMock! { + subject.flagStore as? FlagMaintainingMock + } + var flagSynchronizerMock: LDFlagSynchronizingMock! { + subject.flagSynchronizer as? LDFlagSynchronizingMock + } + var eventReporterMock: EventReportingMock! { + subject.eventReporter as? EventReportingMock + } + var changeNotifierMock: FlagChangeNotifyingMock! { + subject.flagChangeNotifier as? FlagChangeNotifyingMock + } + var environmentReporterMock: EnvironmentReportingMock! { + subject.environmentReporter as? EnvironmentReportingMock + } + var makeFlagSynchronizerStreamingMode: LDStreamingMode? { + serviceFactoryMock.makeFlagSynchronizerReceivedParameters?.streamingMode + } + var makeFlagSynchronizerPollingInterval: TimeInterval? { + serviceFactoryMock.makeFlagSynchronizerReceivedParameters?.pollingInterval + } + var makeFlagSynchronizerService: DarklyServiceProvider? { + serviceFactoryMock.makeFlagSynchronizerReceivedParameters?.service + } + var onSyncComplete: FlagSyncCompleteClosure? { + serviceFactoryMock.onFlagSyncComplete + } + var recordedEvent: LaunchDarkly.Event? { + eventReporterMock.recordReceivedEvent + } + var throttlerMock: ThrottlingMock? { + subject.throttler as? ThrottlingMock + } + + private(set) var cachedFlags: [String: [String: [LDFlagKey: FeatureFlag]]] = [:] + + init(newConfig: LDConfig? = nil, + startOnline: Bool = false, + streamingMode: LDStreamingMode = .streaming, + enableBackgroundUpdates: Bool = true) { + config = newConfig ?? LDConfig.stub(mobileKey: LDConfig.Constants.mockMobileKey, autoEnvAttributes: .disabled, isDebugBuild: false) + config.startOnline = startOnline + config.streamingMode = streamingMode + config.enableBackgroundUpdates = enableBackgroundUpdates + config.eventFlushInterval = 300.0 // 5 min...don't want this to trigger + + context = LDContext.stub() + + serviceFactoryMock = ClientServiceMockFactory(config: config) + serviceFactoryMock.makeFlagChangeNotifierReturnValue = FlagChangeNotifier(logger: OSLog(subsystem: "com.launchdarkly", category: "tests")) + + serviceFactoryMock.makeFeatureFlagCacheCallback = { + let mobileKey = self.serviceFactoryMock.makeFeatureFlagCacheReceivedParameters!.mobileKey + let mockCache = FeatureFlagCachingMock() + mockCache.getCachedDataCallback = { + mockCache.getCachedDataReturnValue = (items: StoredItems(items: self.cachedFlags[mobileKey]?[mockCache.getCachedDataReceivedCacheKey!] ?? [:]), etag: nil, lastUpdated: nil) + } + self.serviceFactoryMock.makeFeatureFlagCacheReturnValue = mockCache + } + } + + func withContext(_ context: LDContext?) -> TestContext { + self.context = context + return self + } + + func withCached(flags: [LDFlagKey: FeatureFlag]?) -> TestContext { + withCached(contextKey: context.contextHash(), flags: flags) + } + + func withCached(contextKey: String, flags: [LDFlagKey: FeatureFlag]?) -> TestContext { + var forEnv = cachedFlags[config.mobileKey] ?? [:] + forEnv[contextKey] = flags + cachedFlags[config.mobileKey] = forEnv + return self + } + + func start(runMode: LDClientRunMode = .foreground, completion: (() -> Void)? = nil) { + LDClient.start(serviceFactory: serviceFactoryMock, config: config, context: context) { + self.subject = LDClient.get() + if runMode == .background { + self.subject.setRunMode(.background) + } + completion?() + } + subject = LDClient.get() + } + + func start(runMode: LDClientRunMode = .foreground, timeOut: TimeInterval, timeOutCompletion: ((_ timedOut: Bool) -> Void)? = nil) { + LDClient.start(serviceFactory: serviceFactoryMock, config: config, context: context, startWaitSeconds: timeOut) { timedOut in + self.subject = LDClient.get() + if runMode == .background { + self.subject.setRunMode(.background) + } + timeOutCompletion?(timedOut) + } + subject = LDClient.get() + } +} + +struct DefaultFlagValues { + static let bool = false + static let int = 5 + static let double = 2.71828 + static let string = "default string value" + static let array: LDValue = [-1, -2] + static let dictionary: LDValue = ["sub-flag-x": true, "sub-flag-y": 1, "sub-flag-z": 42.42] +}