Skip to content

Commit 5c3344b

Browse files
feat: pass Dictionary to flag evaluation (#193)
* feat: Add support for Struct evaluations Signed-off-by: Fabrizio Demaria <[email protected]> * test: OF testing with object evals * ci: Lint * ci: Update API * test: Improve reliability apply check in tests * fix: Smaller fixes * docs: Better comment * feat: OpenFeature Provider support for Value def * feat: Deep check of Dictionary schema * fix: Correct func naming * refactor: Refactor complex testing class * fix: Test run via terminal * test: All types in struct eval * feat: Support partial nulls * test: All types in provider * feat: Restore reason strings * refactor: cleanup and consolidate struct tests * refactor!: Remove unused code * test: Corner case tested and documented * docs: Update README with new functionality * test: Add IT test for complex evals --------- Signed-off-by: Fabrizio Demaria <[email protected]>
1 parent 9ef17b2 commit 5c3344b

12 files changed

+1791
-143
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ let messageValue = messageFlag.value
148148
// message and messageValue are the same
149149
```
150150

151+
It's also possible to pass a Dictionary as evaluation type, for flags with a complex schema. The dictionary can
152+
only contain keys that map to properties in the complex flag, both in terms of property name and property type, otherwise
153+
the default value is returned with a `typeMismatch` error.
154+
151155
### Tracking events
152156
The Confidence instance offers APIs to track events, which are uploaded to the Confidence backend:
153157
```swift

Sources/Confidence/ConfidenceError.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,32 @@ public enum ConfidenceError: Error, Equatable {
99
case internalError(message: String)
1010
case parseError(message: String)
1111
case invalidContextInMessage
12+
case typeMismatch(message: String = "Mismatch between default value and flag value type")
13+
14+
var errorCode: ErrorCode {
15+
switch self {
16+
case .grpcError(let message),
17+
.cacheError(let message),
18+
.corruptedCache(let message),
19+
.badRequest(let message?),
20+
.internalError(let message):
21+
return .generalError(message: message)
22+
23+
case .flagNotFoundError:
24+
return .flagNotFound
25+
26+
case .parseError(let message):
27+
return .parseError(message: message)
28+
29+
case .invalidContextInMessage:
30+
return .invalidContext
31+
case .badRequest(message: .none):
32+
return .generalError(message: "unknown error")
33+
34+
case .typeMismatch(let message):
35+
return .typeMismatch(message: message)
36+
}
37+
}
1238
}
1339

1440
extension ConfidenceError: CustomStringConvertible {
@@ -33,6 +59,8 @@ extension ConfidenceError: CustomStringConvertible {
3359
return "Parse error occurred: \(message)"
3460
case .invalidContextInMessage:
3561
return "Field 'context' is not allowed in event's data"
62+
case .typeMismatch(message: let message):
63+
return message
3664
}
3765
}
3866
}

Sources/Confidence/ConfidenceValue.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,32 @@ public class ConfidenceValue: Equatable, Codable, CustomStringConvertible {
164164
if case let .structure(values) = value {
165165
return values.mapValues { ConfidenceValue(valueInternal: $0) }
166166
}
167-
168167
return nil
169168
}
170169

170+
func asNative() -> Any {
171+
switch value {
172+
case .boolean(let bool):
173+
return bool
174+
case .string(let string):
175+
return string
176+
case .integer(let int):
177+
return int
178+
case .double(let double):
179+
return double
180+
case .date(let dateComponents):
181+
return dateComponents
182+
case .timestamp(let date):
183+
return date
184+
case .list(let values):
185+
return values.map { ConfidenceValue(valueInternal: $0).asNative() }
186+
case .structure(let values):
187+
return values.mapValues { ConfidenceValue(valueInternal: $0).asNative() }
188+
case .null:
189+
return NSNull()
190+
}
191+
}
192+
171193
public func isNull() -> Bool {
172194
if case .null = value {
173195
return true

Sources/Confidence/FlagEvaluation.swift

Lines changed: 129 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ public struct Evaluation<T> {
88
public let errorMessage: String?
99
}
1010

11-
public enum ErrorCode {
11+
public enum ErrorCode: Equatable {
1212
case providerNotReady
1313
case invalidContext
1414
case flagNotFound
1515
case evaluationError
16-
case typeMismatch
16+
case parseError(message: String)
17+
case typeMismatch(message: String = "Mismatch between default value and flag value type")
18+
case generalError(message: String)
1719
}
1820

1921
struct FlagResolution: Encodable, Decodable, Equatable {
@@ -70,8 +72,8 @@ extension FlagResolution {
7072
)
7173
}
7274

73-
let parsedValue = try getValue(path: parsedKey.path, value: value)
74-
let typedValue: T? = getTyped(value: parsedValue)
75+
let parsedValue = try getValueForPath(path: parsedKey.path, value: value)
76+
let typedValue: T? = try getTyped(value: parsedValue, defaultValue: defaultValue)
7577

7678
if resolvedFlag.resolveReason == .match {
7779
var resolveReason: ResolveReason = .match
@@ -111,7 +113,7 @@ extension FlagResolution {
111113
value: defaultValue,
112114
variant: nil,
113115
reason: .error,
114-
errorCode: .typeMismatch,
116+
errorCode: .typeMismatch(),
115117
errorMessage: nil
116118
)
117119
}
@@ -130,6 +132,14 @@ extension FlagResolution {
130132
errorMessage: nil
131133
)
132134
}
135+
} catch let error as ConfidenceError {
136+
return Evaluation(
137+
value: defaultValue,
138+
variant: nil,
139+
reason: .error,
140+
errorCode: error.errorCode,
141+
errorMessage: error.description
142+
)
133143
} catch {
134144
return Evaluation(
135145
value: defaultValue,
@@ -168,68 +178,152 @@ extension FlagResolution {
168178
}
169179

170180
// swiftlint:disable:next cyclomatic_complexity
171-
private func getTyped<T>(value: ConfidenceValue) -> T? {
181+
private func getTyped<T>(value: ConfidenceValue, defaultValue: T) throws -> T? {
172182
if let value = self as? T {
173183
return value
174184
}
175185

186+
let result: Any?
176187
switch value.type() {
177188
case .boolean:
178-
return value.asBoolean() as? T
189+
result = value.asBoolean()
179190
case .string:
180-
return value.asString() as? T
191+
result = value.asString()
181192
case .integer:
182-
if let intValue = value.asInteger() as? T {
183-
return intValue
184-
}
185193
if T.self == Int32.self, let intValue = value.asInteger() {
186-
return Int32(intValue) as? T
187-
}
188-
if T.self == Int64.self, let intValue = value.asInteger() {
189-
return Int64(intValue) as? T
194+
result = Int32(intValue)
195+
} else if T.self == Int64.self, let intValue = value.asInteger() {
196+
result = Int64(intValue)
197+
} else {
198+
result = value.asInteger()
190199
}
191-
return nil
192200
case .double:
193-
return value.asDouble() as? T
201+
result = value.asDouble()
194202
case .date:
195-
return value.asDate() as? T
203+
result = value.asDate()
196204
case .timestamp:
197-
return value.asDateComponents() as? T
205+
result = value.asDateComponents()
206+
// TODO We should align List and Structure to return the same data type - asListNative?
198207
case .list:
199-
return value.asList() as? T
208+
result = value.asList()
200209
case .structure:
201-
return value.asStructure() as? T
210+
result = try handleStructureValue(value: value, defaultValue: defaultValue)
202211
case .null:
203212
return nil
204213
}
214+
215+
if let typedResult = result as? T {
216+
return typedResult
217+
} else {
218+
throw ConfidenceError.typeMismatch(message: "Value \(value) cannot be cast to \(T.self)")
219+
}
205220
}
206221

207-
private func getValue(path: [String], value: ConfidenceValue) throws -> ConfidenceValue {
208-
if path.isEmpty {
209-
guard value.asStructure() != nil else {
210-
throw ConfidenceError.parseError(
211-
message: "Flag path must contain path to the field for non-object values")
222+
private func handleStructureValue<T>(value: ConfidenceValue, defaultValue: T) throws -> [String: Any] {
223+
guard let defaultDict = defaultValue as? [String: Any] else {
224+
throw ConfidenceError
225+
.typeMismatch(
226+
message: "Expected a Dictionary as default value, but got a different type"
227+
)
228+
}
229+
guard let structure = value.asStructure() else {
230+
throw ConfidenceError
231+
.typeMismatch(
232+
message: "Unexpected error with internal ConfidenceStruct conversion"
233+
)
234+
}
235+
try validateDictionaryStructureCompatibility(
236+
structure: structure,
237+
defaultDict: defaultDict
238+
)
239+
// Filter only the entries in the original default value
240+
var filteredNative: [String: Any] = [:]
241+
for requiredKey in defaultDict.keys {
242+
if let confidenceValue = structure[requiredKey] {
243+
// If the resolved value is null, use the default value instead
244+
if confidenceValue.isNull() {
245+
filteredNative[requiredKey] = defaultDict[requiredKey]
246+
} else {
247+
filteredNative[requiredKey] = confidenceValue.asNative()
248+
}
249+
}
250+
}
251+
return filteredNative
252+
}
253+
254+
private func validateDictionaryStructureCompatibility(
255+
structure: ConfidenceStruct,
256+
defaultDict: [String: Any]
257+
) throws {
258+
for defaultValueKey in defaultDict.keys {
259+
guard let confidenceValue = structure[defaultValueKey] else {
260+
throw ConfidenceError.typeMismatch(
261+
message: "Default value key '\(defaultValueKey)' not found in flag"
262+
)
263+
}
264+
265+
// If the resolved value is null, it's compatible with any type (we'll use default value)
266+
if confidenceValue.isNull() {
267+
continue
268+
}
269+
270+
let defaultValueValue: Any? = defaultDict[defaultValueKey]
271+
if !isValueCompatibleWithDefaultValue(
272+
confidenceType: confidenceValue.type(),
273+
defaultValue: defaultValueValue
274+
) {
275+
let message = "Default value key '\(defaultValueKey)' has incompatible type. " +
276+
"Expected from flag is '\(getIntrinsicType(of: defaultValueValue))', " +
277+
"got '\(confidenceValue.type())'"
278+
throw ConfidenceError.typeMismatch(message: message)
212279
}
213280
}
281+
}
214282

215-
var pathValue = value
216-
if !path.isEmpty {
217-
pathValue = try getValueForPath(path: path, value: value)
283+
private func getIntrinsicType(of value: Any?) -> String {
284+
if let unwrapped = value {
285+
return "\(type(of: unwrapped))"
286+
} else {
287+
return "nil"
218288
}
289+
}
219290

220-
return pathValue
291+
private func isValueCompatibleWithDefaultValue(
292+
confidenceType: ConfidenceValueType,
293+
defaultValue: Any?
294+
) -> Bool {
295+
switch defaultValue {
296+
case is String:
297+
return confidenceType == .string
298+
case is Int, is Int32, is Int64:
299+
return confidenceType == .integer
300+
case is Double, is Float:
301+
return confidenceType == .double
302+
case is Bool:
303+
return confidenceType == .boolean
304+
case is Date:
305+
return confidenceType == .timestamp
306+
case is DateComponents:
307+
return confidenceType == .date
308+
case is Array<Any>:
309+
return confidenceType == .list
310+
case is [String: Any]:
311+
return confidenceType == .structure
312+
case .none: // TODO This requires extra care
313+
return confidenceType == .null
314+
default:
315+
return false
316+
}
221317
}
222318

223319
private func getValueForPath(path: [String], value: ConfidenceValue) throws -> ConfidenceValue {
224320
var curValue = value
225-
for field in path {
226-
guard let values = curValue.asStructure(), let newValue = values[field] else {
227-
throw ConfidenceError.internalError(message: "Unable to find key '\(field)'")
321+
for step in path {
322+
guard let values = curValue.asStructure(), let newValue = values[step] else {
323+
throw ConfidenceError.internalError(message: "Unable to find key '\(step)'")
228324
}
229-
230325
curValue = newValue
231326
}
232-
233327
return curValue
234328
}
235329
}

Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,11 @@ public class ConfidenceFeatureProvider: FeatureProvider {
9999
public func getObjectEvaluation(key: String, defaultValue: OpenFeature.Value, context: EvaluationContext?)
100100
throws -> OpenFeature.ProviderEvaluation<OpenFeature.Value>
101101
{
102-
try confidence.getEvaluation(key: key, defaultValue: defaultValue).toProviderEvaluation()
102+
guard let nativeDefault = defaultValue.asNativeDictionary() else {
103+
throw OpenFeatureError.generalError(message: "Unexpected error handling the default value")
104+
}
105+
let evaluation = confidence.getEvaluation(key: key, defaultValue: nativeDefault)
106+
return try evaluation.toProviderEvaluationWithValueConversion()
103107
}
104108

105109
public func observe() -> AnyPublisher<OpenFeature.ProviderEvent?, Never> {
@@ -117,7 +121,8 @@ public class ConfidenceFeatureProvider: FeatureProvider {
117121
}
118122

119123
extension Evaluation {
120-
func toProviderEvaluation() throws -> ProviderEvaluation<T> {
124+
/// Throws an OpenFeature error if this evaluation contains an error code
125+
private func throwIfError() throws {
121126
if let errorCode = self.errorCode {
122127
switch errorCode {
123128
case .providerNotReady:
@@ -130,8 +135,16 @@ extension Evaluation {
130135
throw OpenFeatureError.generalError(message: self.errorMessage ?? "unknown error")
131136
case .typeMismatch:
132137
throw OpenFeatureError.typeMismatchError
138+
case .parseError(message: let message):
139+
throw OpenFeatureError.parseError(message: message)
140+
case .generalError(message: let message):
141+
throw OpenFeatureError.generalError(message: message)
133142
}
134143
}
144+
}
145+
146+
func toProviderEvaluation() throws -> ProviderEvaluation<T> {
147+
try throwIfError()
135148
return ProviderEvaluation(
136149
value: self.value,
137150
variant: self.variant,
@@ -141,3 +154,17 @@ extension Evaluation {
141154
)
142155
}
143156
}
157+
158+
extension Evaluation where T == [String: Any] {
159+
func toProviderEvaluationWithValueConversion() throws -> ProviderEvaluation<OpenFeature.Value> {
160+
try throwIfError()
161+
let openFeatureValue = try OpenFeature.Value.fromNativeDictionary(self.value)
162+
return ProviderEvaluation(
163+
value: openFeatureValue,
164+
variant: self.variant,
165+
reason: self.reason.rawValue,
166+
errorCode: nil,
167+
errorMessage: nil
168+
)
169+
}
170+
}

0 commit comments

Comments
 (0)