Skip to content
Merged
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
4 changes: 2 additions & 2 deletions Package.resolved

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

14 changes: 7 additions & 7 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import PackageDescription

let package = Package(
name: "swift-provider",
name: "SplitProvider",
platforms: [
.iOS(.v14),
.macOS(.v11),
Expand All @@ -14,28 +14,28 @@ let package = Package(
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "swift-provider",
targets: ["swift-provider"]
name: "SplitProvider",
targets: ["SplitProvider"]
)
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/open-feature/swift-sdk.git", from: "0.4.0"),
.package(url: "https://github.com/splitio/ios-client.git", from: "3.4.1"),
.package(url: "https://github.com/splitio/ios-client.git", from: "3.4.2"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "swift-provider",
name: "SplitProvider",
dependencies: [
.product(name: "OpenFeature", package: "swift-sdk"),
.product(name: "Split", package: "ios-client"),
]
),
.testTarget(
name: "swift-providerTests",
dependencies: ["swift-provider"]
name: "SplitProviderTests",
dependencies: ["SplitProvider"]
),
]
)
8 changes: 8 additions & 0 deletions Sources/SplitProvider/Constants.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Created by Martin Cardozo on 24/10/2025.

internal enum Constants: String {
case API_KEY = "API_KEY"
case USER_KEY = "USER_KEY"
case CONTROL = "CONTROL"
case PROVIDER_NAME = "Split"
}
17 changes: 17 additions & 0 deletions Sources/SplitProvider/EventHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Created by Martin Cardozo on 22/10/2025.

import Combine
import OpenFeature

// Used by Combine to propagate events
internal final class EventHandler {
private let subject = PassthroughSubject<OpenFeature.ProviderEvent?, Never>()

var publisher: AnyPublisher<OpenFeature.ProviderEvent?, Never> {
subject.eraseToAnyPublisher()
}

func send(_ event: OpenFeature.ProviderEvent) {
subject.send(event)
}
}
44 changes: 44 additions & 0 deletions Sources/SplitProvider/InitContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Created by Martin Cardozo on 22/10/2025.

import OpenFeature

internal struct InitContext: OpenFeature.EvaluationContext {
let apiKey: String
let userKey: String

var props: [String: OpenFeature.Value] {
[
"API_KEY": .string(apiKey),
"USER_KEY": .string(userKey)
]
}

func keySet() -> Set<String> {
[Constants.API_KEY.rawValue, Constants.USER_KEY.rawValue]
}

func getTargetingKey() -> String {
userKey
}

func deepCopy() -> any OpenFeature.EvaluationContext {
InitContext(apiKey: apiKey, userKey: userKey)
}

func getValue(key: String) -> OpenFeature.Value? {
props[key]
}

func asMap() -> [String: OpenFeature.Value] {
props
}

func asObjectMap() -> [String: AnyHashable?] {
props.mapValues { value in
switch value {
case .string(let str): return str
default: return nil
}
}
}
}
8 changes: 8 additions & 0 deletions Sources/SplitProvider/SplitErrors.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Created by Martin Cardozo on 22/10/2025.

public enum SplitError: Int, Error {
case notImplemented
case missingInitContext
case missingInitData
case timeout
}
111 changes: 111 additions & 0 deletions Sources/SplitProvider/SplitProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Created by Martin Cardozo on 22/10/2025.

import Combine
import Foundation
import OpenFeature
import Split

public class SplitProvider: FeatureProvider {

// Split Components
internal var splitClient: SplitClient?
internal var splitClientConfig: SplitClientConfig?
internal var factory: SplitFactory?

// Open Feature Components
public var hooks: [any OpenFeature.Hook] = []
public var metadata: any OpenFeature.ProviderMetadata = SplitProviderMetadata()
private let eventHandler = EventHandler()
private var splitContext: InitContext?

// MARK: Custom Initialization
public init(_ config: SplitClientConfig? = nil) {
splitClientConfig = config
}

public func initialize(initialContext: (any OpenFeature.EvaluationContext)?) async throws {

guard let initialContext = initialContext else {
eventHandler.send(.error(errorCode: .invalidContext, message: "Initialization context is missing for Split provider."))
throw SplitError.missingInitContext
}

// 1. Unpack Context
let apiKeyValue = initialContext.getValue(key: Constants.API_KEY.rawValue)?.asString()
let userKeyValue = initialContext.getValue(key: Constants.USER_KEY.rawValue)?.asString()
guard let API_KEY = apiKeyValue, apiKeyValue != "" else {
eventHandler.send(.error(errorCode: .invalidContext, message: "Initialization data is missing for Split provider."))
throw SplitError.missingInitData
}
guard let USER_KEY = userKeyValue, userKeyValue != "" else {
eventHandler.send(.error(errorCode: .targetingKeyMissing, message: "Initialization data is missing for Split provider."))
throw SplitError.missingInitData
}

// 2. Client setup
splitContext = InitContext(apiKey: API_KEY, userKey: USER_KEY)
let key: Key = Key(matchingKey: USER_KEY)
if factory == nil {
factory = DefaultSplitFactoryBuilder().setApiKey(API_KEY).setKey(key).setConfig(splitClientConfig ?? SplitClientConfig()).build()
}
splitClient = factory?.client

// 3. Wait for SDK
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
var didResume = false

// Avoid crash by multiple countinuations
func resumeOnce(error: Bool = false) {
guard !didResume else { return }
didResume = true

if error {
eventHandler.send(.error(errorCode: .general, message: "Provider timed out"))
} else {
continuation.resume()
}
}

splitClient?.on(event: .sdkReady) { resumeOnce() }
splitClient?.on(event: .sdkReadyFromCache) { resumeOnce() }
splitClient?.on(event: .sdkReadyTimedOut) { resumeOnce(error: true) }
}
}

// MARK: Context Change
public func onContextSet(oldContext: (any OpenFeature.EvaluationContext)?, newContext: any OpenFeature.EvaluationContext) async throws {
throw SplitError.notImplemented
}
}

// MARK: Evaluation Methods
extension SplitProvider {

public func getBooleanEvaluation(key: String, defaultValue: Bool, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation<Bool> {
ProviderEvaluation(value: false)
}

public func getStringEvaluation(key: String, defaultValue: String, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation<String> {
ProviderEvaluation(value: splitClient?.getTreatment(key) ?? Constants.CONTROL.rawValue)
}

public func getIntegerEvaluation(key: String, defaultValue: Int64, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation<Int64> {
ProviderEvaluation(value: 1)
}

public func getDoubleEvaluation(key: String, defaultValue: Double, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation<Double> {
ProviderEvaluation(value: 1.0)
}

public func getObjectEvaluation(key: String, defaultValue: OpenFeature.Value, context: (any OpenFeature.EvaluationContext)?) throws -> OpenFeature.ProviderEvaluation<OpenFeature.Value> {
throw SplitError.notImplemented
}

public func observe() -> AnyPublisher<OpenFeature.ProviderEvent?, Never> {
eventHandler.publisher.eraseToAnyPublisher()
}
}

struct SplitProviderMetadata: ProviderMetadata {
let name: String? = Constants.PROVIDER_NAME.rawValue
}
63 changes: 0 additions & 63 deletions Sources/swift-provider/SplitProvider.swift

This file was deleted.

52 changes: 52 additions & 0 deletions Tests/SplitProviderTests/InitContextTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Created by Martin Cardozo on 24/10/2025.

import XCTest
import OpenFeature
@testable import SplitProvider

final class InitContextTests: XCTestCase {

var SUT: InitContext!

override func setUp() {
SUT = InitContext(apiKey: "skhjcgkjhfgasdhka", userKey: "martin")
}

override func tearDown() {}

func testValues() {
XCTAssertEqual(SUT.getValue(key: Constants.API_KEY.rawValue)?.asString(), "skhjcgkjhfgasdhka")
XCTAssertEqual(SUT.getValue(key: Constants.USER_KEY.rawValue)?.asString(), "martin")
}

func testKeySet() {
XCTAssertEqual(SUT.keySet(), [Constants.API_KEY.rawValue, Constants.USER_KEY.rawValue])
}

func testTargetingKey() {
XCTAssertEqual(SUT.getTargetingKey(), "martin")
}

func testDeepCopy() {
let deepCopy = SUT.deepCopy()

XCTAssertEqual(deepCopy.getValue(key: Constants.API_KEY.rawValue)?.asString(), "skhjcgkjhfgasdhka")
XCTAssertEqual(deepCopy.getValue(key: Constants.USER_KEY.rawValue)?.asString(), "martin")
}

func testAsMap() {
let map = SUT.asMap()

XCTAssertEqual(map[Constants.API_KEY.rawValue]?.asString(), "skhjcgkjhfgasdhka")
XCTAssertEqual(map[Constants.USER_KEY.rawValue]?.asString(), "martin")
}

func testAsObjectMap() {
let asObjectMap = SUT.asObjectMap()

XCTAssertEqual(asObjectMap[Constants.API_KEY.rawValue], "skhjcgkjhfgasdhka")
XCTAssertEqual(asObjectMap[Constants.USER_KEY.rawValue], "martin")
}
}


Loading