• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

optimizely / swift-sdk / 28036748129

23 Jun 2026 03:23PM UTC coverage: 93.833% (+0.005%) from 93.828%
28036748129

push

github

web-flow
[AI-FSSDK] [FSSDK-12804] prepare for release swift v5.4.0 (#640)

1126 of 1200 relevant lines covered (93.83%)

8937.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.83
/Sources/Data%20Model/ProjectConfig.swift
1
//
2
// Copyright 2019-2022, Optimizely, Inc. and contributors
3
//
4
// Licensed under the Apache License, Version 2.0 (the "License");
5
// you may not use this file except in compliance with the License.
6
// You may obtain a copy of the License at
7
//
8
//    http://www.apache.org/licenses/LICENSE-2.0
9
//
10
// Unless required by applicable law or agreed to in writing, software
11
// distributed under the License is distributed on an "AS IS" BASIS,
12
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
// See the License for the specific language governing permissions and
14
// limitations under the License.
15
//
16

17
import Foundation
18

19
class ProjectConfig {
20
    var project: Project! {
21
        didSet {
308✔
22
            updateProjectDependentProps()
23
        }
24
    }
25
    
26
    let logger = OPTLoggerFactory.getLogger()
3,233✔
27
    
28
    // local runtime forcedVariations [UserId: [ExperimentId: VariationId]]
29
    // NOTE: experiment.forcedVariations use [ExperimentKey: VariationKey] instead of ids
30
    var whitelistUsers = AtomicProperty(property: [String: [String: String]]())
3,233✔
31
    
32
    var experimentKeyMap = [String: Experiment]()
3,233✔
33
    var experimentIdMap = [String: Experiment]()
3,233✔
34
    var experimentFeatureMap = [String: [String]]()
3,233✔
35
    var eventKeyMap = [String: Event]()
3,233✔
36
    var attributeKeyMap = [String: Attribute]()
3,233✔
37
    var attributeIdMap = [String: Attribute]()
3,233✔
38
    var featureFlagKeyMap = [String: FeatureFlag]()
3,233✔
39
    var featureFlagKeys = [String]()
3,233✔
40
    var rolloutIdMap = [String: Rollout]()
3,233✔
41
    var allExperiments = [Experiment]()
3,233✔
42
    var flagVariationsMap = [String: [Variation]]()
3,233✔
43
    var allSegments = [String]()
3,233✔
44
    var holdoutConfig = HoldoutConfig()
3,233✔
45

46
    // MARK: - Init
47
    
48
    init(datafile: Data) throws {
3,207✔
49
        var project: Project
50
        do {
51
            project = try JSONDecoder().decode(Project.self, from: datafile)
52
        } catch {
53
            throw OptimizelyError.dataFileInvalid
54
        }
55
        
56
        if !isValidVersion(version: project.version) {
57
            throw OptimizelyError.dataFileVersionInvalid(project.version)
58
        }
59

60
        self.project = project
61
        updateProjectDependentProps()  // project:didSet is not fired in init. explicitly called.
62
    }
63
    
64
    convenience init(datafile: String) throws {
×
65
        try self.init(datafile: Data(datafile.utf8))
66
    }
67
    
68
    init() {}
26✔
69
    
70
    func updateProjectDependentProps() {
3,495✔
71
        
72
        self.allExperiments = project.experiments + project.groups.map { $0.experiments }.flatMap { $0 }
304✔
73
        
74
        self.rolloutIdMap = {
3,495✔
75
            var map = [String: Rollout]()
76
            project.rollouts.forEach { map[$0.id] = $0 }
3,086✔
77
            return map
78
        }()
79

80
        // Feature Rollout injection: for each feature flag, inject the "everyone else"
81
        // variation into any experiment with type == .featureRollout
82
        injectFeatureRolloutVariations()
83
        
84
        // Holdout sections are partitioned by the datafile (FSSDK-12760):
85
        //   - `holdouts`      -> global holdouts (applied to every flag)
86
        //   - `localHoldouts` -> rule-scoped local holdouts
87
        // HoldoutConfig enforces the partition: `includedRules` on global-section
88
        // entries is stripped, and local-section entries without `includedRules`
89
        // are logged and excluded.
90
        holdoutConfig = HoldoutConfig(
91
            globalHoldouts: project.holdouts,
92
            localHoldouts: project.localHoldouts
93
        )
94
        
95
        self.experimentKeyMap = {
3,495✔
96
            var map = [String: Experiment]()
97
            allExperiments.forEach { exp in
9,452✔
98
                map[exp.key] = exp
99
            }
100
            return map
101
        }()
102
        
103
        self.experimentIdMap = {
3,495✔
104
            var map = [String: Experiment]()
105
            allExperiments.forEach { map[$0.id] = $0 }
9,452✔
106
            return map
107
        }()
108
        
109
        self.experimentFeatureMap = {
3,495✔
110
            var experimentFeatureMap = [String: [String]]()
111
            project.featureFlags.forEach { (ff) in
4,144✔
112
                ff.experimentIds.forEach {
1,318✔
113
                    if var arr = experimentFeatureMap[$0] {
114
                        arr.append(ff.id)
115
                        experimentFeatureMap[$0] = arr
116
                    } else {
117
                        experimentFeatureMap[$0] = [ff.id]
118
                    }
119
                }
120
            }
121
            return experimentFeatureMap
122
        }()
123
        
124
        self.eventKeyMap = {
3,495✔
125
            var eventKeyMap = [String: Event]()
126
            project.events.forEach { eventKeyMap[$0.key] = $0 }
6,110✔
127
            return eventKeyMap
128
        }()
129
        
130
        self.attributeKeyMap = {
3,495✔
131
            var map = [String: Attribute]()
132
            project.attributes.forEach { map[$0.key] = $0 }
4,092✔
133
            return map
134
        }()
135
        
136
        self.attributeIdMap = {
3,495✔
137
            var map = [String: Attribute]()
138
            project.attributes.forEach { map[$0.id] = $0 }
4,092✔
139
            return map
140
        }()
141
        
142
        self.featureFlagKeyMap = {
3,495✔
143
            var map = [String: FeatureFlag]()
144
            project.featureFlags.forEach { map[$0.key] = $0 }
4,144✔
145
            return map
146
        }()
147
        
148
        self.featureFlagKeys = {
3,495✔
149
            return project.featureFlags.map { $0.key }
4,144✔
150
        }()
151

152
        // all variations for each flag
153
        // - datafile does not contain a separate entity for this.
154
        // - we collect variations used in each rule (experiment rules and delivery rules)
155
        
156
        self.flagVariationsMap = {
3,495✔
157
            var map = [String: [Variation]]()
158
            
159
            project.featureFlags.forEach { flag in
4,144✔
160
                var variations = [Variation]()
161
                
162
                getAllRulesForFlag(flag).forEach { rule in
4,826✔
163
                    rule.variations.forEach { variation in
6,083✔
164
                        if variations.filter({ $0.id == variation.id }).first == nil {
4,140✔
165
                            variations.append(variation)
166
                        }
167
                    }
168
                }
169
                map[flag.key] = variations
170
            }
171
            
172
            return map
173
        }()
174
        
175
        self.allSegments = {
3,495✔
176
            let audiences = project.typedAudiences ?? []
3,068✔
177
            return Array(Set(audiences.flatMap { $0.getSegments() }))
5,184✔
178
        }()
179
        
180
    }
181
    
182
    func getGlobalHoldouts() -> [Holdout] {
4,033✔
183
        return holdoutConfig.getGlobalHoldouts()
184
    }
185

186
    func getHoldoutsForRule(ruleId: String) -> [Holdout] {
3,823✔
187
        return holdoutConfig.getHoldoutsForRule(ruleId: ruleId)
188
    }
189
        
190
    func getAllRulesForFlag(_ flag: FeatureFlag) -> [Experiment] {
4,144✔
191
        var rules = flag.experimentIds.compactMap { experimentIdMap[$0] }
1,318✔
192
        let rollout = self.rolloutIdMap[flag.rolloutId]
193
        rules.append(contentsOf: rollout?.experiments ?? [])
1,090✔
194
        return rules
195
    }
196

197
}
198

199
// MARK: - Feature Rollout Injection
200

201
extension ProjectConfig {
202
    /// Injects the "everyone else" variation from a flag's rollout into any
203
    /// experiment with type == .featureRollout. After injection the existing
204
    /// decision logic evaluates feature rollouts without modification.
205
    func injectFeatureRolloutVariations() {
3,495✔
206
        for flag in project.featureFlags {
207
            guard let everyoneElseVariation = getEveryoneElseVariation(for: flag) else {
208
                continue
209
            }
210

211
            for experimentId in flag.experimentIds {
212
                guard let index = allExperiments.firstIndex(where: { $0.id == experimentId }) else {
724✔
213
                    continue
214
                }
215

216
                guard allExperiments[index].isFeatureRollout else {
217
                    continue
218
                }
219

220
                allExperiments[index].variations.append(everyoneElseVariation)
221
                allExperiments[index].trafficAllocation.append(
222
                    TrafficAllocation(entityId: everyoneElseVariation.id, endOfRange: 10000)
223
                )
224
            }
225
        }
226
    }
227

228
    /// Returns the first variation of the last experiment (the "everyone else"
229
    /// rule) in the rollout associated with the given feature flag. Returns nil
230
    /// if the rollout cannot be resolved or has no variations.
231
    func getEveryoneElseVariation(for flag: FeatureFlag) -> Variation? {
4,144✔
232
        guard !flag.rolloutId.isEmpty,
233
              let rollout = rolloutIdMap[flag.rolloutId],
234
              let everyoneElseRule = rollout.experiments.last,
235
              let variation = everyoneElseRule.variations.first else {
236
            return nil
237
        }
238
        return variation
239
    }
240
}
241

242
// MARK: - Persistent Data
243

244
extension ProjectConfig {
245
    func whitelistUser(userId: String, experimentId: String, variationId: String) {
1,011✔
246
        whitelistUsers.performAtomic { whitelist in
1,011✔
247
            var dict = whitelist[userId] ?? [String: String]()
110✔
248
            dict[experimentId] = variationId
249
            whitelist[userId] = dict
250
        }
251
    }
252
    
253
    func removeFromWhitelist(userId: String, experimentId: String) {
1,001✔
254
        whitelistUsers.performAtomic { whitelist in
1,001✔
255
            whitelist[userId]?.removeValue(forKey: experimentId)
256
        }
257
    }
258
    
259
    func getWhitelistedVariationId(userId: String, experimentId: String) -> String? {
5,656✔
260
        if let dict = whitelistUsers.property?[userId] {
261
            return dict[experimentId]
262
        }
263
        
264
        logger.d(.userHasNoForcedVariation(userId))
265
        return nil
266
    }
267
    
268
    func isValidVersion(version: String) -> Bool {
3,190✔
269
        // old versions (< 4) of datafiles not supported
270
        return ["4"].contains(version)
271
    }
272
}
273

274
// MARK: - Project Access
275

276
extension ProjectConfig {
277
    
278
    /**
279
     * Get the region value. Defaults to US if not specified in the project.
280
     */
281
    public var region: Region {
1,514✔
282
        return project.region ?? .US
1,514✔
283
    }
284
    
285
    /**
286
     * Get sendFlagDecisions value.
287
     */
288
    var sendFlagDecisions: Bool {
2,966✔
289
        return project.sendFlagDecisions ?? false
2,516✔
290
    }
291
    
292
    /**
293
     * ODP API server publicKey.
294
     */
295
    var publicKeyForODP: String? {
3,148✔
296
        return project.integrations?.filter { $0.key == "odp" }.first?.publicKey
37✔
297
    }
298
    
299
    /**
300
     * ODP API server host.
301
     */
302
    var hostForODP: String? {
3,148✔
303
        return project.integrations?.filter { $0.key == "odp" }.first?.host
37✔
304
    }
305
    
306
    /**
307
     * Get an Experiment object for a key.
308
     */
309
    func getExperiment(key: String) -> Experiment? {
8,806✔
310
        return experimentKeyMap[key]
311
    }
312
    
313
    /**
314
     * Get an Experiment object for an Id.
315
     */
316
    func getExperiment(id: String) -> Experiment? {
1,205✔
317
        return experimentIdMap[id]
318
    }
319
    
320
    /**
321
     * Get an experiment Id for the human readable experiment key
322
     **/
323
    func getExperimentId(key: String) -> String? {
5✔
324
        return getExperiment(key: key)?.id
325
    }
326
    
327
    /**
328
     * Get a Group object for an Id.
329
     */
330
    func getGroup(id: String) -> Group? {
6✔
331
        return project.groups.filter { $0.id == id }.first
10✔
332
    }
333
    
334
    /**
335
     * Get a Feature Flag object for a key.
336
     */
337
    func getFeatureFlag(key: String) -> FeatureFlag? {
5,664✔
338
        return featureFlagKeyMap[key]
339
    }
340
    
341
    /**
342
     * Get all Feature Flag objects.
343
     */
344
    func getFeatureFlags() -> [FeatureFlag] {
4✔
345
        return project.featureFlags
346
    }
347
    
348
    /**
349
     * Get a Rollout object for an Id.
350
     */
351
    func getRollout(id: String) -> Rollout? {
2,595✔
352
        return rolloutIdMap[id]
353
    }
354
    
355
    /**
356
     * Get a Holdout object for an Id.
357
     */
358
    func getHoldout(id: String) -> Holdout? {
×
359
        return holdoutConfig.getHoldout(id: id)
360
    }
361
    
362
    /**
363
     * Gets an event for a corresponding event key
364
     */
365
    func getEvent(key: String) -> Event? {
68✔
366
        return eventKeyMap[key]
367
    }
368
    
369
    /**
370
     * Gets an event id for a corresponding event key
371
     */
372
    func getEventId(key: String) -> String? {
1✔
373
        return getEvent(key: key)?.id
374
    }
375
    
376
    /**
377
     * Get an attribute for a given key.
378
     */
379
    func getAttribute(key: String) -> Attribute? {
210✔
380
        return attributeKeyMap[key]
381
    }
382
    
383
    /**
384
     * Get an attribute for a given id.
385
     */
386
    func getAttribute(id: String) -> Attribute? {
45✔
387
        return attributeIdMap[id]
388
    }
389
    
390
    /**
391
     * Get an attribute Id for a given key.
392
     **/
393
    func getAttributeId(key: String) -> String? {
209✔
394
        return getAttribute(key: key)?.id
395
    }
396
    
397
    /**
398
     * Get an audience for a given audience id.
399
     */
400
    func getAudience(id: String) -> Audience? {
1✔
401
        return project.getAudience(id: id)
402
    }
403
    
404
    /**
405
     *  Returns true if experiment belongs to any feature, false otherwise.
406
     */
407
    func isFeatureExperiment(id: String) -> Bool {
2,556✔
408
        return !(experimentFeatureMap[id]?.isEmpty ?? true)
2,533✔
409
    }
410
        
411
    /**
412
     * Get forced variation for a given experiment key and user id.
413
     */
414
    func getForcedVariation(experimentKey: String, userId: String) -> DecisionResponse<Variation> {
3,657✔
415
        let reasons = DecisionReasons()
416
        
417
        guard let experiment = getExperiment(key: experimentKey) else {
418
            return DecisionResponse(result: nil, reasons: reasons)
419
        }
420
        
421
        if let id = getWhitelistedVariationId(userId: userId, experimentId: experiment.id) {
422
            if let variation = experiment.getVariation(id: id) {
423
                let info = LogMessage.userHasForcedVariation(userId, experiment.key, variation.key)
424
                logger.d(info)
425
                reasons.addInfo(info)
426

427
                return DecisionResponse(result: variation, reasons: reasons)
428
            }
429
            
430
            let info = LogMessage.userHasForcedVariationButInvalid(userId, experiment.key)
431
            logger.d(info)
432
            reasons.addInfo(info)
433

434
            return DecisionResponse(result: nil, reasons: reasons)
435
        }
436
        
437
        logger.d(.userHasNoForcedVariationForExperiment(userId, experiment.key))
438
        return DecisionResponse(result: nil, reasons: reasons)
439
    }
440
    
441
    /**
442
     * Set forced variation for a given experiment key and user id according to a given variation key.
443
     */
444
    func setForcedVariation(experimentKey: String, userId: String, variationKey: String?) -> Bool {
15✔
445
        guard let experiment = getExperiment(key: experimentKey) else {
446
            return false
447
        }
448
        
449
        guard var variationKey = variationKey else {
450
            logger.d(.variationRemovedForUser(userId, experimentKey))
451
            self.removeFromWhitelist(userId: userId, experimentId: experiment.id)
452
            return true
453
        }
454
        
455
        // TODO: common function to trim all keys
456
        variationKey = variationKey.trimmingCharacters(in: NSCharacterSet.whitespaces)
457
        
458
        guard !variationKey.isEmpty else {
459
            logger.e(.variationKeyInvalid(experimentKey, variationKey))
460
            return false
461
        }
462
        
463
        guard let variation = experiment.getVariation(key: variationKey) else {
464
            logger.e(.variationKeyInvalid(experimentKey, variationKey))
465
            return false
466
        }
467
        
468
        self.whitelistUser(userId: userId, experimentId: experiment.id, variationId: variation.id)
469
        
470
        logger.d(.userMappedToForcedVariation(userId, experiment.id, variation.id))
471
        return true
472
    }
473
    
474
    func getFlagVariationByKey(flagKey: String, variationKey: String) -> Variation? {
22✔
475
        if let variations = flagVariationsMap[flagKey] {
476
            return variations.filter { $0.key == variationKey }.first
98✔
477
        }
478
        
479
        return nil
480
    }
481
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc