Skip to content

Commit 0fe8aeb

Browse files
feat: Exporter Metadata
Signed-off-by: Thomas Poignant <[email protected]>
1 parent 9f9b8cc commit 0fe8aeb

File tree

6 files changed

+169
-8
lines changed

6 files changed

+169
-8
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import Foundation
2+
import OpenFeature
3+
4+
// Define a Codable enum that can represent any type of JSON value
5+
public enum ExporterMetadataValue: Codable, Equatable {
6+
case string(String)
7+
case integer(Int64)
8+
case double(Double)
9+
case bool(Bool)
10+
11+
// Decode the JSON based on its type
12+
public init(from decoder: Decoder) throws {
13+
let container = try decoder.singleValueContainer()
14+
if let stringValue = try? container.decode(String.self) {
15+
self = .string(stringValue)
16+
} else if let intValue = try? container.decode(Int64.self) {
17+
self = .integer(intValue)
18+
} else if let doubleValue = try? container.decode(Double.self) {
19+
self = .double(doubleValue)
20+
} else if let boolValue = try? container.decode(Bool.self) {
21+
self = .bool(boolValue)
22+
} else {
23+
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode JSONValue")
24+
}
25+
}
26+
27+
// Encode the JSON based on its type
28+
public func encode(to encoder: Encoder) throws {
29+
var container = encoder.singleValueContainer()
30+
switch self {
31+
case .string(let value):
32+
try container.encode(value)
33+
case .integer(let value):
34+
try container.encode(value)
35+
case .double(let value):
36+
try container.encode(value)
37+
case .bool(let value):
38+
try container.encode(value)
39+
}
40+
}
41+
42+
public func asString() -> String? {
43+
if case .string(let value) = self {
44+
return value
45+
}
46+
return nil
47+
}
48+
49+
public func asBoolean() -> Bool? {
50+
if case .bool(let value) = self {
51+
return value
52+
}
53+
return nil
54+
}
55+
56+
public func asInteger() -> Int64? {
57+
if case .integer(let value) = self {
58+
return value
59+
}
60+
return nil
61+
}
62+
63+
public func asDouble() -> Double? {
64+
if case .double(let value) = self {
65+
return value
66+
}
67+
return nil
68+
}
69+
70+
public func toValue() -> Value {
71+
switch self {
72+
case .string(let string):
73+
return .string(string)
74+
case .integer(let integer):
75+
return .integer(integer)
76+
case .double(let double):
77+
return .double(double)
78+
case .bool(let bool):
79+
return .boolean(bool)
80+
}
81+
}
82+
}

Sources/GOFeatureFlag/controller/goff_api.swift

+12-4
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ import OFREP
44

55
class GoFeatureFlagAPI {
66
private let networkingService: NetworkingService
7-
private let options: GoFeatureFlagProviderOptions
8-
private let metadata: [String:String] = ["provider": "openfeature-swift"]
7+
private var options: GoFeatureFlagProviderOptions
98

109
init(networkingService: NetworkingService, options: GoFeatureFlagProviderOptions) {
1110
self.networkingService = networkingService
1211
self.options = options
12+
13+
if self.options.exporterMetadata == nil {
14+
self.options.exporterMetadata = [:]
15+
}
16+
if var exporterMetadata = self.options.exporterMetadata {
17+
exporterMetadata["openfeature"] = .bool(true)
18+
exporterMetadata["provider"] = .string("swift")
19+
self.options.exporterMetadata = exporterMetadata
20+
}
1321
}
1422

1523
func postDataCollector(events: [FeatureEvent]?) async throws -> (DataCollectorResponse, HTTPURLResponse) {
@@ -27,8 +35,8 @@ class GoFeatureFlagAPI {
2735
let dataCollectorURL = url.appendingPathComponent("v1/data/collector")
2836
var request = URLRequest(url: dataCollectorURL)
2937
request.httpMethod = "POST"
30-
31-
let requestBody = DataCollectorRequest(meta: metadata, events: events)
38+
39+
let requestBody = DataCollectorRequest(meta: self.options.exporterMetadata, events: events)
3240
let encoder = JSONEncoder()
3341
encoder.outputFormatting = .prettyPrinted
3442
request.httpBody = try encoder.encode(requestBody)

Sources/GOFeatureFlag/model/data_collector_request.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import Foundation
22

33
struct DataCollectorRequest: Codable {
4-
var meta: [String:String]?
4+
var meta: [String:ExporterMetadataValue]?
55
var events: [FeatureEvent]? = []
66

7-
public init(meta: [String:String]? = [:], events: [FeatureEvent]? = []) {
7+
public init(meta: [String:ExporterMetadataValue]? = [:], events: [FeatureEvent]? = []) {
88
self.meta = meta
99
self.events = events
1010
}

Sources/GOFeatureFlag/options.swift

+7
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,24 @@ public struct GoFeatureFlagProviderOptions {
3232
* default: URLSession.shared
3333
*/
3434
public var networkService: NetworkingService?
35+
/**
36+
* (optional) exporter metadata to be sent to the relay proxy data collector to be used for evaluation data events.
37+
* default: empty
38+
*/
39+
public var exporterMetadata: [String:ExporterMetadataValue]? = [:]
3540

3641
public init(
3742
endpoint: String,
3843
pollInterval: TimeInterval = 60,
3944
apiKey: String? = nil,
4045
dataFlushInterval: TimeInterval = 600,
46+
exporterMetadata: [String:ExporterMetadataValue]? = [:],
4147
networkService: NetworkingService? = URLSession.shared) {
4248
self.endpoint = endpoint
4349
self.pollInterval = pollInterval
4450
self.apiKey = apiKey
4551
self.networkService = networkService
4652
self.dataCollectorInterval = dataFlushInterval
53+
self.exporterMetadata = exporterMetadata
4754
}
4855
}

Tests/GOFeatureFlagTests/mock_networking_service.swift

+2
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ public class MockNetworkingService: NetworkingService {
1010
var callCounter = 0
1111
var dataCollectorCallCounter = 0
1212
var dataCollectorEventCounter = 0
13+
var requests: [URLRequest] = []
1314

1415
public init(mockStatus: Int = 200) {
1516
self.mockStatus = mockStatus
1617
}
1718

1819
public func doRequest(for request: URLRequest) async throws -> (Data, URLResponse) {
20+
self.requests.append(request)
1921
self.callCounter+=1
2022
let isDataCollector = request.url?.absoluteString.contains("/v1/data/collector") ?? false
2123
let isBulkEvaluation = request.url?.absoluteString.contains("/ofrep/v1/evaluate/flags") ?? false

Tests/GOFeatureFlagTests/provider_tests.swift

+64-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class GoFeatureFlagProviderTests: XCTestCase {
1717
options: GoFeatureFlagProviderOptions(
1818
endpoint: "https://localhost:1031",
1919
dataFlushInterval: 1,
20+
exporterMetadata: ["version": ExporterMetadataValue.string("1.0.0")],
2021
networkService: mockNetworkService
2122
)
2223
)
@@ -34,11 +35,72 @@ class GoFeatureFlagProviderTests: XCTestCase {
3435

3536
let expectation = self.expectation(description: "Waiting for delay")
3637

37-
DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { expectation.fulfill() }
38-
await fulfillment(of: [expectation], timeout: 2.0)
38+
DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { expectation.fulfill() }
39+
await fulfillment(of: [expectation], timeout: 3.0)
3940

4041
XCTAssertEqual(1, mockNetworkService.dataCollectorCallCounter)
4142
XCTAssertEqual(6, mockNetworkService.dataCollectorEventCounter)
43+
44+
do {
45+
let httpBodyCollector = mockNetworkService.requests[mockNetworkService.requests.count - 1].httpBody!
46+
// print httpBodyCollector
47+
print(String(data: httpBodyCollector, encoding: .utf8)!)
48+
let json = try JSONSerialization.jsonObject(with: httpBodyCollector, options: []) as? [String: Any]
49+
guard let jsonDict = json else {
50+
XCTFail("Could not deserialize JSON")
51+
return
52+
}
53+
} catch {
54+
XCTFail("Error deserializing JSON: \(error)")
55+
}
56+
57+
}
58+
59+
func testExporterMetadata() async {
60+
let mockNetworkService = MockNetworkingService(mockStatus: 200)
61+
let provider = GoFeatureFlagProvider(
62+
options: GoFeatureFlagProviderOptions(
63+
endpoint: "https://localhost:1031",
64+
dataFlushInterval: 1,
65+
exporterMetadata: ["version": ExporterMetadataValue.string("1.0.0"),"testInt": ExporterMetadataValue.integer(123), "testDouble": ExporterMetadataValue.double(123.45)],
66+
networkService: mockNetworkService
67+
)
68+
)
69+
let evaluationCtx = MutableContext(targetingKey: "ede04e44-463d-40d1-8fc0-b1d6855578d0")
70+
let api = OpenFeatureAPI()
71+
await api.setProviderAndWait(provider: provider, initialContext: evaluationCtx)
72+
let client = api.getClient()
73+
74+
_ = client.getBooleanDetails(key: "my-flag", defaultValue: false)
75+
_ = client.getBooleanDetails(key: "my-flag", defaultValue: false)
76+
_ = client.getIntegerDetails(key: "int-flag", defaultValue: 1)
77+
_ = client.getDoubleDetails(key: "double-flag", defaultValue: 1.0)
78+
_ = client.getStringDetails(key: "string-flag", defaultValue: "default")
79+
_ = client.getObjectDetails(key: "object-flag", defaultValue: Value.null)
80+
81+
let expectation = self.expectation(description: "Waiting for delay")
82+
83+
DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { expectation.fulfill() }
84+
await fulfillment(of: [expectation], timeout: 3.0)
85+
86+
XCTAssertEqual(1, mockNetworkService.dataCollectorCallCounter)
87+
XCTAssertEqual(6, mockNetworkService.dataCollectorEventCounter)
88+
89+
do {
90+
let httpBodyCollector = mockNetworkService.requests[mockNetworkService.requests.count - 1].httpBody!
91+
let decodedStruct = try JSONDecoder().decode(DataCollectorRequest.self, from: httpBodyCollector)
92+
let want = [
93+
"version": ExporterMetadataValue.string("1.0.0"),
94+
"testDouble": ExporterMetadataValue.double(123.45),
95+
"testInt": ExporterMetadataValue.integer(123),
96+
"openfeature": ExporterMetadataValue.bool(true),
97+
"provider": ExporterMetadataValue.string("swift")
98+
] as? [String: ExporterMetadataValue]
99+
XCTAssertEqual(want, decodedStruct.meta)
100+
} catch {
101+
XCTFail("Error deserializing: \(error)")
102+
}
103+
42104
}
43105

44106
func testProviderMultipleHookCall() async {

0 commit comments

Comments
 (0)