diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..12146c6 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "7f45dbb0d6994027f7b0bbf0fbc4efa8e24757c603648dcbb2daedf841dd5c80", + "pins" : [ + { + "identity" : "swift-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/open-feature/swift-sdk.git", + "state" : { + "revision" : "d71686219a65ab78f77923ceb0e2ea549e1e50ef", + "version" : "0.5.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 503d61c..9453d9f 100644 --- a/Package.swift +++ b/Package.swift @@ -11,16 +11,31 @@ let package = Package( name: "Featurevisor", targets: ["Featurevisor"] ), + .library( + name: "FeaturevisorOpenFeature", + targets: ["FeaturevisorOpenFeature"] + ), .executable( name: "featurevisor", targets: ["FeaturevisorCLI"] ), ], + dependencies: [ + .package(url: "https://github.com/open-feature/swift-sdk.git", from: "0.5.0") + ], targets: [ .target( name: "Featurevisor", path: "Sources/Featurevisor" ), + .target( + name: "FeaturevisorOpenFeature", + dependencies: [ + "Featurevisor", + .product(name: "OpenFeature", package: "swift-sdk") + ], + path: "Sources/FeaturevisorOpenFeature" + ), .executableTarget( name: "FeaturevisorCLI", dependencies: ["Featurevisor"], @@ -31,6 +46,14 @@ let package = Package( dependencies: ["Featurevisor"], path: "Tests/FeaturevisorTests" ), + .testTarget( + name: "FeaturevisorOpenFeatureTests", + dependencies: [ + "FeaturevisorOpenFeature", + .product(name: "OpenFeature", package: "swift-sdk") + ], + path: "Tests/FeaturevisorOpenFeatureTests" + ), .testTarget( name: "FeaturevisorCLITests", dependencies: ["FeaturevisorCLI", "Featurevisor"], diff --git a/README.md b/README.md index f179918..714c59f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -586,6 +587,51 @@ swift run featurevisor assess-distribution \ --n=1000 ``` +## OpenFeature + +The package exposes `FeaturevisorOpenFeature` as a separate library product. Add both products to targets that use OpenFeature: + +```swift +.product(name: "Featurevisor", package: "featurevisor-swift2"), +.product(name: "FeaturevisorOpenFeature", package: "featurevisor-swift2") +``` + +```swift +import Featurevisor +import FeaturevisorOpenFeature +import OpenFeature + +let provider = FeaturevisorOpenFeatureProvider( + options: FeaturevisorOptions(datafile: datafile) +) + +await OpenFeatureAPI.shared.setProviderAndWait( + provider: provider, + initialContext: ImmutableContext(targetingKey: "user-123") +) + +let client = OpenFeatureAPI.shared.getClient() +let enabled = client.getBooleanValue( + key: "checkout", + defaultValue: false +) +``` + +Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Lists, structures, and JSON variables use the object resolver. + +OpenFeature's targeting key maps to `userId` by default. `targetingKeyField`, `keySeparator`, and `variationKey` can customize the mapping. Call `provider.close()` when the application owns the provider lifecycle and is finished with it. + +You can also reuse an existing Featurevisor instance: + +```swift +let featurevisor = createFeaturevisor(FeaturevisorOptions(datafile: datafile)) +let provider = FeaturevisorOpenFeatureProvider(featurevisor: featurevisor) +``` + +The caller owns an instance passed this way. Closing the provider does not close it. Call `featurevisor.close()` when every consumer is finished with it. When the provider creates the instance from `options`, the provider owns and closes it. If both are supplied, `featurevisor` takes precedence over `options`. + +See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages. + ## Development of this package diff --git a/Sources/FeaturevisorOpenFeature/FeaturevisorOpenFeatureProvider.swift b/Sources/FeaturevisorOpenFeature/FeaturevisorOpenFeatureProvider.swift new file mode 100644 index 0000000..ce91a4a --- /dev/null +++ b/Sources/FeaturevisorOpenFeature/FeaturevisorOpenFeatureProvider.swift @@ -0,0 +1,209 @@ +import Combine +import Foundation +import Featurevisor +import OpenFeature + +public struct FeaturevisorOpenFeatureMetadata: ProviderMetadata { + public let name: String? = "Featurevisor" + public init() {} +} + +public final class FeaturevisorOpenFeatureProvider: FeatureProvider, @unchecked Sendable { + public let hooks: [any Hook] = [] + public let metadata: ProviderMetadata = FeaturevisorOpenFeatureMetadata() + public let featurevisor: Featurevisor + + private let targetingKeyField: String + private let keySeparator: String + private let variationKey: String + private let onTrack: ((String, (any EvaluationContext)?, (any TrackingEventDetails)?) -> Void)? + private let ownsFeaturevisor: Bool + private let eventHandler = EventHandler() + + public init( + options: FeaturevisorOptions = FeaturevisorOptions(), + featurevisor: Featurevisor? = nil, + targetingKeyField: String = "userId", + keySeparator: String = ":", + variationKey: String = "variation", + onTrack: ((String, (any EvaluationContext)?, (any TrackingEventDetails)?) -> Void)? = nil + ) { + self.ownsFeaturevisor = featurevisor == nil + self.featurevisor = featurevisor ?? createFeaturevisor(options) + self.targetingKeyField = targetingKeyField.isEmpty ? "userId" : targetingKeyField + self.keySeparator = keySeparator.isEmpty ? ":" : keySeparator + self.variationKey = variationKey.isEmpty ? "variation" : variationKey + self.onTrack = onTrack + } + + public func initialize(initialContext: (any EvaluationContext)?) async throws {} + public func onContextSet(oldContext: (any EvaluationContext)?, newContext: any EvaluationContext) async throws {} + public func observe() -> AnyPublisher { eventHandler.observe() } + + public func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws { + onTrack?(key, context, details) + } + + public func close() { + if ownsFeaturevisor { featurevisor.close() } + } + + public func getBooleanEvaluation(key: String, defaultValue: Bool, context: (any EvaluationContext)?) throws -> ProviderEvaluation { + typed(resolve(key: key, defaultValue: AnyValue.bool(defaultValue), context: context, expected: .boolean), fallback: defaultValue) { $0.asBool() } + } + + public func getStringEvaluation(key: String, defaultValue: String, context: (any EvaluationContext)?) throws -> ProviderEvaluation { + typed(resolve(key: key, defaultValue: AnyValue.string(defaultValue), context: context, expected: .string), fallback: defaultValue) { $0.asString() } + } + + public func getIntegerEvaluation(key: String, defaultValue: Int64, context: (any EvaluationContext)?) throws -> ProviderEvaluation { + typed(resolve(key: key, defaultValue: AnyValue.int(Int(defaultValue)), context: context, expected: .integer), fallback: defaultValue) { value in + guard case .int(let int) = value else { return nil } + return Int64(int) + } + } + + public func getDoubleEvaluation(key: String, defaultValue: Double, context: (any EvaluationContext)?) throws -> ProviderEvaluation { + typed(resolve(key: key, defaultValue: AnyValue.double(defaultValue), context: context, expected: .number), fallback: defaultValue) { $0.asDouble() } + } + + public func getObjectEvaluation(key: String, defaultValue: Value, context: (any EvaluationContext)?) throws -> ProviderEvaluation { + let fallback = anyValue(defaultValue) + return typed(resolve(key: key, defaultValue: fallback, context: context, expected: .object), fallback: defaultValue) { openFeatureValue($0) } + } + + private enum ExpectedType { case boolean, string, integer, number, object } + + private func resolve(key: String, defaultValue: AnyValue, context: (any EvaluationContext)?, expected: ExpectedType) -> ProviderEvaluation { + let parts = splitKey(key) + let fvContext = featurevisorContext(context) + let evaluation: Evaluation + var value: AnyValue? + + if parts.selector == nil || parts.selector?.isEmpty == true { + guard expected == .boolean else { return typeMismatch(key, defaultValue, expected) } + evaluation = featurevisor.evaluateFlag(parts.feature, context: fvContext) + if let enabled = evaluation.enabled { value = .bool(enabled) } + } else if parts.selector == variationKey { + evaluation = featurevisor.evaluateVariation(parts.feature, context: fvContext) + if let variation = evaluation.variationValue ?? evaluation.variation?.value { value = .string(variation) } + } else { + evaluation = featurevisor.evaluateVariable(parts.feature, parts.selector!, context: fvContext) + value = evaluation.variableValue + if evaluation.variableSchema?.type == "json", case .string(let json)? = value, + let data = json.data(using: .utf8), let parsed = try? JSONDecoder().decode(AnyValue.self, from: data) { + value = parsed + } + } + + let metadata = metadata(evaluation) + if let code = errorCode(evaluation.reason) { + return ProviderEvaluation(value: defaultValue, flagMetadata: metadata, reason: Reason.error.rawValue, errorCode: code, errorMessage: errorMessage(evaluation)) + } + guard let resolved = value else { + return ProviderEvaluation(value: defaultValue, flagMetadata: metadata, variant: variant(evaluation), reason: reason(evaluation.reason)) + } + guard matches(resolved, expected) else { return typeMismatch(key, defaultValue, expected, metadata: metadata) } + return ProviderEvaluation(value: resolved, flagMetadata: metadata, variant: variant(evaluation), reason: reason(evaluation.reason)) + } + + private func splitKey(_ key: String) -> (feature: String, selector: String?) { + guard let range = key.range(of: keySeparator) else { return (key, nil) } + return (String(key[.. Context { + guard let context else { return [:] } + var result = context.asMap().mapValues(anyValue) + let targetingKey = context.getTargetingKey() + if !targetingKey.isEmpty { result[targetingKeyField] = .string(targetingKey) } + return result + } + + private func anyValue(_ value: Value) -> AnyValue { + switch value { + case .boolean(let value): return .bool(value) + case .string(let value): return .string(value) + case .integer(let value): return .int(Int(value)) + case .double(let value): return .double(value) + case .date(let value): return .string(ISO8601DateFormatter().string(from: value)) + case .list(let values): return .array(values.map(anyValue)) + case .structure(let values): return .object(values.mapValues(anyValue)) + case .null: return .null + } + } + + private func openFeatureValue(_ value: AnyValue) -> Value? { + switch value { + case .bool(let value): return .boolean(value) + case .string(let value): return .string(value) + case .int(let value): return .integer(Int64(value)) + case .double(let value): return .double(value) + case .array(let values): return .list(values.compactMap(openFeatureValue)) + case .object(let values): return .structure(values.compactMapValues(openFeatureValue)) + case .null: return .null + } + } + + private func metadata(_ evaluation: Evaluation) -> FlagMetadata { + var metadata: FlagMetadata = [ + "featureKey": .string(evaluation.featureKey), + "featurevisorReason": .string(evaluation.reason.rawValue), + "schemaVersion": .string(featurevisor.getSchemaVersion()), + ] + let revision = featurevisor.getRevision() + if !revision.isEmpty { metadata["revision"] = .string(revision) } + if let value = evaluation.variableKey { metadata["variableKey"] = .string(value) } + if let value = evaluation.ruleKey { metadata["ruleKey"] = .string(value) } + if let value = evaluation.bucketKey { metadata["bucketKey"] = .string(value) } + if let value = evaluation.bucketValue { metadata["bucketValue"] = .integer(Int64(value)) } + if let value = evaluation.forceIndex { metadata["forceIndex"] = .integer(Int64(value)) } + if let value = evaluation.variableOverrideIndex { metadata["variableOverrideIndex"] = .integer(Int64(value)) } + return metadata + } + + private func reason(_ value: EvaluationReason) -> String { + switch value { + case .featureNotFound, .variableNotFound, .noVariations, .error: return Reason.error.rawValue + case .required, .forced, .sticky, .rule, .variableOverrideVariation, .variableOverrideRule: return Reason.targetingMatch.rawValue + case .allocated: return Reason.split.rawValue + case .disabled, .variationDisabled, .variableDisabled: return Reason.disabled.rawValue + default: return Reason.defaultReason.rawValue + } + } + + private func errorCode(_ value: EvaluationReason) -> ErrorCode? { + switch value { + case .featureNotFound, .variableNotFound, .noVariations: return .flagNotFound + case .error: return .general + default: return nil + } + } + + private func errorMessage(_ evaluation: Evaluation) -> String { + if let error = evaluation.error { return error } + switch evaluation.reason { + case .featureNotFound: return "Feature \"\(evaluation.featureKey)\" was not found" + case .variableNotFound: return "Variable \"\(evaluation.variableKey ?? "")\" was not found for feature \"\(evaluation.featureKey)\"" + case .noVariations: return "Feature \"\(evaluation.featureKey)\" has no variations" + default: return "Featurevisor evaluation failed" + } + } + + private func variant(_ evaluation: Evaluation) -> String? { evaluation.variationValue ?? evaluation.variation?.value } + + private func matches(_ value: AnyValue, _ expected: ExpectedType) -> Bool { + switch (value, expected) { + case (.bool, .boolean), (.string, .string), (.int, .integer), (.int, .number), (.double, .number), (.array, .object), (.object, .object): return true + default: return false + } + } + + private func typeMismatch(_ key: String, _ value: AnyValue, _ expected: ExpectedType, metadata: FlagMetadata = [:]) -> ProviderEvaluation { + ProviderEvaluation(value: value, flagMetadata: metadata, reason: Reason.error.rawValue, errorCode: .typeMismatch, errorMessage: "Flag \"\(key)\" did not resolve to a \(expected) value") + } + + private func typed(_ source: ProviderEvaluation, fallback: T, convert: (AnyValue) -> T?) -> ProviderEvaluation { + ProviderEvaluation(value: convert(source.value) ?? fallback, flagMetadata: source.flagMetadata, variant: source.variant, reason: source.reason, errorCode: source.errorCode, errorMessage: source.errorMessage) + } +} diff --git a/Tests/FeaturevisorOpenFeatureTests/FeaturevisorOpenFeatureProviderTests.swift b/Tests/FeaturevisorOpenFeatureTests/FeaturevisorOpenFeatureProviderTests.swift new file mode 100644 index 0000000..9254203 --- /dev/null +++ b/Tests/FeaturevisorOpenFeatureTests/FeaturevisorOpenFeatureProviderTests.swift @@ -0,0 +1,83 @@ +import XCTest +import Featurevisor +import FeaturevisorOpenFeature +import OpenFeature + +final class FeaturevisorOpenFeatureProviderTests: XCTestCase { + private final class CloseState: @unchecked Sendable { + var closed = false + } + + private let datafileJSON = #"{"schemaVersion":"2","revision":"openfeature-test","segments":{},"features":{"checkout":{"bucketBy":"userId","variations":[{"value":"on","variables":{"title":"Hello","count":3,"ratio":1.5,"visible":true,"items":["a"],"config":{"color":"blue"},"json":"{\"nested\":true}"}}],"variablesSchema":{"title":{"type":"string","defaultValue":"Default"},"count":{"type":"integer","defaultValue":0},"ratio":{"type":"double","defaultValue":0},"visible":{"type":"boolean","defaultValue":false},"items":{"type":"array","defaultValue":[]},"config":{"type":"object","defaultValue":{}},"json":{"type":"json","defaultValue":"{}"}},"force":[{"conditions":{"attribute":"userId","operator":"equals","value":"forced-user"},"enabled":true,"variation":"on"}],"traffic":[{"key":"all","segments":"*","percentage":100000,"variation":"on"}]}}}"# + + private func provider( + keySeparator: String = ":", + variationKey: String = "variation", + onTrack: ((String, (any EvaluationContext)?, (any TrackingEventDetails)?) -> Void)? = nil + ) throws -> FeaturevisorOpenFeatureProvider { + FeaturevisorOpenFeatureProvider( + options: FeaturevisorOptions(datafile: try DatafileContent.fromJSON(datafileJSON), logLevel: .fatal), + keySeparator: keySeparator, + variationKey: variationKey, + onTrack: onTrack + ) + } + + func testResolvesEveryTypeAndMapsTargetingKey() throws { + let provider = try provider() + let context = ImmutableContext(targetingKey: "forced-user") + XCTAssertTrue(try provider.getBooleanEvaluation(key: "checkout", defaultValue: false, context: context).value) + XCTAssertEqual(try provider.getStringEvaluation(key: "checkout:variation", defaultValue: "fallback", context: context).value, "on") + XCTAssertEqual(try provider.getStringEvaluation(key: "checkout:title", defaultValue: "fallback", context: context).value, "Hello") + XCTAssertEqual(try provider.getIntegerEvaluation(key: "checkout:count", defaultValue: 0, context: context).value, 3) + XCTAssertEqual(try provider.getDoubleEvaluation(key: "checkout:ratio", defaultValue: 0, context: context).value, 1.5) + XCTAssertTrue(try provider.getBooleanEvaluation(key: "checkout:visible", defaultValue: false, context: context).value) + XCTAssertEqual(try provider.getObjectEvaluation(key: "checkout:items", defaultValue: .list([]), context: context).value, .list([.string("a")])) + XCTAssertEqual(try provider.getObjectEvaluation(key: "checkout:config", defaultValue: .structure([:]), context: context).value, .structure(["color": .string("blue")])) + XCTAssertEqual(try provider.getObjectEvaluation(key: "checkout:json", defaultValue: .structure([:]), context: context).value, .structure(["nested": .boolean(true)])) + } + + func testErrorsCustomGrammarTrackingAndClose() throws { + var tracked: [String] = [] + let provider = try provider(keySeparator: "/", variationKey: "$variation") { name, _, _ in tracked.append(name) } + XCTAssertEqual(try provider.getStringEvaluation(key: "checkout/$variation", defaultValue: "fallback", context: nil).value, "on") + XCTAssertEqual(try provider.getStringEvaluation(key: "missing", defaultValue: "fallback", context: nil).errorCode, .typeMismatch) + let missing = try provider.getBooleanEvaluation(key: "missing", defaultValue: true, context: nil) + XCTAssertTrue(missing.value) + XCTAssertEqual(missing.errorCode, .flagNotFound) + try provider.track(key: "purchase", context: nil, details: nil) + XCTAssertEqual(tracked, ["purchase"]) + provider.close() + } + + func testWorksThroughOpenFeatureAPI() async throws { + let provider = try provider() + await OpenFeatureAPI.shared.setProviderAndWait( + provider: provider, + initialContext: ImmutableContext(targetingKey: "forced-user") + ) + let client = OpenFeatureAPI.shared.getClient() + let value = client.getBooleanValue( + key: "checkout", + defaultValue: false + ) + XCTAssertTrue(value) + } + + func testProviderBorrowsExistingFeaturevisor() throws { + let state = CloseState() + let featurevisor = createFeaturevisor(FeaturevisorOptions( + datafile: try DatafileContent.fromJSON(datafileJSON), + logLevel: .fatal, + modules: [FeaturevisorModule(name: "owner", close: { state.closed = true })] + )) + let provider = FeaturevisorOpenFeatureProvider(featurevisor: featurevisor) + + XCTAssertTrue(provider.featurevisor === featurevisor) + provider.close() + XCTAssertFalse(state.closed) + + featurevisor.close() + XCTAssertTrue(state.closed) + } +}