Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support transient identities and traits #68

Merged
merged 4 commits into from
Oct 8, 2024
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
9 changes: 5 additions & 4 deletions Example/FlagsmithClient/Base.lproj/LaunchScreen.xib
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23094" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23084"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
Expand All @@ -11,14 +12,14 @@
<rect key="frame" x="0.0" y="0.0" width="480" height="480"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" Copyright (c) 2019 SolidStateGroup. All rights reserved." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="8ie-xW-0ye">
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="© 2024 Flagsmith. All rights reserved." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="8ie-xW-0ye">
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
<rect key="frame" x="20" y="439" width="440" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="darkTextColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="FlagsmithClient" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="kId-c2-rCX">
<rect key="frame" x="20" y="140" width="440" height="43"/>
<rect key="frame" x="20" y="139.66666666666666" width="440" height="43"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
<color key="textColor" systemColor="darkTextColor"/>
<nil key="highlightedColor"/>
Expand Down
10 changes: 8 additions & 2 deletions FlagsmithClient/Classes/Flagsmith.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,18 @@
///
/// - Parameters:
/// - identity: ID of the user (optional)
/// - transient: If `true`, identity is not persisted
/// - completion: Closure with Result which contains array of Flag objects in case of success or Error in case of failure
public func getFeatureFlags(forIdentity identity: String? = nil,
traits: [Trait]? = nil,
transient: Bool = false,
completion: @Sendable @escaping (Result<[Flag], any Error>) -> Void)
{
if let identity = identity {
if let traits = traits {
apiManager.request(.postTraits(identity: identity, traits: traits)) { (result: Result<Traits, Error>) in
apiManager.request(
.postTraits(identity: identity, traits: traits, transient: transient)
) { (result: Result<Traits, Error>) in
switch result {
case let .success(result):
completion(.success(result.flags))
Expand All @@ -97,7 +101,7 @@
}
}
} else {
getIdentity(identity) { result in
getIdentity(identity, transient: transient) { result in
switch result {
case let .success(thisIdentity):
completion(.success(thisIdentity.flags))
Expand All @@ -107,7 +111,7 @@
}
}
} else {
if let _ = traits {

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

View workflow job for this annotation

GitHub Actions / swift-lint

Unused Optional Binding Violation: Prefer `!= nil` over `let _ =` (unused_optional_binding)
completion(.failure(FlagsmithError.invalidArgument("You must provide an identity to set traits")))
} else {
apiManager.request(.getFlags) { (result: Result<[Flag], Error>) in
Expand Down Expand Up @@ -289,8 +293,10 @@
///
/// - Parameters:
/// - identity: ID of the user
/// - transient: If `true`, identity is not persisted
/// - completion: Closure with Result which contains Identity in case of success or Error in case of failure
public func getIdentity(_ identity: String,
transient: Bool = false,
completion: @Sendable @escaping (Result<Identity, any Error>) -> Void)
{
apiManager.request(.getIdentity(identity: identity)) { (result: Result<Identity, Error>) in
Expand Down
5 changes: 4 additions & 1 deletion FlagsmithClient/Classes/Identity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@
import Foundation

/**
An Identity represents a user stored on the server.
An `Identity` represents a set of user data used for flag evaluation.
An `Identity` with `transient` set to `true` is not stored in Flagsmith backend.
*/
public struct Identity: Decodable, Sendable {
enum CodingKeys: String, CodingKey {
case flags
case traits
case transient
}

public let flags: [Flag]
public let traits: [Trait]
public let transient: Bool
}
16 changes: 10 additions & 6 deletions FlagsmithClient/Classes/Internal/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ enum Router: Sendable {
}

case getFlags
case getIdentity(identity: String)
case getIdentity(identity: String, transient: Bool = false)
case postTrait(trait: Trait, identity: String)
case postTraits(identity: String, traits: [Trait])
case postTraits(identity: String, traits: [Trait], transient: Bool = false)
case postAnalytics(events: [String: Int])

private var method: HTTPMethod {
Expand All @@ -46,8 +46,12 @@ enum Router: Sendable {

private var parameters: [URLQueryItem]? {
switch self {
case let .getIdentity(identity), let .postTraits(identity, _):
return [URLQueryItem(name: "identifier", value: identity)]
case let .getIdentity(identity, transient):
var queryItems = [URLQueryItem(name: "identifier", value: identity)]
if transient {
queryItems.append(URLQueryItem(name: "transient", value: "true"))
}
return queryItems
default:
return nil
}
Expand All @@ -60,8 +64,8 @@ enum Router: Sendable {
case let .postTrait(trait, identifier):
let traitWithIdentity = Trait(trait: trait, identifier: identifier)
return try encoder.encode(traitWithIdentity)
case let .postTraits(identifier, traits):
let traitsWithIdentity = Traits(traits: traits, identifier: identifier)
case let .postTraits(identifier, traits, transient):
let traitsWithIdentity = Traits(traits: traits, identifier: identifier, transient: transient)
return try encoder.encode(traitsWithIdentity)
case let .postAnalytics(events):
return try encoder.encode(events)
Expand Down
34 changes: 27 additions & 7 deletions FlagsmithClient/Classes/Trait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
import Foundation

/**
A Trait represents a value stored against an Identity (user) on the server.
A `Trait` represents a key-value pair used by Flagsmith to segment an `Identity`.
A `Trait` with `transient` set to `true` is not stored in Flagsmith backend.
*/
public struct Trait: Codable, Sendable {
enum CodingKeys: String, CodingKey {
case key = "trait_key"
case value = "trait_value"
case transient
case identity
case identifier
}
Expand All @@ -24,11 +26,13 @@ public struct Trait: Codable, Sendable {
/// - note: In the future, this can be renamed back to 'value' as major/feature-breaking
/// updates are released.
public var typedValue: TypedValue
public let transient: Bool
/// The identity of the `Trait` when creating.
internal let identifier: String?

public init(key: String, value: TypedValue) {
public init(key: String, value: TypedValue, transient: Bool = false) {
self.key = key
self.transient = transient
typedValue = value
identifier = nil
}
Expand All @@ -39,12 +43,18 @@ public struct Trait: Codable, Sendable {
/// will contain a `identity` key.
internal init(trait: Trait, identifier: String) {
key = trait.key
transient = trait.transient
typedValue = trait.typedValue
self.identifier = identifier
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if container.contains(.transient) {
transient = try container.decode(Bool.self, forKey: .transient)
} else {
transient = false
}
key = try container.decode(String.self, forKey: .key)
typedValue = try container.decode(TypedValue.self, forKey: .value)
identifier = nil
Expand All @@ -56,35 +66,45 @@ public struct Trait: Codable, Sendable {
try container.encode(typedValue, forKey: .value)

if let identifier = identifier {
// Assume call to `/api/v1/traits` SDK endpoint
// (used to persist traits for previously persisted identities).
// Flagsmith does not process the `transient` attribute in this case,
// so we don't need it here.
var identity = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .identity)
try identity.encode(identifier, forKey: .identifier)
} else {
try container.encode(transient, forKey: .transient)
}
}
}

// MARK: - Convenience Initializers

public extension Trait {
init(key: String, value: Bool) {
init(key: String, value: Bool, transient: Bool = false) {
self.key = key
self.transient = transient
typedValue = .bool(value)
identifier = nil
}

init(key: String, value: Float) {
init(key: String, value: Float, transient: Bool = false) {
self.key = key
self.transient = transient
typedValue = .float(value)
identifier = nil
}

init(key: String, value: Int) {
init(key: String, value: Int, transient: Bool = false) {
self.key = key
self.transient = transient
typedValue = .int(value)
identifier = nil
}

init(key: String, value: String) {
init(key: String, value: String, transient: Bool = false) {
self.key = key
self.transient = transient
typedValue = .string(value)
identifier = nil
}
Expand Down
11 changes: 10 additions & 1 deletion FlagsmithClient/Classes/Traits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,19 @@ public struct Traits: Codable, Sendable {
public let traits: [Trait]
public let identifier: String?
public let flags: [Flag]
public let transient: Bool

init(traits: [Trait], identifier: String?, flags: [Flag] = []) {
init(traits: [Trait], identifier: String?, flags: [Flag] = [], transient: Bool = false) {
self.traits = traits
self.identifier = identifier
self.flags = flags
self.transient = transient
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(traits, forKey: .traits)
try container.encode(identifier, forKey: .identifier)
try container.encode(transient, forKey: .transient)
}
}
38 changes: 33 additions & 5 deletions FlagsmithClient/Tests/RouterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ final class RouterTests: FlagsmithClientTestCase {
XCTAssertNil(request.httpBody)
}

func testGetIdentityRequest_Transient() throws {
let url = try XCTUnwrap(baseUrl)
let route = Router.getIdentity(identity: "6056BCBF", transient: true)
let request = try route.request(baseUrl: url, apiKey: apiKey)
XCTAssertEqual(request.url?.absoluteString,
"https://edge.api.flagsmith.com/api/v1/identities/?identifier=6056BCBF&transient=true")
}

func testPostTraitRequest() throws {
let trait = Trait(key: "meaning_of_life", value: 42)
let url = try XCTUnwrap(baseUrl)
Expand All @@ -57,27 +65,47 @@ final class RouterTests: FlagsmithClientTestCase {

func testPostTraitsRequest() throws {
let questionTrait = Trait(key: "question_meaning_of_life", value: "6 x 9")
let meaningTrait = Trait(key: "meaning_of_life", value: 42)
let meaningTrait = Trait(key: "meaning_of_life", value: 42, transient: true)
let url = try XCTUnwrap(baseUrl)
let route = Router.postTraits(identity: "A1B2C3D4", traits: [questionTrait, meaningTrait])
let request = try route.request(baseUrl: url, apiKey: apiKey, using: encoder)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.absoluteString, "https://edge.api.flagsmith.com/api/v1/identities/?identifier=A1B2C3D4")
XCTAssertEqual(request.url?.absoluteString, "https://edge.api.flagsmith.com/api/v1/identities/")

let expectedJson = try """
{
"traits" : [
{
"trait_key" : "question_meaning_of_life",
"trait_value" : "6 x 9"
"trait_value" : "6 x 9",
"transient": false
},
{
"trait_key" : "meaning_of_life",
"trait_value" : 42
"trait_value" : 42,
"transient": true
}
],
"identifier" : "A1B2C3D4",
"flags": []
"transient": false
}
""".json(using: .utf8)
let body = try request.httpBody.json()
XCTAssertEqual(body, expectedJson)
}

func testPostTraitsRequest_TransientIdentity() throws {
let url = try XCTUnwrap(baseUrl)
let route = Router.postTraits(identity: "A1B2C3D4", traits: [], transient: true)
let request = try route.request(baseUrl: url, apiKey: apiKey, using: encoder)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.absoluteString, "https://edge.api.flagsmith.com/api/v1/identities/")

let expectedJson = try """
{
"traits" : [],
"identifier" : "A1B2C3D4",
"transient": true
}
""".json(using: .utf8)
let body = try request.httpBody.json()
Expand Down