-
Notifications
You must be signed in to change notification settings - Fork 39
Expand file tree
/
Copy pathOptimizelyUserContext.swift
More file actions
481 lines (410 loc) · 20.3 KB
/
OptimizelyUserContext.swift
File metadata and controls
481 lines (410 loc) · 20.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//
// Copyright 2021-2022, Optimizely, Inc. and contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
public typealias DecideCompletion = (OptimizelyDecision) -> Void
public typealias DecideForKeysCompletion = ([String: OptimizelyDecision]) -> Void
/// An object for user contexts that the SDK will use to make decisions for.
public class OptimizelyUserContext {
weak var optimizely: OptimizelyClient?
public var userId: String
private var atomicAttributes: AtomicProperty<[String: Any?]>
public var attributes: [String: Any?] {
return atomicAttributes.property ?? [:]
}
private var atomicForcedDecisions: AtomicProperty<[OptimizelyDecisionContext: OptimizelyForcedDecision]>
var forcedDecisions: [OptimizelyDecisionContext: OptimizelyForcedDecision]? {
return atomicForcedDecisions.property
}
private var atomicQualifiedSegments: AtomicProperty<[String]>
/// an array of segment names that the user is qualified for. The result of **fetchQualifiedSegments()** will be saved here.
public var qualifiedSegments: [String]? {
get {
return atomicQualifiedSegments.property
}
// keep this public set api for clients to set directly (testing/debugging)
set {
atomicQualifiedSegments.property = newValue
}
}
var clone: OptimizelyUserContext? {
guard let optimizely = self.optimizely else { return nil }
let userContext = OptimizelyUserContext(optimizely: optimizely, userId: userId, attributes: attributes, identify: false)
if let fds = forcedDecisions {
userContext.atomicForcedDecisions.property = fds
}
if let qs = qualifiedSegments {
userContext.atomicQualifiedSegments.property = qs
}
return userContext
}
let logger = OPTLoggerFactory.getLogger()
/// OptimizelyUserContext init
///
/// - Parameters:
/// - optimizely: An instance of OptimizelyClient to be used for decisions.
/// - userId: The user ID to be used for bucketing.
/// - attributes: A map of attribute names to current user attribute values.
/// - region: The region for the user context (optional). Defaults to the region from the project config.
public convenience init(optimizely: OptimizelyClient,
userId: String,
attributes: [String: Any?]? = nil,
region: String? = nil) {
self.init(optimizely: optimizely, userId: userId, attributes: attributes ?? [:], identify: true)
}
init(optimizely: OptimizelyClient,
userId: String,
attributes: [String: Any?],
identify: Bool) {
self.optimizely = optimizely
self.userId = userId
let lock = DispatchQueue(label: "user-context")
self.atomicAttributes = AtomicProperty(property: attributes, lock: lock)
self.atomicForcedDecisions = AtomicProperty(property: nil, lock: lock)
self.atomicQualifiedSegments = AtomicProperty(property: nil, lock: lock)
if identify {
// async call so event building overhead is not blocking context creation
lock.async {
self.optimizely?.identifyUserToOdp(userId: userId)
}
}
}
/// Sets an attribute for a given key.
/// - Parameters:
/// - key: An attribute key
/// - value: An attribute value
public func setAttribute(key: String, value: Any?) {
atomicAttributes.performAtomic { attributes in
attributes[key] = value
}
}
/// Returns a decision result for a given flag key and a user context, which contains all data required to deliver the flag or experiment.
///
/// If the SDK finds an error (__sdkNotReady__, etc), it’ll return a decision with `nil` for `enabled` and `variationKey`. The decision will include an error message in `reasons` (regardless of the __includeReasons__ option).
///
/// - Parameters:
/// - key: A flag key for which a decision will be made.
/// - user: A user context. This is optional when a user context has been set before.
/// - options: An array of options for decision-making.
/// - Returns: A decision result.
public func decide(key: String,
options: [OptimizelyDecideOption]? = nil) -> OptimizelyDecision {
guard let optimizely = self.optimizely, let clone = self.clone else {
return OptimizelyDecision.errorDecision(key: key, user: self, error: .sdkNotReady)
}
return optimizely.decide(user: clone, key: key, options: options)
}
/// Asynchronously makes a feature decision for a given feature key.
///
/// - Parameters:
/// - key: The feature key to make a decision for
/// - options: Optional array of decision options that will be used for this decision only
/// - completion: A callback that receives the resulting OptimizelyDecision
///
/// - Note:
/// - If the SDK is not ready, this method will immediately return an error decision through the completion handler.
/// - The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler.
public func decideAsync(key: String,
options: [OptimizelyDecideOption]? = nil,
completion: @escaping DecideCompletion) {
guard let optimizely = self.optimizely, let clone = self.clone else {
let decision = OptimizelyDecision.errorDecision(key: key, user: self, error: .sdkNotReady)
completion(decision)
return
}
optimizely.decideAsync(user: clone, key: key, options: options, completion: completion)
}
/// Returns a decision result asynchronously for a given flag key
/// - Parameters:
/// - key: A flag key for which a decision will be made
/// - options: An array of options for decision-making
/// - Returns: A decision result
///
/// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func decideAsync(key: String,
options: [OptimizelyDecideOption]? = nil) async -> OptimizelyDecision {
return await withCheckedContinuation { continuation in
decideAsync(key: key, options: options) { decision in
continuation.resume(returning: decision)
}
}
}
/// Returns a key-map of decision results for multiple flag keys and a user context.
///
/// - If the SDK finds an error (__flagKeyInvalid__, etc) for a key, the response will include a decision for the key showing `reasons` for the error (regardless of __includeReasons__ in options).
/// - The SDK will always return key-mapped decisions. When it can not process requests (on __sdkNotReady__ error), it’ll return an empty map after logging the errors.
///
/// - Parameters:
/// - keys: An array of flag keys for which decisions will be made. When set to `nil`, the SDK will return decisions for all active flag keys.
/// - options: An array of options for decision-making.
/// - Returns: A dictionary of all decision results, mapped by flag keys.
public func decide(keys: [String],
options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] {
guard let optimizely = self.optimizely, let clone = self.clone else {
logger.e(OptimizelyError.sdkNotReady)
return [:]
}
return optimizely.decide(user: clone, keys: keys, options: options)
}
/// Asynchronously decides variations for multiple feature flags.
///
/// - Parameters:
/// - keys: An array of feature flag keys.
/// - options: An array of options for decision-making.
/// - completion: A callback that receives a dictionary mapping each feature flag key to its corresponding decision result.
///
/// - Note:
/// - If the SDK is not ready, this method will immediately return an error decision through the completion handler.
/// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler.
public func decideAsync(keys: [String],
options: [OptimizelyDecideOption]? = nil,
completion: @escaping DecideForKeysCompletion) {
guard let optimizely = self.optimizely, let clone = self.clone else {
logger.e(OptimizelyError.sdkNotReady)
completion([:])
return
}
optimizely.decideAsync(user: clone, keys: keys, options: options, completion: completion)
}
/// Returns decisions for multiple flag keys asynchronously
/// - Parameters:
/// - keys: An array of flag keys for which decisions will be made
/// - options: An array of options for decision-making
/// - Returns: A dictionary of all decision results, mapped by flag keys
///
/// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func decideAsync(keys: [String],
options: [OptimizelyDecideOption]? = nil) async -> [String: OptimizelyDecision] {
return await withCheckedContinuation { continuation in
decideAsync(keys: keys, options: options) { decisions in
continuation.resume(returning: decisions)
}
}
}
/// Returns a key-map of decision results for all active flag keys.
///
/// - Parameters:
/// - options: An array of options for decision-making.
/// - Returns: A dictionary of all decision results, mapped by flag keys.
public func decideAll(options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] {
guard let optimizely = self.optimizely, let clone = self.clone else {
logger.e(OptimizelyError.sdkNotReady)
return [:]
}
return optimizely.decideAll(user: clone, options: options)
}
/// Asynchronously makes a decision for all features and experiments for this user.
///
/// - Parameters:
/// - options: An array of decision options. If not provided, the default options will be used.
/// - completion: A closure that will be called with the decision results for all keys.
/// The closure takes a dictionary of feature/experiment keys to their corresponding decision results.
///
/// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler.
public func decideAllAsync(options: [OptimizelyDecideOption]? = nil, completion: @escaping DecideForKeysCompletion) {
guard let optimizely = self.optimizely, let clone = self.clone else {
logger.e(OptimizelyError.sdkNotReady)
completion([:])
return
}
optimizely.decideAllAsync(user: clone, options: options, completion: completion)
}
/// Returns decisions for all active flag keys asynchronously
/// - Parameter options: An array of options for decision-making
/// - Returns: A dictionary of all decision results, mapped by flag keys
///
/// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func decideAllAsync(options: [OptimizelyDecideOption]? = nil) async -> [String: OptimizelyDecision] {
return await withCheckedContinuation { continuation in
decideAllAsync(options: options) { decisions in
continuation.resume(returning: decisions)
}
}
}
/// Tracks an event.
///
/// - Parameters:
/// - eventKey: The event name.
/// - eventTags: A map of event tag names to event tag values (NSString or NSNumber containing float, double, integer, or boolean).
/// - Throws: `OptimizelyError` if an error is detected.
public func trackEvent(eventKey: String,
eventTags: OptimizelyEventTags? = nil) throws {
guard let optimizely = self.optimizely else {
throw OptimizelyError.sdkNotReady
}
try optimizely.track(eventKey: eventKey,
userId: userId,
attributes: attributes,
eventTags: eventTags)
}
}
// MARK: - ODP
extension OptimizelyUserContext {
/// Fetch (non-blocking) all qualified segments for the user context.
///
/// The segments fetched will be saved in **qualifiedSegments** and can be accessed any time.
/// On failure, **qualifiedSegments** will be nil and one of these errors will be returned:
/// - OptimizelyError.invalidSegmentIdentifier
/// - OptimizelyError.fetchSegmentsFailed(String)
///
/// - Parameters:
/// - options: A set of options for fetching qualified segments (optional).
/// - completionHandler: A completion handler to be called with the fetch result. On success, it'll pass a nil error. On failure, it'll pass a non-nil error .
public func fetchQualifiedSegments(options: [OptimizelySegmentOption] = [],
completionHandler: @escaping (OptimizelyError?) -> Void) {
// on failure, qualifiedSegments should be reset if a previous value exists.
self.atomicQualifiedSegments.property = nil
guard let optimizely = self.optimizely else {
completionHandler(.sdkNotReady)
return
}
optimizely.fetchQualifiedSegments(userId: userId, options: options) { segments, err in
guard err == nil, let segments = segments else {
let error = err ?? OptimizelyError.fetchSegmentsFailed("invalid segments")
self.logger.e(error)
completionHandler(error)
return
}
self.atomicQualifiedSegments.property = segments
completionHandler(nil)
}
}
/// Fetch (non-blocking) all qualified segments for the user context.
///
/// The segments fetched will be saved in **qualifiedSegments** and can be accessed any time.
/// On failure, **qualifiedSegments** will be nil and one of these errors will be thrown:
/// - OptimizelyError.invalidSegmentIdentifier
/// - OptimizelyError.fetchSegmentsFailed(String)
///
/// - Parameters:
/// - options: A set of options for fetching qualified segments (optional).
/// - Throws: `OptimizelyError` if error is detected
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func fetchQualifiedSegments(options: [OptimizelySegmentOption] = []) async throws {
return try await withCheckedThrowingContinuation { continuation in
fetchQualifiedSegments { error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
/// Fetch (blocking) all qualified segments for the user context.
///
/// Note that this call will block the calling thread until fetching is completed.
/// The segments fetched will be saved in **qualifiedSegments** and can be accessed any time.
/// On failure, **qualifiedSegments** will be nil and one of these errors will be thrown:
/// - OptimizelyError.invalidSegmentIdentifier
/// - OptimizelyError.fetchSegmentsFailed(String)
///
/// - Parameters:
/// - options: A set of options for fetching qualified segments (optional).
public func fetchQualifiedSegments(options: [OptimizelySegmentOption] = []) throws {
var error: OptimizelyError?
let semaphore = DispatchSemaphore(value: 0)
fetchQualifiedSegments(options: options) { asyncError in
error = asyncError
semaphore.signal()
}
semaphore.wait()
if let err = error { throw err }
}
/// Check if the user is qualified for the given segment.
///
/// - Parameter segment: the segment name to check qualification for.
/// - Returns: true if qualified.
public func isQualifiedFor(segment: String) -> Bool {
return atomicQualifiedSegments.property?.contains(segment) ?? false
}
}
// MARK: - ForcedDecisions
/// Decision Context
public struct OptimizelyDecisionContext: Hashable {
public let flagKey: String
public let ruleKey: String?
public init(flagKey: String, ruleKey: String? = nil) {
self.flagKey = flagKey
self.ruleKey = ruleKey
}
}
/// Forced Decision
public struct OptimizelyForcedDecision: Equatable {
public let variationKey: String
public init(variationKey: String) {
self.variationKey = variationKey
}
}
extension OptimizelyUserContext {
/// Sets the forced decision for a given decision context.
/// - Parameters:
/// - context: A decision context.
/// - decision: A forced decision.
/// - Returns: true if the forced decision has been set successfully.
public func setForcedDecision(context: OptimizelyDecisionContext, decision: OptimizelyForcedDecision) -> Bool {
// create on the first setForcedDecision call
if forcedDecisions == nil {
atomicForcedDecisions.property = [:]
}
atomicForcedDecisions.performAtomic { property in
property[context] = decision
}
return true
}
/// Returns the forced decision for a given decision context.
/// - Parameters:
/// - context: A decision context
/// - Returns: A forced decision or nil if forced decisions are not set for the decision context.
public func getForcedDecision(context: OptimizelyDecisionContext) -> OptimizelyForcedDecision? {
return atomicForcedDecisions.property?[context]
}
/// Removes the forced decision for a given decision context.
/// - Parameters:
/// - context: A decision context.
/// - Returns: true if the forced decision has been removed successfully.
public func removeForcedDecision(context: OptimizelyDecisionContext) -> Bool {
var exist = false
atomicForcedDecisions.performAtomic { property in
exist = property[context] != nil
property[context] = nil
}
return exist
}
/// Removes all forced decisions bound to this user context.
/// - Returns: true if forced decisions have been removed successfully.
public func removeAllForcedDecisions() -> Bool {
atomicForcedDecisions.property = nil
return true
}
}
// MARK: - Equatable
extension OptimizelyUserContext: Equatable {
public static func == (lhs: OptimizelyUserContext, rhs: OptimizelyUserContext) -> Bool {
return lhs.userId == rhs.userId &&
(lhs.attributes as NSDictionary).isEqual(to: rhs.attributes as [AnyHashable: Any]) &&
lhs.forcedDecisions == rhs.forcedDecisions &&
lhs.qualifiedSegments == rhs.qualifiedSegments
}
}
// MARK: - CustomStringConvertible
extension OptimizelyUserContext: CustomStringConvertible {
public var description: String {
return "{ userId: \(userId), attributes: \(attributes) }"
}
}