Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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"],
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

<!-- FEATUREVISOR_DOCS_END -->

## Development of this package
Expand Down
209 changes: 209 additions & 0 deletions Sources/FeaturevisorOpenFeature/FeaturevisorOpenFeatureProvider.swift
Original file line number Diff line number Diff line change
@@ -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<ProviderEvent?, Never> { 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<Bool> {
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<String> {
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<Int64> {
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<Double> {
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<Value> {
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<AnyValue> {
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[..<range.lowerBound]), String(key[range.upperBound...]))
}

private func featurevisorContext(_ context: (any EvaluationContext)?) -> 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<AnyValue> {
ProviderEvaluation(value: value, flagMetadata: metadata, reason: Reason.error.rawValue, errorCode: .typeMismatch, errorMessage: "Flag \"\(key)\" did not resolve to a \(expected) value")
}

private func typed<T>(_ source: ProviderEvaluation<AnyValue>, fallback: T, convert: (AnyValue) -> T?) -> ProviderEvaluation<T> {
ProviderEvaluation(value: convert(source.value) ?? fallback, flagMetadata: source.flagMetadata, variant: source.variant, reason: source.reason, errorCode: source.errorCode, errorMessage: source.errorMessage)
}
}
Loading
Loading