Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(OptimizelyConfig): add new fields to OptimizelyConfig #418

Merged
merged 14 commits into from
Jun 23, 2021
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1230"
LastUpgradeVersion = "1240"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
15 changes: 15 additions & 0 deletions DemoSwiftApp/Samples/SamplesForAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ class SamplesForAPI {
let optConfig = try! optimizely.getOptimizelyConfig()

print("[OptimizelyConfig] revision = \(optConfig.revision)")
print("[OptimizelyConfig] sdkKey = \(optConfig.sdkKey)")
print("[OptimizelyConfig] environmentKey = \(optConfig.environmentKey)")

print("[OptimizelyConfig] attributes:")
optConfig.attributes.forEach { attribute in
print("[OptimizelyConfig] -- (id, key) = (\(attribute.id), \(attribute.key))")
}
print("[OptimizelyConfig] audiences:")
optConfig.audiences.forEach { audience in
print("[OptimizelyConfig] -- (id, name, conditions) = (\(audience.id), \(audience.name), \(audience.conditions))")
}
print("[OptimizelyConfig] events:")
optConfig.events.forEach { event in
print("[OptimizelyConfig] -- (id, key, experimentIds) = (\(event.id), \(event.key), \(event.experimentIds))")
}

//let experiments = optConfig.experimentsMap.values
let experimentKeys = optConfig.experimentsMap.keys
Expand Down
2 changes: 1 addition & 1 deletion Sources/Data Model/Attribute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import Foundation

struct Attribute: Codable, Equatable {
struct Attribute: Codable, Equatable, OptimizelyAttribute {
var id: String
var key: String
}
44 changes: 26 additions & 18 deletions Sources/Data Model/Audience/Audience.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@

import Foundation

struct Audience: Codable, Equatable {
struct Audience: Codable, Equatable, OptimizelyAudience {
var id: String
var name: String
var conditions: ConditionHolder

var conditionHolder: ConditionHolder
var conditions: String // string representation for OptimizelyConfig

enum CodingKeys: String, CodingKey {
case id
case name
Expand All @@ -32,38 +33,45 @@ struct Audience: Codable, Equatable {

self.id = try container.decode(String.self, forKey: .id)
self.name = try container.decode(String.self, forKey: .name)

let hint = "id: \(self.id), name: \(self.name)"
let decodeError = DecodingError.dataCorrupted(
DecodingError.Context(codingPath: container.codingPath,
debugDescription: "Failed to decode Audience Condition (\(hint))"))

if let value = try? container.decode(String.self, forKey: .conditions) {

// legacy stringified conditions
// - "[\"or\",{\"value\":30,\"type\":\"custom_attribute\",\"match\":\"exact\",\"name\":\"geo\"}]"
// decode it to recover to formatted CondtionHolder type

let data = value.data(using: .utf8)
self.conditions = try JSONDecoder().decode(ConditionHolder.self, from: data!)

} else if let value = try? container.decode(ConditionHolder.self, forKey: .conditions) {

// typedAudience formats
// [TODO] Tom: check if this is correct
// NOTE: UserAttribute (not in array) at the top-level is allowed

guard let data = value.data(using: .utf8) else { throw decodeError }

self.conditionHolder = try JSONDecoder().decode(ConditionHolder.self, from: data)
self.conditions = value

} else if let value = try? container.decode(ConditionHolder.self, forKey: .conditions) {
self.conditionHolder = value

// sort by keys to compare strings in tests
let sortEncoder = JSONEncoder()
if #available(iOS 11.0, tvOS 11.0, watchOS 4.0, *) {
sortEncoder.outputFormatting = .sortedKeys
}
let data = try sortEncoder.encode(value)
self.conditions = String(bytes: data, encoding: .utf8) ?? ""
} else {
let hint = "id: \(self.id), name: \(self.name)"
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath, debugDescription: "Failed to decode Audience Condition (\(hint))"))
throw decodeError
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(conditions, forKey: .conditions)
try container.encode(conditionHolder, forKey: .conditions)
}

func evaluate(project: ProjectProtocol?, attributes: OptimizelyAttributes?) throws -> Bool {
return try conditions.evaluate(project: project, attributes: attributes)
return try conditionHolder.evaluate(project: project, attributes: attributes)
}

}
70 changes: 70 additions & 0 deletions Sources/Data Model/Audience/ConditionHolder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@ enum ConditionHolder: Codable, Equatable {
return try conditions.evaluate(project: project, attributes: attributes)
}
}

}

// MARK: - serialization

extension ConditionHolder {

/// Returns a serialized string of audienceConditions
/// - each audienceId is converted into "AUDIENCE(audienceId)", which can be translated to correponding names later
///
/// Examples:
/// - "123" => "AUDIENCE(123)"
/// - ["and", "123", "456"] => "AUDIENCE(123) AND AUDIENCE(456)"
/// - ["or", "123", ["and", "456", "789"]] => "AUDIENCE(123) OR ((AUDIENCE(456) AND AUDIENCE(789))"
var serialized: String {
switch self {
case .logicalOp:
return ""
case .leaf(.audienceId(let audienceId)):
return "AUDIENCE(\(audienceId))"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have comments here to explain .leaf and .array cases? why these are required?

case .array(let conditions):
return "\(conditions.serialized)"
default:
return ""
}
}

var isArray: Bool {
if case .array = self {
return true
} else {
return false
}
Comment on lines +104 to +106
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not important but i think this would look cleaner.

Suggested change
} else {
return false
}
}
return false

}
}

// MARK: - [ConditionHolder]
Expand Down Expand Up @@ -118,4 +152,40 @@ extension Array where Element == ConditionHolder {
}
}

/// Represents an array of ConditionHolder as a serialized string
///
/// Examples:
/// - ["not", A] => "NOT A"
/// - ["and", A, B] => "A AND B"
/// - ["or", A, ["and", B, C]] => "A OR (B AND C)"
/// - [A] => "A"
var serialized: String {
var result = ""

guard let firstItem = self.first else {
return "\(result)"
}

// The first item of the array is supposed to be a logical op (and, or, not)
// extract it first and join the rest of the array items with the logical op
switch firstItem {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have comments here?

case .logicalOp(.not):
result = (self.count < 2) ? "" : "NOT \(self[1].serialized)"
case .logicalOp(let op):
result = self.enumerated()
.filter { $0.offset > 0 }
.map {
let desc = $0.element.serialized
return ($0.element.isArray) ? "(\(desc))" : desc
}
.joined(separator: " " + "\(op)".uppercased() + " ")
case .leaf(.audienceId):
result = "\([[ConditionHolder.logicalOp(.or)], self].flatMap({$0}).serialized)"
default:
result = ""
}

return "\(result)"
}

}
2 changes: 1 addition & 1 deletion Sources/Data Model/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import Foundation

struct Event: Codable, Equatable {
struct Event: Codable, Equatable, OptimizelyEvent {
var id: String
var key: String
var experimentIds: [String]
Expand Down
91 changes: 81 additions & 10 deletions Sources/Data Model/Experiment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import Foundation

struct Experiment: Codable, Equatable {
struct Experiment: Codable, OptimizelyExperiment {
enum Status: String, Codable {
case running = "Running"
case launched = "Launched"
Expand All @@ -35,17 +35,29 @@ struct Experiment: Codable, Equatable {
var audienceConditions: ConditionHolder?
// datafile spec defines this as [String: Any]. Supposed to be [ExperimentKey: VariationKey]
var forcedVariations: [String: String]
}

enum CodingKeys: String, CodingKey {
case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, forcedVariations
}

// MARK: - OptimizelyConfig
// MARK: - OptimizelyConfig

extension Experiment: OptimizelyExperiment {
var variationsMap: [String: OptimizelyVariation] {
var map = [String: Variation]()
variations.forEach {
map[$0.key] = $0
}
return map
var variationsMap: [String: OptimizelyVariation] = [:]
// replace with serialized string representation with audience names when ProjectConfig is ready
var audiences: String = ""
}

extension Experiment: Equatable {
static func == (lhs: Experiment, rhs: Experiment) -> Bool {
return lhs.id == rhs.id &&
lhs.key == rhs.key &&
lhs.status == rhs.status &&
lhs.layerId == rhs.layerId &&
lhs.variations == rhs.variations &&
lhs.trafficAllocation == rhs.trafficAllocation &&
lhs.audienceIds == rhs.audienceIds &&
lhs.audienceConditions == rhs.audienceConditions &&
lhs.forcedVariations == rhs.forcedVariations
}
}

Expand All @@ -63,4 +75,63 @@ extension Experiment {
var isActivated: Bool {
return status == .running
}

mutating func serializeAudiences(with audiencesMap: [String: String]) {
guard let conditions = audienceConditions else { return }

let serialized = conditions.serialized
audiences = replaceAudienceIdsWithNames(string: serialized, audiencesMap: audiencesMap)
}

/// Replace audience ids with audience names
///
/// example:
/// - string: "(AUDIENCE(1) OR AUDIENCE(2)) AND AUDIENCE(3)"
/// - replaced: "(\"us\" OR \"female\") AND \"adult\""
///
/// - Parameter string: before replacement
/// - Returns: string after replacement
func replaceAudienceIdsWithNames(string: String, audiencesMap: [String: String]) -> String {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have unit test for this method.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a unit test case covering this audience mapping -

func testAudiencesSerialization() {

let beginWord = "AUDIENCE("
let endWord = ")"
var keyIdx = 0
var audienceId = ""
var collect = false

var replaced = ""
for ch in string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have some comments here for explanation?

// extract audience id in parenthesis (example: AUDIENCE("35") => "35")
if collect {
if String(ch) == endWord {
// output the extracted audienceId
replaced += "\"\(audiencesMap[audienceId] ?? audienceId)\""
collect = false
audienceId = ""
} else {
audienceId += String(ch)
}
continue
}

// walk-through until finding a matching keyword "AUDIENCE("
if ch == Array(beginWord)[keyIdx] {
keyIdx += 1
if keyIdx == beginWord.count {
keyIdx = 0
collect = true
}
continue
} else {
if keyIdx > 0 {
replaced += Array(beginWord)[..<keyIdx]
}
keyIdx = 0
}

// pass through other characters
replaced += String(ch)
}

return replaced
}
}
32 changes: 6 additions & 26 deletions Sources/Data Model/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import Foundation

struct FeatureFlag: Codable, Equatable {
struct FeatureFlag: Codable, Equatable, OptimizelyFeature {
static func == (lhs: FeatureFlag, rhs: FeatureFlag) -> Bool {
return lhs.id == rhs.id
}
Expand All @@ -35,32 +35,12 @@ struct FeatureFlag: Codable, Equatable {
case variables
}

// for OptimizelyConfig only
// MARK: - OptimizelyConfig

var experiments: [Experiment] = []
}

// MARK: - OptimizelyConfig

extension FeatureFlag: OptimizelyFeature {
var experimentsMap: [String: OptimizelyExperiment] {
var map = [String: Experiment]()
experiments.forEach {
map[$0.key] = $0
}
return map
}

var variablesMap: [String: OptimizelyVariable] {
var map = [String: Variable]()
variables.forEach { featureVariable in
map[featureVariable.key] = Variable(id: featureVariable.id,
value: featureVariable.defaultValue ?? "",
key: featureVariable.key,
type: featureVariable.type)
}
return map
}
var experimentsMap: [String: OptimizelyExperiment] = [:]
var variablesMap: [String: OptimizelyVariable] = [:]
var experimentRules: [OptimizelyExperiment] = []
var deliveryRules: [OptimizelyExperiment] = []
}

// MARK: - Utils
Expand Down
2 changes: 1 addition & 1 deletion Sources/Data Model/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ extension Project: ProjectProtocol {
throw OptimizelyError.conditionNoMatchingAudience(audienceId)
}
logger.d { () -> String in
return LogMessage.audienceEvaluationStarted(audienceId, Utils.getConditionString(conditions: audience.conditions)).description
return LogMessage.audienceEvaluationStarted(audienceId, Utils.getConditionString(conditions: audience.conditionHolder)).description
}

let result = try audience.evaluate(project: self, attributes: attributes)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Data Model/ProjectConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class ProjectConfig {
}()

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

// MARK: - Init
Expand Down
Loading