Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 6 additions & 16 deletions LaunchDarkly/LaunchDarkly/LDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ public class LDClient {
*/
@available(*, deprecated, message: "Use LDClient.identify(context: completion:) with non-optional completion parameter")
public func identify(context: LDContext, completion: (() -> Void)? = nil) {
_identify(context: context, sheddable: false, useCache: .yes) { _ in
_identifyHooked(context: context, sheddable: false, useCache: .yes, timeout: 0) { _ in
if let completion = completion {
completion()
}
Expand All @@ -313,7 +313,7 @@ public class LDClient {
- parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays.
*/
public func identify(context: LDContext, completion: @escaping (_ result: IdentifyResult) -> Void) {
_identify(context: context, sheddable: true, useCache: .yes, completion: completion)
_identifyHooked(context: context, sheddable: true, useCache: .yes, timeout: 0, completion: completion)
}

/**
Expand All @@ -327,12 +327,12 @@ public class LDClient {
- parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays.
*/
public func identify(context: LDContext, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) {
_identify(context: context, sheddable: true, useCache: useCache, completion: completion)
_identifyHooked(context: context, sheddable: true, useCache: useCache, timeout: 0, completion: completion)
}

// Temporary helper method to allow code sharing between the sheddable and unsheddable identify methods. In the next major release, we will remove the deprecated identify method and inline
// this implementation in the other one.
private func _identify(context: LDContext, sheddable: Bool, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) {
func _identify(context: LDContext, sheddable: Bool, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) {
let work: TaskHandler = { taskCompletion in
let dispatch = DispatchGroup()

Expand All @@ -352,7 +352,7 @@ public class LDClient {
}
identifyQueue.enqueue(request: identifyTask)
}

/**
Sets the LDContext into the LDClient inline with the behavior detailed on `LDClient.identify(context: completion:)`. Additionally,
this method will ensure the `completion` parameter will be called within the specified time interval.
Expand Down Expand Up @@ -385,17 +385,7 @@ public class LDClient {
os_log("%s LDClient.identify was called with a timeout greater than %f seconds. We recommend a timeout of less than %f seconds.", log: config.logger, type: .info, self.typeName(and: #function), LDClient.longTimeoutInterval, LDClient.longTimeoutInterval)
}

TimeoutExecutor.run(
timeout: timeout,
queue: .global(),
operation: { done in
self.identify(context: context, useCache: useCache) { result in
done(result)
}
},
timeoutValue: .timeout,
completion: completion
)
self._identifyHooked(context: context, sheddable: true, useCache: useCache, timeout: timeout, completion: completion)
}

func internalIdentify(newContext: LDContext, useCache: IdentifyCacheUsage, completion: (() -> Void)? = nil) {
Expand Down
71 changes: 71 additions & 0 deletions LaunchDarkly/LaunchDarkly/LDClientIdentifyHook.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Foundation

extension LDClient {
private struct IdentifyHookState {
let seriesContext: IdentifySeriesContext
let seriesData: [IdentifySeriesData]
let hooksSnapshot: [Hook]
}

private func executeWithIdentifyHooks(context: LDContext, work: @escaping ((@escaping (IdentifyResult) -> Void)) -> Void) {
let state = executeBeforeIdentifyHooks(context: context)
work() { [weak self] result in
guard let state else {
return
}
self?.executeAfterIdentifyHooks(state: state, result: result)
}
}

private func executeBeforeIdentifyHooks(context: LDContext) -> IdentifyHookState? {
guard !hooks.isEmpty else {
return nil
}

let hooksSnapshot = self.hooks
let seriesContext = IdentifySeriesContext(context: context, methodName: "identify")
let seriesData = hooksSnapshot.map { hook in
hook.beforeIdentify(seriesContext: seriesContext, seriesData: EvaluationSeriesData())
}
return IdentifyHookState(seriesContext: seriesContext, seriesData: seriesData, hooksSnapshot: hooksSnapshot)
}

private func executeAfterIdentifyHooks(state: IdentifyHookState, result: IdentifyResult) {
guard !state.hooksSnapshot.isEmpty else {
return
}

// Invoke hooks in reverse order and give them back the series data they gave us.
zip(state.hooksSnapshot, state.seriesData).reversed().forEach { (hook, data) in
_ = hook.afterIdentify(seriesContext: state.seriesContext, seriesData: data, result: result)
}
}

func _identifyHooked(context: LDContext, sheddable: Bool, useCache: IdentifyCacheUsage, timeout: TimeInterval, completion: @escaping (_ result: IdentifyResult) -> Void) {
if timeout > 0 {
executeWithIdentifyHooks(context: context) { hooksCompletion in
TimeoutExecutor.run(
timeout: timeout,
queue: .global(),
operation: { [weak self] done in
self?._identify(context: context, sheddable: sheddable, useCache: useCache) { result in
done(result)
}
},
timeoutValue: .timeout,
completion: { result in
completion(result)
hooksCompletion(result)
}
)
}
} else {
executeWithIdentifyHooks(context: context) { [weak self] hooksCompletion in
self?._identify(context: context, sheddable: sheddable, useCache: useCache) { result in
completion(result)
hooksCompletion(result)
}
}
}
}
}
2 changes: 1 addition & 1 deletion LaunchDarkly/LaunchDarkly/LDClientVariation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ extension LDClient {
}

private func evaluateWithHooks<D>(flagKey: LDFlagKey, defaultValue: D, methodName: String, evaluation: () -> LDEvaluationDetail<D>) -> LDEvaluationDetail<D> where D: LDValueConvertible, D: Decodable {
if self.hooks.isEmpty {
guard !self.hooks.isEmpty else {
return evaluation()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation


/// Contextual information that will be provided to handlers during evaluation series.
public class EvaluationSeriesContext {
/// The key of the flag being evaluated.
Expand Down
68 changes: 64 additions & 4 deletions LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,62 @@ import Foundation
///
/// Hook implementations can use this to store data needed between stages.
public typealias EvaluationSeriesData = [String: Any]
public typealias IdentifySeriesData = [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.

/// Executed by the SDK at the start of the evaluation of a feature flag.
///
/// This is not executed as part of a call to `LDClient.allFlags()`.
///
/// To provide custom data to the series which will be given back to your Hook at the next stage of the
/// series, return a dictionary containing the custom data. You should initialize this dictionary from the
/// `seriesData`.
///
/// - Parameters:
/// - seriesContext: Container of parameters associated with this evaluation.
/// - seriesData: Immutable data from the previous stage in the evaluation series.
/// `beforeEvaluation` is the first stage in this series, so this will be an empty dictionary.
/// - Returns: A dictionary containing custom data that will be carried through to the next stage of the series.
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.

/// Executed by the SDK after the evaluation of a feature flag completes.
///
/// This is not executed as part of a call to `LDClient.allFlags()`.
///
/// This is currently the last stage of the evaluation series in the Hook, but that may not be the case in the
/// future. To ensure forward compatibility, return the `seriesData` unmodified.
///
/// - Parameters:
/// - seriesContext: Container of parameters associated with this evaluation.
/// - seriesData: Immutable data from the previous stage in the evaluation series.
/// - evaluationDetail: The result of the evaluation that took place before this hook was invoked.
/// - Returns: A dictionary containing custom data that will be carried through to the next stage of the series (if added in the future).
func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail<LDValue>) -> EvaluationSeriesData

/// To provide custom data to the series which will be given back to your Hook at the next stage of the series,
/// return a dictionary containing the custom data. You should initialize this dictionary from the `seriesData`.
///
/// - Parameters:
/// - seriesContext: Contains information about the identify operation being performed. This is not mutable.
/// - seriesData: A record associated with each stage of hook invocations. Each stage is called with the data of the previous stage for a series. The input record should not be modified.
/// - Returns: A dictionary containing custom data that will be carried through to the next stage of the series.
func beforeIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData) -> IdentifySeriesData

/// Called during the execution of the identify process, after the operation completes.
///
/// This is currently the last stage of the identify series in the Hook, but that may not be the case in the future.
/// To ensure forward compatibility, return the `seriesData` unmodified.
///
/// - Parameters:
/// - seriesContext: Contains information about the identify operation being performed. This is not mutable.
/// - seriesData: A record associated with each stage of hook invocations. Each stage is called with the data of the previous stage for a series. The input record should not be modified.
/// - result: The result of the identify operation.
/// - Returns: A dictionary containing custom data that will be carried through to the next stage of the series (if added in the future).
func afterIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData, result: IdentifyResult) -> IdentifySeriesData
}

public extension Hook {
Expand All @@ -34,4 +79,19 @@ public extension Hook {
func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail<LDValue>) -> EvaluationSeriesData {
return seriesData
}

/// Called during the execution of the identify process before the operation completes,
/// but after any context modifications are performed.
///
/// Default implementation is a no-op that returns `seriesData` unchanged.
func beforeIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData) -> IdentifySeriesData {
return seriesData
}

/// Called during the execution of the identify process, after the operation completes.
///
/// Default implementation is a no-op that returns `seriesData` unchanged.
func afterIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData, result: IdentifyResult) -> IdentifySeriesData {
return seriesData
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

/// Contextual information that will be provided to handlers during identify series.
public class IdentifySeriesContext {
/// The context involved in the identify operation.
public let context: LDContext
/// A string identifying the name of the method called.
public let methodName: String

init(context: LDContext, methodName: String) {
self.context = context
self.methodName = methodName
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import LDSwiftEventSource
import XCTest
@testable import LaunchDarkly

final class LDClientHookSpec: XCTestCase {
final class LDClientEvaluationHookSpec: XCTestCase {
func testRegistration() {
var count = 0
let hook = MockHook(before: { _, data in count += 1; return data }, after: { _, data, _ in count += 2; return data })
Expand Down
119 changes: 119 additions & 0 deletions LaunchDarkly/LaunchDarklyTests/LDClientIdentifyHookSpec.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import Foundation
import OSLog
import Quick
import Nimble
import LDSwiftEventSource
import XCTest
@testable import LaunchDarkly

final class LDClientIdentifyHookSpec: 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.identify(context: LDContext.stub()) { _ in }
expect(count).toEventually(equal(3))
}

func testRegistrationWithTimeout() {
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.identify(context: LDContext.stub(), timeout: 30.0) { _ in }
expect(count).toEventually(equal(3))
}

func testIdentifyOrder() {
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.identify(context: LDContext.stub()) { _ in }
expect(callRecord).toEventually(equal(["first before", "second before", "second after", "first after"]))
}

func testIdentifyResultIsCaptured() {
var captured: IdentifyResult? = nil
let hook = MockHook(before: { _, data in return data }, after: { _, data, result in captured = result; 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.identify(context: LDContext.stub()) { _ in }

expect(captured).toEventually(equal(.complete))
}

func testBeforeHookPassesDataToAfterHook() {
var seriesData: IdentifySeriesData? = 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.subject.identify(context: LDContext.stub()) { _ in }

expect(seriesData?["before"] as? String).toEventually(equal("was called"))
}

typealias BeforeHook = (_: IdentifySeriesContext, _: IdentifySeriesData) -> IdentifySeriesData
typealias AfterHook = (_: IdentifySeriesContext, _: IdentifySeriesData, _: IdentifyResult) -> IdentifySeriesData

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 beforeIdentify(seriesContext: LaunchDarkly.IdentifySeriesContext, seriesData: LaunchDarkly.IdentifySeriesData) -> LaunchDarkly.IdentifySeriesData {
return self.before(seriesContext, seriesData)
}

func afterIdentify(seriesContext: LaunchDarkly.IdentifySeriesContext, seriesData: LaunchDarkly.IdentifySeriesData, result: LaunchDarkly.IdentifyResult) -> LaunchDarkly.IdentifySeriesData {
return self.after(seriesContext, seriesData, result)
}
}
}