From 0fe8aebc06ccc873fbf5ae4506d958a5d7d38412 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 20 Jan 2025 18:31:06 +0100 Subject: [PATCH 1/4] feat: Exporter Metadata Signed-off-by: Thomas Poignant --- .../controller/exporter_metadata.swift | 82 +++++++++++++++++++ .../GOFeatureFlag/controller/goff_api.swift | 16 +++- .../model/data_collector_request.swift | 4 +- Sources/GOFeatureFlag/options.swift | 7 ++ .../mock_networking_service.swift | 2 + Tests/GOFeatureFlagTests/provider_tests.swift | 66 ++++++++++++++- 6 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 Sources/GOFeatureFlag/controller/exporter_metadata.swift diff --git a/Sources/GOFeatureFlag/controller/exporter_metadata.swift b/Sources/GOFeatureFlag/controller/exporter_metadata.swift new file mode 100644 index 0000000..a92f60c --- /dev/null +++ b/Sources/GOFeatureFlag/controller/exporter_metadata.swift @@ -0,0 +1,82 @@ +import Foundation +import OpenFeature + +// Define a Codable enum that can represent any type of JSON value +public enum ExporterMetadataValue: Codable, Equatable { + case string(String) + case integer(Int64) + case double(Double) + case bool(Bool) + + // Decode the JSON based on its type + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else if let intValue = try? container.decode(Int64.self) { + self = .integer(intValue) + } else if let doubleValue = try? container.decode(Double.self) { + self = .double(doubleValue) + } else if let boolValue = try? container.decode(Bool.self) { + self = .bool(boolValue) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode JSONValue") + } + } + + // Encode the JSON based on its type + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .integer(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + } + } + + public func asString() -> String? { + if case .string(let value) = self { + return value + } + return nil + } + + public func asBoolean() -> Bool? { + if case .bool(let value) = self { + return value + } + return nil + } + + public func asInteger() -> Int64? { + if case .integer(let value) = self { + return value + } + return nil + } + + public func asDouble() -> Double? { + if case .double(let value) = self { + return value + } + return nil + } + + public func toValue() -> Value { + switch self { + case .string(let string): + return .string(string) + case .integer(let integer): + return .integer(integer) + case .double(let double): + return .double(double) + case .bool(let bool): + return .boolean(bool) + } + } +} diff --git a/Sources/GOFeatureFlag/controller/goff_api.swift b/Sources/GOFeatureFlag/controller/goff_api.swift index 0facb01..12ebe37 100644 --- a/Sources/GOFeatureFlag/controller/goff_api.swift +++ b/Sources/GOFeatureFlag/controller/goff_api.swift @@ -4,12 +4,20 @@ import OFREP class GoFeatureFlagAPI { private let networkingService: NetworkingService - private let options: GoFeatureFlagProviderOptions - private let metadata: [String:String] = ["provider": "openfeature-swift"] + private var options: GoFeatureFlagProviderOptions init(networkingService: NetworkingService, options: GoFeatureFlagProviderOptions) { self.networkingService = networkingService self.options = options + + if self.options.exporterMetadata == nil { + self.options.exporterMetadata = [:] + } + if var exporterMetadata = self.options.exporterMetadata { + exporterMetadata["openfeature"] = .bool(true) + exporterMetadata["provider"] = .string("swift") + self.options.exporterMetadata = exporterMetadata + } } func postDataCollector(events: [FeatureEvent]?) async throws -> (DataCollectorResponse, HTTPURLResponse) { @@ -27,8 +35,8 @@ class GoFeatureFlagAPI { let dataCollectorURL = url.appendingPathComponent("v1/data/collector") var request = URLRequest(url: dataCollectorURL) request.httpMethod = "POST" - - let requestBody = DataCollectorRequest(meta: metadata, events: events) + + let requestBody = DataCollectorRequest(meta: self.options.exporterMetadata, events: events) let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted request.httpBody = try encoder.encode(requestBody) diff --git a/Sources/GOFeatureFlag/model/data_collector_request.swift b/Sources/GOFeatureFlag/model/data_collector_request.swift index 8fe9a72..e637eef 100644 --- a/Sources/GOFeatureFlag/model/data_collector_request.swift +++ b/Sources/GOFeatureFlag/model/data_collector_request.swift @@ -1,10 +1,10 @@ import Foundation struct DataCollectorRequest: Codable { - var meta: [String:String]? + var meta: [String:ExporterMetadataValue]? var events: [FeatureEvent]? = [] - public init(meta: [String:String]? = [:], events: [FeatureEvent]? = []) { + public init(meta: [String:ExporterMetadataValue]? = [:], events: [FeatureEvent]? = []) { self.meta = meta self.events = events } diff --git a/Sources/GOFeatureFlag/options.swift b/Sources/GOFeatureFlag/options.swift index 62221fb..e271353 100644 --- a/Sources/GOFeatureFlag/options.swift +++ b/Sources/GOFeatureFlag/options.swift @@ -32,17 +32,24 @@ public struct GoFeatureFlagProviderOptions { * default: URLSession.shared */ public var networkService: NetworkingService? + /** + * (optional) exporter metadata to be sent to the relay proxy data collector to be used for evaluation data events. + * default: empty + */ + public var exporterMetadata: [String:ExporterMetadataValue]? = [:] public init( endpoint: String, pollInterval: TimeInterval = 60, apiKey: String? = nil, dataFlushInterval: TimeInterval = 600, + exporterMetadata: [String:ExporterMetadataValue]? = [:], networkService: NetworkingService? = URLSession.shared) { self.endpoint = endpoint self.pollInterval = pollInterval self.apiKey = apiKey self.networkService = networkService self.dataCollectorInterval = dataFlushInterval + self.exporterMetadata = exporterMetadata } } diff --git a/Tests/GOFeatureFlagTests/mock_networking_service.swift b/Tests/GOFeatureFlagTests/mock_networking_service.swift index 0e0cf9c..86ee786 100644 --- a/Tests/GOFeatureFlagTests/mock_networking_service.swift +++ b/Tests/GOFeatureFlagTests/mock_networking_service.swift @@ -10,12 +10,14 @@ public class MockNetworkingService: NetworkingService { var callCounter = 0 var dataCollectorCallCounter = 0 var dataCollectorEventCounter = 0 + var requests: [URLRequest] = [] public init(mockStatus: Int = 200) { self.mockStatus = mockStatus } public func doRequest(for request: URLRequest) async throws -> (Data, URLResponse) { + self.requests.append(request) self.callCounter+=1 let isDataCollector = request.url?.absoluteString.contains("/v1/data/collector") ?? false let isBulkEvaluation = request.url?.absoluteString.contains("/ofrep/v1/evaluate/flags") ?? false diff --git a/Tests/GOFeatureFlagTests/provider_tests.swift b/Tests/GOFeatureFlagTests/provider_tests.swift index 5fb250c..8cb95dd 100644 --- a/Tests/GOFeatureFlagTests/provider_tests.swift +++ b/Tests/GOFeatureFlagTests/provider_tests.swift @@ -17,6 +17,7 @@ class GoFeatureFlagProviderTests: XCTestCase { options: GoFeatureFlagProviderOptions( endpoint: "https://localhost:1031", dataFlushInterval: 1, + exporterMetadata: ["version": ExporterMetadataValue.string("1.0.0")], networkService: mockNetworkService ) ) @@ -34,11 +35,72 @@ class GoFeatureFlagProviderTests: XCTestCase { let expectation = self.expectation(description: "Waiting for delay") - DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { expectation.fulfill() } - await fulfillment(of: [expectation], timeout: 2.0) + DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { expectation.fulfill() } + await fulfillment(of: [expectation], timeout: 3.0) XCTAssertEqual(1, mockNetworkService.dataCollectorCallCounter) XCTAssertEqual(6, mockNetworkService.dataCollectorEventCounter) + + do { + let httpBodyCollector = mockNetworkService.requests[mockNetworkService.requests.count - 1].httpBody! + // print httpBodyCollector + print(String(data: httpBodyCollector, encoding: .utf8)!) + let json = try JSONSerialization.jsonObject(with: httpBodyCollector, options: []) as? [String: Any] + guard let jsonDict = json else { + XCTFail("Could not deserialize JSON") + return + } + } catch { + XCTFail("Error deserializing JSON: \(error)") + } + + } + + func testExporterMetadata() async { + let mockNetworkService = MockNetworkingService(mockStatus: 200) + let provider = GoFeatureFlagProvider( + options: GoFeatureFlagProviderOptions( + endpoint: "https://localhost:1031", + dataFlushInterval: 1, + exporterMetadata: ["version": ExporterMetadataValue.string("1.0.0"),"testInt": ExporterMetadataValue.integer(123), "testDouble": ExporterMetadataValue.double(123.45)], + networkService: mockNetworkService + ) + ) + let evaluationCtx = MutableContext(targetingKey: "ede04e44-463d-40d1-8fc0-b1d6855578d0") + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: evaluationCtx) + let client = api.getClient() + + _ = client.getBooleanDetails(key: "my-flag", defaultValue: false) + _ = client.getBooleanDetails(key: "my-flag", defaultValue: false) + _ = client.getIntegerDetails(key: "int-flag", defaultValue: 1) + _ = client.getDoubleDetails(key: "double-flag", defaultValue: 1.0) + _ = client.getStringDetails(key: "string-flag", defaultValue: "default") + _ = client.getObjectDetails(key: "object-flag", defaultValue: Value.null) + + let expectation = self.expectation(description: "Waiting for delay") + + DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { expectation.fulfill() } + await fulfillment(of: [expectation], timeout: 3.0) + + XCTAssertEqual(1, mockNetworkService.dataCollectorCallCounter) + XCTAssertEqual(6, mockNetworkService.dataCollectorEventCounter) + + do { + let httpBodyCollector = mockNetworkService.requests[mockNetworkService.requests.count - 1].httpBody! + let decodedStruct = try JSONDecoder().decode(DataCollectorRequest.self, from: httpBodyCollector) + let want = [ + "version": ExporterMetadataValue.string("1.0.0"), + "testDouble": ExporterMetadataValue.double(123.45), + "testInt": ExporterMetadataValue.integer(123), + "openfeature": ExporterMetadataValue.bool(true), + "provider": ExporterMetadataValue.string("swift") + ] as? [String: ExporterMetadataValue] + XCTAssertEqual(want, decodedStruct.meta) + } catch { + XCTFail("Error deserializing: \(error)") + } + } func testProviderMultipleHookCall() async { From 6786c207aef53b0ecbce7e5869a42ece94d0a705 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 20 Jan 2025 18:32:53 +0100 Subject: [PATCH 2/4] remove unused code Signed-off-by: Thomas Poignant --- Tests/GOFeatureFlagTests/provider_tests.swift | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/Tests/GOFeatureFlagTests/provider_tests.swift b/Tests/GOFeatureFlagTests/provider_tests.swift index 8cb95dd..613b605 100644 --- a/Tests/GOFeatureFlagTests/provider_tests.swift +++ b/Tests/GOFeatureFlagTests/provider_tests.swift @@ -17,7 +17,6 @@ class GoFeatureFlagProviderTests: XCTestCase { options: GoFeatureFlagProviderOptions( endpoint: "https://localhost:1031", dataFlushInterval: 1, - exporterMetadata: ["version": ExporterMetadataValue.string("1.0.0")], networkService: mockNetworkService ) ) @@ -40,20 +39,6 @@ class GoFeatureFlagProviderTests: XCTestCase { XCTAssertEqual(1, mockNetworkService.dataCollectorCallCounter) XCTAssertEqual(6, mockNetworkService.dataCollectorEventCounter) - - do { - let httpBodyCollector = mockNetworkService.requests[mockNetworkService.requests.count - 1].httpBody! - // print httpBodyCollector - print(String(data: httpBodyCollector, encoding: .utf8)!) - let json = try JSONSerialization.jsonObject(with: httpBodyCollector, options: []) as? [String: Any] - guard let jsonDict = json else { - XCTFail("Could not deserialize JSON") - return - } - } catch { - XCTFail("Error deserializing JSON: \(error)") - } - } func testExporterMetadata() async { From 2248e58cbced7acf32db80c003b691ba934b0693 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 21 Jan 2025 13:50:34 +0100 Subject: [PATCH 3/4] adding test with exporter metadata nil Signed-off-by: Thomas Poignant --- Tests/GOFeatureFlagTests/provider_tests.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/Tests/GOFeatureFlagTests/provider_tests.swift b/Tests/GOFeatureFlagTests/provider_tests.swift index 613b605..12dc151 100644 --- a/Tests/GOFeatureFlagTests/provider_tests.swift +++ b/Tests/GOFeatureFlagTests/provider_tests.swift @@ -85,7 +85,49 @@ class GoFeatureFlagProviderTests: XCTestCase { } catch { XCTFail("Error deserializing: \(error)") } + } + + func testExporterMetadataNil() async { + let mockNetworkService = MockNetworkingService(mockStatus: 200) + let provider = GoFeatureFlagProvider( + options: GoFeatureFlagProviderOptions( + endpoint: "https://localhost:1031", + dataFlushInterval: 1, + exporterMetadata: nil, + networkService: mockNetworkService + ) + ) + let evaluationCtx = MutableContext(targetingKey: "ede04e44-463d-40d1-8fc0-b1d6855578d0") + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: evaluationCtx) + let client = api.getClient() + + _ = client.getBooleanDetails(key: "my-flag", defaultValue: false) + _ = client.getBooleanDetails(key: "my-flag", defaultValue: false) + _ = client.getIntegerDetails(key: "int-flag", defaultValue: 1) + _ = client.getDoubleDetails(key: "double-flag", defaultValue: 1.0) + _ = client.getStringDetails(key: "string-flag", defaultValue: "default") + _ = client.getObjectDetails(key: "object-flag", defaultValue: Value.null) + + let expectation = self.expectation(description: "Waiting for delay") + + DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { expectation.fulfill() } + await fulfillment(of: [expectation], timeout: 3.0) + + XCTAssertEqual(1, mockNetworkService.dataCollectorCallCounter) + XCTAssertEqual(6, mockNetworkService.dataCollectorEventCounter) + do { + let httpBodyCollector = mockNetworkService.requests[mockNetworkService.requests.count - 1].httpBody! + let decodedStruct = try JSONDecoder().decode(DataCollectorRequest.self, from: httpBodyCollector) + let want = [ + "openfeature": ExporterMetadataValue.bool(true), + "provider": ExporterMetadataValue.string("swift") + ] as? [String: ExporterMetadataValue] + XCTAssertEqual(want, decodedStruct.meta) + } catch { + XCTFail("Error deserializing: \(error)") + } } func testProviderMultipleHookCall() async { From cdfcff6ed65db00532e4f7b4f6372824c5e22025 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 21 Jan 2025 13:52:10 +0100 Subject: [PATCH 4/4] remove unused code Signed-off-by: Thomas Poignant --- .../controller/exporter_metadata.swift | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/Sources/GOFeatureFlag/controller/exporter_metadata.swift b/Sources/GOFeatureFlag/controller/exporter_metadata.swift index a92f60c..a4f4520 100644 --- a/Sources/GOFeatureFlag/controller/exporter_metadata.swift +++ b/Sources/GOFeatureFlag/controller/exporter_metadata.swift @@ -38,45 +38,4 @@ public enum ExporterMetadataValue: Codable, Equatable { try container.encode(value) } } - - public func asString() -> String? { - if case .string(let value) = self { - return value - } - return nil - } - - public func asBoolean() -> Bool? { - if case .bool(let value) = self { - return value - } - return nil - } - - public func asInteger() -> Int64? { - if case .integer(let value) = self { - return value - } - return nil - } - - public func asDouble() -> Double? { - if case .double(let value) = self { - return value - } - return nil - } - - public func toValue() -> Value { - switch self { - case .string(let string): - return .string(string) - case .integer(let integer): - return .integer(integer) - case .double(let double): - return .double(double) - case .bool(let bool): - return .boolean(bool) - } - } }