Skip to content
Closed
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 .github/workflows/sdk-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
jobs:
sdk-spec:
# Keep the ref in `uses:` and `spec-ref` in sync.
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.4.0
uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0
with:
spec-ref: v0.4.0
spec-ref: v0.7.0
language: swift
22 changes: 22 additions & 0 deletions FlagsmithClient/Classes/Experiment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Experiment.swift
// FlagsmithClient
//

import Foundation

/**
An `Experiment` is the running experiment a `Flag` was evaluated under, for an identity.
*/
public struct Experiment: Codable, Sendable, Equatable {
enum CodingKeys: String, CodingKey {
case id
case name
case inExperiment = "in_experiment"
}

public let id: Int
public let name: String
/// Whether the identity is enrolled in the experiment.
public let inExperiment: Bool
}
25 changes: 25 additions & 0 deletions FlagsmithClient/Classes/Flag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,21 @@ public struct Flag: Codable, Sendable, Equatable {
case feature
case value = "feature_state_value"
case enabled
case variant
case metadata
}

private struct Metadata: Codable {
let experiment: Experiment?
}

public let feature: Feature
public let value: TypedValue
public let enabled: Bool
/// The key of the multivariate option served to the identity, if any.
public let variant: String?
/// The running experiment the flag was evaluated under, for an identity.
public let experiment: Experiment?

public init(featureName: String, boolValue: Bool, enabled: Bool,
featureType: String? = nil, featureDescription: String? = nil)
Expand Down Expand Up @@ -62,13 +72,28 @@ public struct Flag: Codable, Sendable, Equatable {
feature = Feature(name: featureName, type: featureType, description: featureDescription)
self.value = value
self.enabled = enabled
variant = nil
experiment = nil
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
feature = try container.decode(Feature.self, forKey: .feature)
value = try container.decode(TypedValue.self, forKey: .value)
enabled = try container.decode(Bool.self, forKey: .enabled)
variant = try container.decodeIfPresent(String.self, forKey: .variant)
experiment = (try? container.decodeIfPresent(Metadata.self, forKey: .metadata))?.experiment
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(feature, forKey: .feature)
try container.encode(value, forKey: .value)
try container.encode(enabled, forKey: .enabled)
try container.encodeIfPresent(variant, forKey: .variant)
if let experiment = experiment {
try container.encode(Metadata(experiment: experiment), forKey: .metadata)
}
}

public static func == (lhs: Flag, rhs: Flag) -> Bool {
Expand Down
22 changes: 22 additions & 0 deletions FlagsmithClient/Classes/Flagsmith+Concurrency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ public extension Flagsmith {
}
}

/// Get a feature flag for an identity, and record that the identity was exposed to it
///
/// - Parameters:
/// - id: ID of the feature
/// - identity: ID of the user
/// - traits: Traits of the user (optional)
/// - returns: The Flag, if the feature exists
func getExperimentFlag(withID id: String, forIdentity identity: String, traits: [Trait]? = nil) async throws -> Flag? {
try await withCheckedThrowingContinuation { continuation in
getExperimentFlag(withID: id, forIdentity: identity, traits: traits) { result in
continuation.resume(with: result)
}
}
}

/// Send the tracked events now, and wait until they have been sent, or dropped after failing to send
func flushEvents() async {
await withCheckedContinuation { continuation in
flushEvents { continuation.resume() }
}
}

/// Get all user traits for provided identity. Optionally filter results with a list of keys
///
/// - Parameters:
Expand Down
102 changes: 102 additions & 0 deletions FlagsmithClient/Classes/Flagsmith.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
private let apiManager: APIManager
private let sseManager: SSEManager
private let analytics: FlagsmithAnalytics
private let events: FlagsmithEvents

// The last time we got flags via the API
private var lastUpdatedAt: Double = 0.0
Expand Down Expand Up @@ -90,6 +91,28 @@
set { analytics.flushPeriod = newValue }
}

/// Are experimentation events sent? Defaults to `false`.
///
/// Events are tracked with `trackEvent`, `trackExposureEvent` and `getExperimentFlag`.
public var enableEvents: Bool {
get { events.enabled }
set { events.enabled = newValue }
}

/// Base `URL` of Flagsmith's events API.
///
/// The default implementation uses: `https://events.api.flagsmith.com/`.
public var eventsBaseURL: URL {
get { events.baseURL }
set { events.baseURL = newValue }
}

/// How often to send the experimentation events, in seconds
public var eventsFlushPeriod: Int {
get { events.flushPeriod }
set { events.flushPeriod = newValue }
}

/// Default flags to fall back on if an API call fails
private var _defaultFlags: [Flag] = []
public var defaultFlags: [Flag] {
Expand Down Expand Up @@ -120,6 +143,7 @@
apiManager = APIManager()
sseManager = SSEManager()
analytics = FlagsmithAnalytics(apiManager: apiManager)
events = FlagsmithEvents(apiManager: apiManager)
}

/// Get all feature flags (flags and remote config) optionally for a specific identity
Expand Down Expand Up @@ -194,7 +218,7 @@
}
}

private func handleFlagsErrorForIdentity(_ error: any Error, identity: String, completion: @Sendable @escaping (Result<[Flag], any Error>) -> Void) {

Check warning on line 221 in FlagsmithClient/Classes/Flagsmith.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Line Length Violation: Line should be 140 characters or less; currently it has 153 characters (line_length)
// Priority: 1. Try cached flags for identity, 2. Try general cached flags, 3. Fall back to default flags, 4. Return error

// First, try to get cached flags for the specific identity if caching is enabled
Expand Down Expand Up @@ -430,6 +454,84 @@
}
}

/// Get a feature flag for an identity, and record that the identity was exposed to it
///
/// When events are enabled, and the identity is enrolled in a running experiment on the feature,
/// this tracks a `$flag_exposure` event with the variant the identity was served.
///
/// - Parameters:
/// - id: ID of the feature
/// - identity: ID of the user
/// - traits: Traits of the user (optional)
/// - completion: Closure with Result which contains the Flag, if the feature exists, in case of success or Error in case of failure
public func getExperimentFlag(withID id: String,
forIdentity identity: String,
traits: [Trait]? = nil,
completion: @Sendable @escaping (Result<Flag?, any Error>) -> Void)
{
analytics.trackEvent(flagName: id)
getFeatureFlags(forIdentity: identity, traits: traits) { result in
completion(result.map { flags in
let flag = flags.first(where: { $0.feature.name == id })
if let flag = flag {
self.events.trackExposure(to: flag, identifier: identity, traits: traits.map(self.traitValues))
}
return flag
})
}
}

/// Track an event, e.g. a conversion, when events are enabled
///
/// - Parameters:
/// - event: Name of the event. Names starting with `$` are reserved for Flagsmith.
/// - identity: ID of the user (optional)
/// - value: Value of the event, e.g. a purchase amount (optional)
/// - traits: Traits of the user (optional)
/// - metadata: Additional data about the event (optional)
public func trackEvent(_ event: String,
forIdentity identity: String? = nil,
value: TypedValue? = nil,
traits: [Trait]? = nil,
metadata: [String: TypedValue]? = nil) throws
{
guard !event.hasPrefix("$") else {
throw FlagsmithError.invalidArgument("Event names starting with \"$\" are reserved: \(event)")
}
events.trackEvent(event, identifier: identity, value: value, traits: traits.map(traitValues),
metadata: metadata)
}

/// Track that a user was exposed to a variant of a feature, as a `$flag_exposure` event, when events are enabled
///
/// - Parameters:
/// - featureName: Name of the feature
/// - identity: ID of the user
/// - value: The variant the user was exposed to (optional)
/// - traits: Traits of the user (optional)
/// - metadata: Additional data about the exposure, e.g. `experiment_id` (optional)
public func trackExposureEvent(forFeature featureName: String,
forIdentity identity: String,
value: TypedValue? = nil,
traits: [Trait]? = nil,
metadata: [String: TypedValue]? = nil)
{
events.trackExposureEvent(featureName: featureName, identifier: identity, value: value,
traits: traits.map(traitValues), metadata: metadata)
}

/// Send the tracked events now
///
/// - Parameters:
/// - completion: Closure called once the events have been sent, or dropped after failing to send (optional)
public func flushEvents(completion: (@Sendable () -> Void)? = nil) {
events.flush(completion: completion)
}

private func traitValues(_ traits: [Trait]) -> [String: TypedValue] {
Dictionary(traits.map { ($0.key, $0.typedValue) }) { _, last in last }
}

/// Get all user traits for provided identity. Optionally filter results with a list of keys
///
/// - Parameters:
Expand Down Expand Up @@ -539,7 +641,7 @@
switch result {
case let .failure(error):
print("Flagsmith - Error getting flags in SSE stream: \(error.localizedDescription)")
case .success(_):

Check warning on line 644 in FlagsmithClient/Classes/Flagsmith.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Empty Enum Arguments Violation: Arguments can be omitted when matching enums with associated values if they are not used (empty_enum_arguments)
break
}
}
Expand All @@ -553,7 +655,7 @@
func updateFlagStreamAndLastUpdatedAt(_ flags: [Flag]) {
// Update the flag stream
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
if (flags != lastFlags) {

Check warning on line 658 in FlagsmithClient/Classes/Flagsmith.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Control Statement Violation: `if`, `for`, `guard`, `switch`, `while`, and `catch` statements shouldn't unnecessarily wrap their conditionals or arguments in parentheses (control_statement)
flagStreamContinuation?.yield(flags)
}
}
Expand All @@ -580,4 +682,4 @@

/// Skip API if there is a cache available
public var skipAPI: Bool = false
}

Check warning on line 685 in FlagsmithClient/Classes/Flagsmith.swift

View workflow job for this annotation

GitHub Actions / swift-lint

File Length Violation: File should contain 400 lines or less: currently contains 685 (file_length)
14 changes: 10 additions & 4 deletions FlagsmithClient/Classes/Internal/APIManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,19 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable {
///
/// - parameters:
/// - router: The path and parameters that should be requested.
/// - baseURL: The base URL to request the route from, instead of `self.baseURL`.
/// - completion: Function block executed with the result of the request.
private func request(_ router: Router, completion: @Sendable @escaping (Result<Data, any Error>) -> Void) {
private func request(_ router: Router, baseURL: URL? = nil,
completion: @Sendable @escaping (Result<Data, any Error>) -> Void)
{
guard let apiKey = apiKey, !apiKey.isEmpty else {
completion(.failure(FlagsmithError.apiKey))
return
}

var request: URLRequest
do {
request = try router.request(baseUrl: baseURL, apiKey: apiKey)
request = try router.request(baseUrl: baseURL ?? self.baseURL, apiKey: apiKey)
} catch {
completion(.failure(error))
return
Expand Down Expand Up @@ -170,9 +173,12 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable {
///
/// - parameters:
/// - router: The path and parameters that should be requested.
/// - baseURL: The base URL to request the route from, instead of `self.baseURL`.
/// - completion: Function block executed with the result of the request.
func request(_ router: Router, completion: @Sendable @escaping (Result<Void, any Error>) -> Void) {
request(router) { (result: Result<Data, Error>) in
func request(_ router: Router, baseURL: URL? = nil,
completion: @Sendable @escaping (Result<Void, any Error>) -> Void)
{
request(router, baseURL: baseURL) { (result: Result<Data, Error>) in
switch result {
case let .failure(error):
completion(.failure(FlagsmithError(error)))
Expand Down
Loading
Loading