Skip to content

Commit c9b996a

Browse files
authored
feat(OptimizelyConfig): add new fields to OptimizelyConfig (#418)
1 parent 4b5565c commit c9b996a

23 files changed

+1048
-234
lines changed

DemoObjCApp/DemoObjcApp.xcodeproj/xcshareddata/xcschemes/DemoObjctvOS.xcscheme

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<Scheme
3-
LastUpgradeVersion = "1230"
3+
LastUpgradeVersion = "1240"
44
version = "1.3">
55
<BuildAction
66
parallelizeBuildables = "YES"

DemoSwiftApp/Samples/SamplesForAPI.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,21 @@ class SamplesForAPI {
142142
let optConfig = try! optimizely.getOptimizelyConfig()
143143

144144
print("[OptimizelyConfig] revision = \(optConfig.revision)")
145+
print("[OptimizelyConfig] sdkKey = \(optConfig.sdkKey)")
146+
print("[OptimizelyConfig] environmentKey = \(optConfig.environmentKey)")
147+
148+
print("[OptimizelyConfig] attributes:")
149+
optConfig.attributes.forEach { attribute in
150+
print("[OptimizelyConfig] -- (id, key) = (\(attribute.id), \(attribute.key))")
151+
}
152+
print("[OptimizelyConfig] audiences:")
153+
optConfig.audiences.forEach { audience in
154+
print("[OptimizelyConfig] -- (id, name, conditions) = (\(audience.id), \(audience.name), \(audience.conditions))")
155+
}
156+
print("[OptimizelyConfig] events:")
157+
optConfig.events.forEach { event in
158+
print("[OptimizelyConfig] -- (id, key, experimentIds) = (\(event.id), \(event.key), \(event.experimentIds))")
159+
}
145160

146161
//let experiments = optConfig.experimentsMap.values
147162
let experimentKeys = optConfig.experimentsMap.keys

Sources/Data Model/Attribute.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import Foundation
1818

19-
struct Attribute: Codable, Equatable {
19+
struct Attribute: Codable, Equatable, OptimizelyAttribute {
2020
var id: String
2121
var key: String
2222
}

Sources/Data Model/Audience/Audience.swift

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616

1717
import Foundation
1818

19-
struct Audience: Codable, Equatable {
19+
struct Audience: Codable, Equatable, OptimizelyAudience {
2020
var id: String
2121
var name: String
22-
var conditions: ConditionHolder
23-
22+
var conditionHolder: ConditionHolder
23+
var conditions: String // string representation for OptimizelyConfig
24+
2425
enum CodingKeys: String, CodingKey {
2526
case id
2627
case name
@@ -32,38 +33,45 @@ struct Audience: Codable, Equatable {
3233

3334
self.id = try container.decode(String.self, forKey: .id)
3435
self.name = try container.decode(String.self, forKey: .name)
36+
37+
let hint = "id: \(self.id), name: \(self.name)"
38+
let decodeError = DecodingError.dataCorrupted(
39+
DecodingError.Context(codingPath: container.codingPath,
40+
debugDescription: "Failed to decode Audience Condition (\(hint))"))
3541

3642
if let value = try? container.decode(String.self, forKey: .conditions) {
37-
3843
// legacy stringified conditions
3944
// - "[\"or\",{\"value\":30,\"type\":\"custom_attribute\",\"match\":\"exact\",\"name\":\"geo\"}]"
4045
// decode it to recover to formatted CondtionHolder type
4146

42-
let data = value.data(using: .utf8)
43-
self.conditions = try JSONDecoder().decode(ConditionHolder.self, from: data!)
44-
45-
} else if let value = try? container.decode(ConditionHolder.self, forKey: .conditions) {
46-
47-
// typedAudience formats
48-
// [TODO] Tom: check if this is correct
49-
// NOTE: UserAttribute (not in array) at the top-level is allowed
50-
47+
guard let data = value.data(using: .utf8) else { throw decodeError }
48+
49+
self.conditionHolder = try JSONDecoder().decode(ConditionHolder.self, from: data)
5150
self.conditions = value
52-
51+
} else if let value = try? container.decode(ConditionHolder.self, forKey: .conditions) {
52+
self.conditionHolder = value
53+
54+
// sort by keys to compare strings in tests
55+
let sortEncoder = JSONEncoder()
56+
if #available(iOS 11.0, tvOS 11.0, watchOS 4.0, *) {
57+
sortEncoder.outputFormatting = .sortedKeys
58+
}
59+
let data = try sortEncoder.encode(value)
60+
self.conditions = String(bytes: data, encoding: .utf8) ?? ""
5361
} else {
54-
let hint = "id: \(self.id), name: \(self.name)"
55-
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath, debugDescription: "Failed to decode Audience Condition (\(hint))"))
62+
throw decodeError
5663
}
5764
}
5865

5966
func encode(to encoder: Encoder) throws {
6067
var container = encoder.container(keyedBy: CodingKeys.self)
6168
try container.encode(id, forKey: .id)
6269
try container.encode(name, forKey: .name)
63-
try container.encode(conditions, forKey: .conditions)
70+
try container.encode(conditionHolder, forKey: .conditions)
6471
}
6572

6673
func evaluate(project: ProjectProtocol?, attributes: OptimizelyAttributes?) throws -> Bool {
67-
return try conditions.evaluate(project: project, attributes: attributes)
74+
return try conditionHolder.evaluate(project: project, attributes: attributes)
6875
}
76+
6977
}

Sources/Data Model/Audience/ConditionHolder.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,40 @@ enum ConditionHolder: Codable, Equatable {
7171
return try conditions.evaluate(project: project, attributes: attributes)
7272
}
7373
}
74+
75+
}
76+
77+
// MARK: - serialization
78+
79+
extension ConditionHolder {
80+
81+
/// Returns a serialized string of audienceConditions
82+
/// - each audienceId is converted into "AUDIENCE(audienceId)", which can be translated to correponding names later
83+
///
84+
/// Examples:
85+
/// - "123" => "AUDIENCE(123)"
86+
/// - ["and", "123", "456"] => "AUDIENCE(123) AND AUDIENCE(456)"
87+
/// - ["or", "123", ["and", "456", "789"]] => "AUDIENCE(123) OR ((AUDIENCE(456) AND AUDIENCE(789))"
88+
var serialized: String {
89+
switch self {
90+
case .logicalOp:
91+
return ""
92+
case .leaf(.audienceId(let audienceId)):
93+
return "AUDIENCE(\(audienceId))"
94+
case .array(let conditions):
95+
return "\(conditions.serialized)"
96+
default:
97+
return ""
98+
}
99+
}
100+
101+
var isArray: Bool {
102+
if case .array = self {
103+
return true
104+
} else {
105+
return false
106+
}
107+
}
74108
}
75109

76110
// MARK: - [ConditionHolder]
@@ -118,4 +152,40 @@ extension Array where Element == ConditionHolder {
118152
}
119153
}
120154

155+
/// Represents an array of ConditionHolder as a serialized string
156+
///
157+
/// Examples:
158+
/// - ["not", A] => "NOT A"
159+
/// - ["and", A, B] => "A AND B"
160+
/// - ["or", A, ["and", B, C]] => "A OR (B AND C)"
161+
/// - [A] => "A"
162+
var serialized: String {
163+
var result = ""
164+
165+
guard let firstItem = self.first else {
166+
return "\(result)"
167+
}
168+
169+
// The first item of the array is supposed to be a logical op (and, or, not)
170+
// extract it first and join the rest of the array items with the logical op
171+
switch firstItem {
172+
case .logicalOp(.not):
173+
result = (self.count < 2) ? "" : "NOT \(self[1].serialized)"
174+
case .logicalOp(let op):
175+
result = self.enumerated()
176+
.filter { $0.offset > 0 }
177+
.map {
178+
let desc = $0.element.serialized
179+
return ($0.element.isArray) ? "(\(desc))" : desc
180+
}
181+
.joined(separator: " " + "\(op)".uppercased() + " ")
182+
case .leaf(.audienceId):
183+
result = "\([[ConditionHolder.logicalOp(.or)], self].flatMap({$0}).serialized)"
184+
default:
185+
result = ""
186+
}
187+
188+
return "\(result)"
189+
}
190+
121191
}

Sources/Data Model/Event.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import Foundation
1818

19-
struct Event: Codable, Equatable {
19+
struct Event: Codable, Equatable, OptimizelyEvent {
2020
var id: String
2121
var key: String
2222
var experimentIds: [String]

Sources/Data Model/Experiment.swift

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import Foundation
1818

19-
struct Experiment: Codable, Equatable {
19+
struct Experiment: Codable, OptimizelyExperiment {
2020
enum Status: String, Codable {
2121
case running = "Running"
2222
case launched = "Launched"
@@ -35,17 +35,29 @@ struct Experiment: Codable, Equatable {
3535
var audienceConditions: ConditionHolder?
3636
// datafile spec defines this as [String: Any]. Supposed to be [ExperimentKey: VariationKey]
3737
var forcedVariations: [String: String]
38-
}
38+
39+
enum CodingKeys: String, CodingKey {
40+
case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, forcedVariations
41+
}
3942

40-
// MARK: - OptimizelyConfig
43+
// MARK: - OptimizelyConfig
4144

42-
extension Experiment: OptimizelyExperiment {
43-
var variationsMap: [String: OptimizelyVariation] {
44-
var map = [String: Variation]()
45-
variations.forEach {
46-
map[$0.key] = $0
47-
}
48-
return map
45+
var variationsMap: [String: OptimizelyVariation] = [:]
46+
// replace with serialized string representation with audience names when ProjectConfig is ready
47+
var audiences: String = ""
48+
}
49+
50+
extension Experiment: Equatable {
51+
static func == (lhs: Experiment, rhs: Experiment) -> Bool {
52+
return lhs.id == rhs.id &&
53+
lhs.key == rhs.key &&
54+
lhs.status == rhs.status &&
55+
lhs.layerId == rhs.layerId &&
56+
lhs.variations == rhs.variations &&
57+
lhs.trafficAllocation == rhs.trafficAllocation &&
58+
lhs.audienceIds == rhs.audienceIds &&
59+
lhs.audienceConditions == rhs.audienceConditions &&
60+
lhs.forcedVariations == rhs.forcedVariations
4961
}
5062
}
5163

@@ -63,4 +75,63 @@ extension Experiment {
6375
var isActivated: Bool {
6476
return status == .running
6577
}
78+
79+
mutating func serializeAudiences(with audiencesMap: [String: String]) {
80+
guard let conditions = audienceConditions else { return }
81+
82+
let serialized = conditions.serialized
83+
audiences = replaceAudienceIdsWithNames(string: serialized, audiencesMap: audiencesMap)
84+
}
85+
86+
/// Replace audience ids with audience names
87+
///
88+
/// example:
89+
/// - string: "(AUDIENCE(1) OR AUDIENCE(2)) AND AUDIENCE(3)"
90+
/// - replaced: "(\"us\" OR \"female\") AND \"adult\""
91+
///
92+
/// - Parameter string: before replacement
93+
/// - Returns: string after replacement
94+
func replaceAudienceIdsWithNames(string: String, audiencesMap: [String: String]) -> String {
95+
let beginWord = "AUDIENCE("
96+
let endWord = ")"
97+
var keyIdx = 0
98+
var audienceId = ""
99+
var collect = false
100+
101+
var replaced = ""
102+
for ch in string {
103+
// extract audience id in parenthesis (example: AUDIENCE("35") => "35")
104+
if collect {
105+
if String(ch) == endWord {
106+
// output the extracted audienceId
107+
replaced += "\"\(audiencesMap[audienceId] ?? audienceId)\""
108+
collect = false
109+
audienceId = ""
110+
} else {
111+
audienceId += String(ch)
112+
}
113+
continue
114+
}
115+
116+
// walk-through until finding a matching keyword "AUDIENCE("
117+
if ch == Array(beginWord)[keyIdx] {
118+
keyIdx += 1
119+
if keyIdx == beginWord.count {
120+
keyIdx = 0
121+
collect = true
122+
}
123+
continue
124+
} else {
125+
if keyIdx > 0 {
126+
replaced += Array(beginWord)[..<keyIdx]
127+
}
128+
keyIdx = 0
129+
}
130+
131+
// pass through other characters
132+
replaced += String(ch)
133+
}
134+
135+
return replaced
136+
}
66137
}

Sources/Data Model/FeatureFlag.swift

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import Foundation
1818

19-
struct FeatureFlag: Codable, Equatable {
19+
struct FeatureFlag: Codable, Equatable, OptimizelyFeature {
2020
static func == (lhs: FeatureFlag, rhs: FeatureFlag) -> Bool {
2121
return lhs.id == rhs.id
2222
}
@@ -35,32 +35,12 @@ struct FeatureFlag: Codable, Equatable {
3535
case variables
3636
}
3737

38-
// for OptimizelyConfig only
38+
// MARK: - OptimizelyConfig
3939

40-
var experiments: [Experiment] = []
41-
}
42-
43-
// MARK: - OptimizelyConfig
44-
45-
extension FeatureFlag: OptimizelyFeature {
46-
var experimentsMap: [String: OptimizelyExperiment] {
47-
var map = [String: Experiment]()
48-
experiments.forEach {
49-
map[$0.key] = $0
50-
}
51-
return map
52-
}
53-
54-
var variablesMap: [String: OptimizelyVariable] {
55-
var map = [String: Variable]()
56-
variables.forEach { featureVariable in
57-
map[featureVariable.key] = Variable(id: featureVariable.id,
58-
value: featureVariable.defaultValue ?? "",
59-
key: featureVariable.key,
60-
type: featureVariable.type)
61-
}
62-
return map
63-
}
40+
var experimentsMap: [String: OptimizelyExperiment] = [:]
41+
var variablesMap: [String: OptimizelyVariable] = [:]
42+
var experimentRules: [OptimizelyExperiment] = []
43+
var deliveryRules: [OptimizelyExperiment] = []
6444
}
6545

6646
// MARK: - Utils

Sources/Data Model/Project.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ extension Project: ProjectProtocol {
7575
throw OptimizelyError.conditionNoMatchingAudience(audienceId)
7676
}
7777
logger.d { () -> String in
78-
return LogMessage.audienceEvaluationStarted(audienceId, Utils.getConditionString(conditions: audience.conditions)).description
78+
return LogMessage.audienceEvaluationStarted(audienceId, Utils.getConditionString(conditions: audience.conditionHolder)).description
7979
}
8080

8181
let result = try audience.evaluate(project: self, attributes: attributes)

Sources/Data Model/ProjectConfig.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class ProjectConfig {
8585
}()
8686

8787
lazy var allExperiments: [Experiment] = {
88-
return project.experiments + project.groups.map { $0.experiments }.flatMap({$0})
88+
return project.experiments + project.groups.map { $0.experiments }.flatMap { $0 }
8989
}()
9090

9191
// MARK: - Init

0 commit comments

Comments
 (0)