diff --git a/Sources/GOFeatureFlag/controller/exporter_metadata.swift b/Sources/GOFeatureFlag/controller/exporter_metadata.swift new file mode 100644 index 0000000..a4f4520 --- /dev/null +++ b/Sources/GOFeatureFlag/controller/exporter_metadata.swift @@ -0,0 +1,41 @@ +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) + } + } +} 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..12dc151 100644 --- a/Tests/GOFeatureFlagTests/provider_tests.swift +++ b/Tests/GOFeatureFlagTests/provider_tests.swift @@ -34,12 +34,101 @@ 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) } + + 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 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 { let mockNetworkService = MockNetworkingService(mockStatus: 200)