diff --git a/.github/workflows/swift.yaml b/.github/workflows/swift.yaml index 5e5f048..0edbbbc 100644 --- a/.github/workflows/swift.yaml +++ b/.github/workflows/swift.yaml @@ -21,11 +21,14 @@ jobs: run: swift build - name: Run tests run: swift test --enable-code-coverage + - name: Prepare Code Coverage + run: xcrun llvm-cov export -format="lcov" .build/debug/go-feature-flag-providerPackageTests.xctest/Contents/MacOS/go-feature-flag-providerPackageTests -instr-profile .build/debug/codecov/default.profdata > info.lcov - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} + files: info.lcov env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} lint: diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..419822c --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,75 @@ +# By default, SwiftLint uses a set of sensible default rules you can adjust: +disabled_rules: # rule identifiers turned on by default to exclude from running + - colon + - comma + - control_statement +opt_in_rules: # some rules are turned off by default, so you need to opt-in + - empty_count # find all the available rules by running: `swiftlint rules` + +# Alternatively, specify all rules explicitly by uncommenting this option: +# only_rules: # delete `disabled_rules` & `opt_in_rules` if using this +# - empty_parameters +# - vertical_whitespace + +analyzer_rules: # rules run by `swiftlint analyze` + - explicit_self + +# Case-sensitive paths to include during linting. Directory paths supplied on the +# command line will be ignored. +included: + - Sources +excluded: # case-sensitive paths to ignore during linting. Takes precedence over `included` + - Carthage + - Pods + - Sources/ExcludedFolder + - Sources/ExcludedFile.swift + - Sources/*/ExcludedFile.swift # exclude files with a wildcard + +# If true, SwiftLint will not fail if no lintable files are found. +allow_zero_lintable_files: false + +# If true, SwiftLint will treat all warnings as errors. +strict: false + +# The path to a baseline file, which will be used to filter out detected violations. +baseline: Baseline.json + +# The path to save detected violations to as a new baseline. +write_baseline: Baseline.json + +# configurable rules can be customized from this configuration file +# binary rules can set their severity level +force_cast: warning # implicitly +force_try: + severity: warning # explicitly +# rules that have both warning and error levels, can set just the warning level +# implicitly +line_length: 120 +# they can set both implicitly with an array +type_body_length: + - 300 # warning + - 400 # error +# or they can set both explicitly +file_length: + warning: 500 + error: 1200 +# naming rules can set warnings/errors for min_length and max_length +# additionally they can set excluded names +type_name: + min_length: 4 # only warning + max_length: # warning and error + warning: 40 + error: 50 + excluded: iPhone # excluded via string + allowed_symbols: ["_"] # these are allowed in type names +identifier_name: + min_length: # only min_length + error: 4 # only error + excluded: # excluded via string array + - id + - URL + - url + - GlobalAPIKey + - key + - dto +reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, codeclimate, junit, html, emoji, sonarqube, markdown, github-actions-logging, summary) \ No newline at end of file diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..38df451 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "OpenFeature", + "repositoryURL": "https://github.com/open-feature/swift-sdk.git", + "state": { + "branch": null, + "revision": "02b033c954766e86d5706bfc8ee5248244c11e77", + "version": "0.1.0" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5900cd8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "go-feature-flag-provider", + platforms: [ + .iOS(.v14), + .macOS(.v12) + ], + products: [ + .library( + name: "go-feature-flag-provider", + targets: ["go-feature-flag-provider"]) + ], + dependencies: [ + .package(url: "https://github.com/open-feature/swift-sdk.git", from: "0.1.0") + ], + targets: [ + .target( + name: "go-feature-flag-provider", + dependencies: [ + .product(name: "OpenFeature", package: "swift-sdk") + ], + plugins:[] + ), + .testTarget( + name: "go-feature-flag-providerTests", + dependencies: [ + "go-feature-flag-provider" + ] + ) + ] +) diff --git a/Sources/go-feature-flag-provider/controller/ofrep_api.swift b/Sources/go-feature-flag-provider/controller/ofrep_api.swift new file mode 100644 index 0000000..95abf5a --- /dev/null +++ b/Sources/go-feature-flag-provider/controller/ofrep_api.swift @@ -0,0 +1,79 @@ +import Foundation +import OpenFeature + +class OfrepAPI { + private let networkingService: NetworkingService + private var etag: String = "" + private let options: GoFeatureFlagProviderOptions + + init(networkingService: NetworkingService, options: GoFeatureFlagProviderOptions) { + self.networkingService = networkingService + self.options = options + } + + func postBulkEvaluateFlags(context: EvaluationContext?) async throws -> (OfrepEvaluationResponse, HTTPURLResponse) { + guard let context = context else { + throw OpenFeatureError.invalidContextError + } + try validateContext(context: context) + + guard let url = URL(string: options.endpoint) else { + throw InvalidOptions.invalidEndpoint(message: "endpoint [" + options.endpoint + "] is not valid") + } + let ofrepURL = url.appendingPathComponent("ofrep/v1/evaluate/flags") + var request = URLRequest(url: ofrepURL) + request.httpMethod = "POST" + request.httpBody = try EvaluationRequest.convertEvaluationContext(context: context).asJSONData() + request.setValue( + "application/json", + forHTTPHeaderField: "Content-Type" + ) + + if etag != "" { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + + let (data, response) = try await networkingService.doRequest(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw OfrepError.httpResponseCastError + } + + if httpResponse.statusCode == 401 { + throw OfrepError.apiUnauthorizedError(response: httpResponse) + } + if httpResponse.statusCode == 403 { + throw OfrepError.forbiddenError(response: httpResponse) + } + if httpResponse.statusCode == 429 { + throw OfrepError.apiTooManyRequestsError(response: httpResponse) + } + if httpResponse.statusCode > 400 { + throw OfrepError.unexpectedResponseError(response: httpResponse) + } + if httpResponse.statusCode == 304 { + return (OfrepEvaluationResponse(flags: [], errorCode: nil, errorDetails: nil), httpResponse) + } + + // Store ETag to use it in the next request + if let etagHeaderValue = httpResponse.value(forHTTPHeaderField: "ETag") { + if etagHeaderValue != "" && httpResponse.statusCode == 200 { + etag = etagHeaderValue + } + } + + do { + let dto = try JSONDecoder().decode(EvaluationResponseDTO.self, from: data) + let evaluationResponse = OfrepEvaluationResponse.fromEvaluationResponseDTO(dto: dto) + return (evaluationResponse, httpResponse) + } catch { + throw OfrepError.unmarshallError(error: error) + } + } + + private func validateContext(context: EvaluationContext) throws { + let targetingKey = context.getTargetingKey() + if targetingKey.isEmpty { + throw OpenFeatureError.targetingKeyMissingError + } + } +} diff --git a/Sources/go-feature-flag-provider/exception/ofrep_exceptions.swift b/Sources/go-feature-flag-provider/exception/ofrep_exceptions.swift new file mode 100644 index 0000000..22955a2 --- /dev/null +++ b/Sources/go-feature-flag-provider/exception/ofrep_exceptions.swift @@ -0,0 +1,18 @@ +// +// File.swift +// +// +// Created by thomas.poignant on 27/06/2024. +// + +import Foundation + +enum OfrepError: Error { + case httpResponseCastError + case unmarshallError(error: Error) + case apiUnauthorizedError(response: HTTPURLResponse) + case forbiddenError(response: HTTPURLResponse) + case apiTooManyRequestsError(response: HTTPURLResponse) + case unexpectedResponseError(response: HTTPURLResponse) + case waitingRetryLater(date: Date?) +} diff --git a/Sources/go-feature-flag-provider/exception/option_exceptions.swift b/Sources/go-feature-flag-provider/exception/option_exceptions.swift new file mode 100644 index 0000000..57fb93f --- /dev/null +++ b/Sources/go-feature-flag-provider/exception/option_exceptions.swift @@ -0,0 +1,13 @@ +// +// File.swift +// +// +// Created by thomas.poignant on 27/06/2024. +// + +import Foundation + +enum InvalidOptions: Error { + case invalidEndpoint(message: String) + +} diff --git a/Sources/go-feature-flag-provider/go_feature_flag_provider.swift b/Sources/go-feature-flag-provider/go_feature_flag_provider.swift new file mode 100644 index 0000000..fc2cd3f --- /dev/null +++ b/Sources/go-feature-flag-provider/go_feature_flag_provider.swift @@ -0,0 +1,290 @@ +import OpenFeature +import Foundation +import Combine + +struct Metadata: ProviderMetadata { + var name: String? = "GO Feature Flag provider" +} + +final class GoFeatureFlagProvider: FeatureProvider { + private let eventHandler = EventHandler(ProviderEvent.notReady) + private var evaluationContext: OpenFeature.EvaluationContext? + + private var options: GoFeatureFlagProviderOptions + private let ofrepAPI: OfrepAPI + + private var inMemoryCache: [String: OfrepEvaluationResponseFlag] = [:] + private var apiRetryAfter: Date? + private var timer: DispatchSourceTimer? + + init(options: GoFeatureFlagProviderOptions) { + self.options = options + + // Define network service to use + var networkService: NetworkingService = URLSession.shared + if let netSer = self.options.networkService { + networkService = netSer + } + self.ofrepAPI = OfrepAPI(networkingService: networkService, options: self.options) + } + + func observe() -> AnyPublisher { + return eventHandler.observe() + } + + var hooks: [any Hook] = [] + var metadata: ProviderMetadata = Metadata() + + func initialize(initialContext: (any OpenFeature.EvaluationContext)?) { + self.evaluationContext = initialContext + Task { + do { + let status = try await self.evaluateFlags(context: self.evaluationContext) + if self.options.pollInterval > 0 { + self.startPolling(pollInterval: self.options.pollInterval) + } + + if status == .successWithChanges { + self.eventHandler.send(.ready) + return + } + self.eventHandler.send(.error) + } catch { + // TODO: Should be FATAL here + self.eventHandler.send(.error) + } + } + } + + func onContextSet(oldContext: (any OpenFeature.EvaluationContext)?, + newContext: any OpenFeature.EvaluationContext) { + self.eventHandler.send(.stale) + self.evaluationContext = newContext + Task { + do { + let status = try await self.evaluateFlags(context: newContext) + if(status == .successWithChanges || status == .successNoChanges ) { + self.eventHandler.send(.ready) + } + } catch let error as OfrepError { + switch error { + case .apiTooManyRequestsError: + return // we want to stay stale in that case so we ignore the error. + default: + throw error + } + } catch { + self.eventHandler.send(.error) + } + } + } + + func getBooleanEvaluation(key: String, defaultValue: Bool, + context: EvaluationContext?) throws -> ProviderEvaluation { + let flagCached = try genericEvaluation(key: key) + guard let value = flagCached.value?.asBoolean() else { + throw OpenFeatureError.typeMismatchError + } + return ProviderEvaluation( + value: value, + variant: flagCached.variant, + reason: flagCached.reason) + } + + private func genericEvaluation(key: String) throws -> OfrepEvaluationResponseFlag { + guard let flagCached = self.inMemoryCache[key] else { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + if flagCached.isError() { + switch flagCached.errorCode { + case .flagNotFound: + throw OpenFeatureError.flagNotFoundError(key: key) + case .invalidContext: + throw OpenFeatureError.invalidContextError + case .parseError: + throw OpenFeatureError.parseError(message: flagCached.errorDetails ?? "parse error") + case .providerNotReady: + throw OpenFeatureError.providerNotReadyError + case .targetingKeyMissing: + throw OpenFeatureError.targetingKeyMissingError + case .typeMismatch: + throw OpenFeatureError.typeMismatchError + default: + throw OpenFeatureError.generalError(message: flagCached.errorDetails ?? "general error") + } + } + + return flagCached + } + + func getStringEvaluation(key: String, defaultValue: String, + context: EvaluationContext?) throws -> ProviderEvaluation { + let flagCached = try genericEvaluation(key: key) + guard let value = flagCached.value?.asString() else { + throw OpenFeatureError.typeMismatchError + } + return ProviderEvaluation( + value: value, + variant: flagCached.variant, + reason: flagCached.reason) + } + + func getIntegerEvaluation(key: String, defaultValue: Int64, + context: EvaluationContext?) throws -> ProviderEvaluation { + let flagCached = try genericEvaluation(key: key) + guard let value = flagCached.value?.asInteger() else { + throw OpenFeatureError.typeMismatchError + } + return ProviderEvaluation( + value: Int64(value), + variant: flagCached.variant, + reason: flagCached.reason) + + } + + func getDoubleEvaluation(key: String, defaultValue: Double, + context: EvaluationContext?) throws -> ProviderEvaluation { + let flagCached = try genericEvaluation(key: key) + guard let value = flagCached.value?.asDouble() else { + throw OpenFeatureError.typeMismatchError + } + return ProviderEvaluation( + value: value, + variant: flagCached.variant, + reason: flagCached.reason) + + } + + func getObjectEvaluation(key: String, defaultValue: Value, + context: EvaluationContext?) throws -> ProviderEvaluation { + let flagCached = try genericEvaluation(key: key) + let objValue = flagCached.value?.asObject() + let arrayValue = flagCached.value?.asArray() + + if objValue == nil && arrayValue == nil { + throw OpenFeatureError.typeMismatchError + } + + if objValue != nil { + var convertedValue: [String:Value] = [:] + objValue?.forEach { key, value in + convertedValue[key]=value.toValue() + } + + return ProviderEvaluation( + value: Value.structure(convertedValue), + variant: flagCached.variant, + reason: flagCached.reason) + } + + if arrayValue != nil { + var convertedValue: [Value] = [] + arrayValue?.forEach { item in + convertedValue.append(item.toValue()) + } + return ProviderEvaluation( + value: Value.list(convertedValue), + variant: flagCached.variant, + reason: flagCached.reason) + } + throw OpenFeatureError.generalError(message: "impossible to evaluate the flag because it is not a list or a dictionnary") + } + + private func evaluateFlags(context: EvaluationContext?) async throws -> BulkEvaluationStatus { + if self.apiRetryAfter != nil && self.apiRetryAfter! > Date() { + // we don't want to call the API because we got a 429 + return BulkEvaluationStatus.rateLimited + } + + do { + let (ofrepEvalResponse, httpResp) = try await self.ofrepAPI.postBulkEvaluateFlags(context: context) + + if httpResp.statusCode == 304 { + return BulkEvaluationStatus.successNoChanges + } + + if ofrepEvalResponse.isError() { + switch ofrepEvalResponse.errorCode { + case .providerNotReady: + throw OpenFeatureError.providerNotReadyError + case .parseError: + throw OpenFeatureError.parseError(message: ofrepEvalResponse.errorDetails ?? "impossible to parse") + case .targetingKeyMissing: + throw OpenFeatureError.targetingKeyMissingError + case .invalidContext: + throw OpenFeatureError.invalidContextError + default: + throw OpenFeatureError.generalError(message: ofrepEvalResponse.errorDetails ?? "") + } + } + + var inMemoryCacheNew: [String:OfrepEvaluationResponseFlag] = [:] + for flag in ofrepEvalResponse.flags { + if let key = flag.key { + inMemoryCacheNew[key] = flag + } + } + self.inMemoryCache = inMemoryCacheNew + return BulkEvaluationStatus.successWithChanges + } catch let error as OfrepError { + switch error { + case .apiTooManyRequestsError(let response): + self.apiRetryAfter = getRetryAfterDate(from: response.allHeaderFields) + throw error + default: + throw error + } + } catch { + throw error + } + } + + private func getRetryAfterDate(from headers: [AnyHashable: Any]) -> Date? { + // Retrieve the Retry-After value from headers + guard let retryAfterValue = headers["Retry-After"] as? String else { + return nil + } + + // Try to parse Retry-After as an interval in seconds + if let retryAfterInterval = TimeInterval(retryAfterValue) { + return Date().addingTimeInterval(retryAfterInterval) + } + + // Try to parse Retry-After as an HTTP-date + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "E, dd MMM yyyy HH:mm:ss z" // Common HTTP-date format + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + return dateFormatter.date(from: retryAfterValue) + } + + func startPolling(pollInterval: TimeInterval) { + timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global()) + timer?.schedule(deadline: .now(), repeating: pollInterval, leeway: .milliseconds(100)) + timer?.setEventHandler { [weak self] in + guard let weakSelf = self else { return } + Task { + do { + let status = try await weakSelf.evaluateFlags(context: weakSelf.evaluationContext) + if status == .successWithChanges { + weakSelf.eventHandler.send(.configurationChanged) + } + } catch let error as OfrepError { + switch error { + case .apiTooManyRequestsError: + weakSelf.eventHandler.send(.stale) + throw error + default: + weakSelf.eventHandler.send(.error) + } + } catch { + weakSelf.eventHandler.send(.error) + } + } + } + + timer?.resume() + } +} diff --git a/Sources/go-feature-flag-provider/model/bulk_evaluation_response.swift b/Sources/go-feature-flag-provider/model/bulk_evaluation_response.swift new file mode 100644 index 0000000..04f81a9 --- /dev/null +++ b/Sources/go-feature-flag-provider/model/bulk_evaluation_response.swift @@ -0,0 +1,90 @@ +import OpenFeature + +struct EvaluationResponseDTO: Codable { + var flags: [EvaluationResponseFlagDTO]? + let errorCode: String? + let errorDetails: String? +} + +struct EvaluationResponseFlagDTO: Codable { + let value: JSONValue? + let key: String? + let reason: String? + let variant: String? + let errorCode: String? + let errorDetails: String? +// let metadata: [String:Value]? +} + +struct OfrepEvaluationResponse { + let flags: [OfrepEvaluationResponseFlag] + let errorCode: ErrorCode? + let errorDetails: String? + + func isError() -> Bool { + return errorCode != nil + } + + static func fromEvaluationResponseDTO(dto: EvaluationResponseDTO) -> OfrepEvaluationResponse { + var flagsConverted: [OfrepEvaluationResponseFlag] = [] + var errCode: ErrorCode? + let errDetails = dto.errorDetails + + if let flagsDTO = dto.flags { + for flag in flagsDTO { + var errorCode: ErrorCode? + if let erroCodeValue = flag.errorCode { + errorCode = convertErrorCode(code: erroCodeValue) + } + + flagsConverted.append(OfrepEvaluationResponseFlag( + value: flag.value, + key: flag.key, + reason: flag.reason, + variant: flag.variant, + errorCode: errorCode, + errorDetails: flag.errorDetails +// metadata: flag.metadata + )) + } + } + + if let errorCode = dto.errorCode { + errCode = convertErrorCode(code: errorCode) + } + + return OfrepEvaluationResponse(flags: flagsConverted, errorCode: errCode, errorDetails: errDetails) + } + + static func convertErrorCode(code: String) -> ErrorCode { + switch code { + case "PROVIDER_NOT_READY": + return ErrorCode.providerNotReady + case "FLAG_NOT_FOUND": + return ErrorCode.flagNotFound + case "PARSE_ERROR": + return ErrorCode.parseError + case "TYPE_MISMATCH": + return ErrorCode.typeMismatch + case "TARGETING_KEY_MISSING": + return ErrorCode.targetingKeyMissing + case "INVALID_CONTEXT": + return ErrorCode.invalidContext + default: + return ErrorCode.general + } + } +} + +struct OfrepEvaluationResponseFlag { + let value: JSONValue? + let key: String? + let reason: String? + let variant: String? + let errorCode: ErrorCode? + let errorDetails: String? + + func isError() -> Bool { + return errorCode != nil + } +} diff --git a/Sources/go-feature-flag-provider/model/bulk_evaluation_status.swift b/Sources/go-feature-flag-provider/model/bulk_evaluation_status.swift new file mode 100644 index 0000000..501c2d7 --- /dev/null +++ b/Sources/go-feature-flag-provider/model/bulk_evaluation_status.swift @@ -0,0 +1,5 @@ +enum BulkEvaluationStatus { + case successNoChanges + case successWithChanges + case rateLimited +} diff --git a/Sources/go-feature-flag-provider/model/bulk_evalutaion_request.swift b/Sources/go-feature-flag-provider/model/bulk_evalutaion_request.swift new file mode 100644 index 0000000..d4477cf --- /dev/null +++ b/Sources/go-feature-flag-provider/model/bulk_evalutaion_request.swift @@ -0,0 +1,40 @@ +import Foundation +import OpenFeature + +struct EvaluationRequest { + var context: [String: AnyHashable?] = [:] + + mutating func setTargetingKey(targetingKey: String) { + context["targetingKey"] = targetingKey + } + + mutating func addContext(toAdd: [String: AnyHashable?]) { + for (key, value) in toAdd { + context[key] = value + } + } + + func asObjectMap() -> [String: AnyHashable?] { + var container: [String: AnyHashable?] = [:] + container["context"] = context + return container + } + + func asJSONData() throws -> Data { + do { + let filteredDictionary = asObjectMap().compactMapValues { $0 } + let jsonData = try JSONSerialization.data(withJSONObject: filteredDictionary, options: []) + return jsonData + } catch { + // TODO: catch the errors and remap them + throw error + } + } + + static func convertEvaluationContext(context: EvaluationContext) -> EvaluationRequest { + var requestBody = EvaluationRequest() + requestBody.setTargetingKey(targetingKey:context.getTargetingKey()) + requestBody.addContext(toAdd:context.asObjectMap()) + return requestBody + } +} diff --git a/Sources/go-feature-flag-provider/model/json_value.swift b/Sources/go-feature-flag-provider/model/json_value.swift new file mode 100644 index 0000000..1bbc59f --- /dev/null +++ b/Sources/go-feature-flag-provider/model/json_value.swift @@ -0,0 +1,119 @@ +import Foundation +import OpenFeature + +// Define a Codable enum that can represent any type of JSON value +enum JSONValue: Codable, Equatable { + case string(String) + case integer(Int64) + case double(Double) + case object([String: JSONValue]) + case array([JSONValue]) + case bool(Bool) + case null + + // Decode the JSON based on its type + 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 if let objectValue = try? container.decode([String: JSONValue].self) { + self = .object(objectValue) + } else if let arrayValue = try? container.decode([JSONValue].self) { + self = .array(arrayValue) + } else if container.decodeNil() { + self = .null + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode JSONValue") + } + } + + // Encode the JSON based on its type + 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 .object(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + func asString() -> String? { + if case .string(let value) = self { + return value + } + return nil + } + + func asBoolean() -> Bool? { + if case .bool(let value) = self { + return value + } + return nil + } + + func asInteger() -> Int64? { + if case .integer(let value) = self { + return value + } + return nil + } + + func asDouble() -> Double? { + if case .double(let value) = self { + return value + } + return nil + } + + func asObject() -> [String:JSONValue]? { + if case .object(let value) = self { + return value + } + return nil + } + + func asArray() -> [JSONValue]? { + if case .array(let value) = self { + return value + } + return nil + } + + 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) + case .object(let object): + let transformedObject = object.mapValues { $0.toValue() } + return .structure(transformedObject) + case .array(let array): + let transformedArray = array.map { $0.toValue() } + return .list(transformedArray) + case .null: + return .null + } + } +} diff --git a/Sources/go-feature-flag-provider/model/options.swift b/Sources/go-feature-flag-provider/model/options.swift new file mode 100644 index 0000000..97ccdb8 --- /dev/null +++ b/Sources/go-feature-flag-provider/model/options.swift @@ -0,0 +1,7 @@ +import Foundation + +struct GoFeatureFlagProviderOptions { + let endpoint: String + var pollInterval: TimeInterval = 30 + var networkService: NetworkingService? = URLSession.shared +} diff --git a/Sources/go-feature-flag-provider/protocol/networking_service.swift b/Sources/go-feature-flag-provider/protocol/networking_service.swift new file mode 100644 index 0000000..7221304 --- /dev/null +++ b/Sources/go-feature-flag-provider/protocol/networking_service.swift @@ -0,0 +1,11 @@ +import Foundation + +protocol NetworkingService { + func doRequest(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: NetworkingService { + func doRequest(for request: URLRequest) async throws -> (Data, URLResponse) { + return try await data(for: request) + } +} diff --git a/Tests/go-feature-flag-providerTests/ofrep_api_mock.swift b/Tests/go-feature-flag-providerTests/ofrep_api_mock.swift new file mode 100644 index 0000000..e516239 --- /dev/null +++ b/Tests/go-feature-flag-providerTests/ofrep_api_mock.swift @@ -0,0 +1,160 @@ +import Foundation +import OpenFeature +@testable import go_feature_flag_provider + +class MockNetworkingService: NetworkingService { + var mockData: Data? + var mockStatus: Int + var mockURLResponse: URLResponse? + var callCounter = 0 + + init(mockData: Data? = nil, mockStatus: Int = 200, mockURLResponse: URLResponse? = nil) { + self.mockData = mockData + if mockData == nil { + self.mockData = defaultResponse.data(using: .utf8) + } + self.mockURLResponse = mockURLResponse + self.mockStatus = mockStatus + } + + func doRequest(for request: URLRequest) async throws -> (Data, URLResponse) { + callCounter+=1 + guard let jsonDictionary = try JSONSerialization.jsonObject(with: request.httpBody!, options: []) as? [String: Any] else { + throw OpenFeatureError.invalidContextError + } + guard let targetingKey = ((jsonDictionary["context"] as! [String:Any])["targetingKey"] as? String) else { + throw OpenFeatureError.targetingKeyMissingError + } + + + var data = mockData ?? Data() + var headers: [String: String]? = nil + if mockStatus == 429 || (targetingKey == "429" && callCounter >= 2){ + headers = ["Retry-After": "120"] + mockStatus = 429 + let response = HTTPURLResponse(url: request.url!, statusCode: mockStatus, httpVersion: nil, headerFields: headers)! + return (data, response) + } + + if mockStatus == 200 { + mockStatus = 200 + headers = ["ETag": "33a64df551425fcc55e4d42a148795d9f25f89d4"] + } + + if targetingKey == "second-context" || (targetingKey == "test-change-config" && callCounter >= 3){ + headers = ["ETag": "differentEtag33a64df551425fcc55e"] + data = secondResponse.data(using: .utf8)! + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: headers)! + return (data, response) + } + + if request.value(forHTTPHeaderField: "If-None-Match") == "33a64df551425fcc55e4d42a148795d9f25f89d4" { + mockStatus = 304 + } + + let response = mockURLResponse ?? HTTPURLResponse(url: request.url!, statusCode: mockStatus, httpVersion: nil, headerFields: headers)! + return (data, response) + } + + private let secondResponse = """ + { + "flags": [ + { + "value": false, + "key": "my-flag", + "reason": "TARGETING_MATCH", + "variant": "variantB", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + } + ] + } + """ + + private let defaultResponse = """ +{ + "flags": [ + { + "value": true, + "key": "my-flag", + "reason": "STATIC", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + }, + { + "value": true, + "key": "bool-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + }, + { + "value": 1234, + "key": "int-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + }, + { + "value": 12.34, + "key": "double-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + }, + { + "value": "1234value", + "key": "string-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + }, + { + "value": {"testValue":{"toto":1234}}, + "key": "object-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + }, + { + "value": [1234, 5678], + "key": "array-flag", + "reason": "TARGETING_MATCH", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + } + ] +} +""" +} diff --git a/Tests/go-feature-flag-providerTests/ofrep_api_tests.swift b/Tests/go-feature-flag-providerTests/ofrep_api_tests.swift new file mode 100644 index 0000000..88d7d82 --- /dev/null +++ b/Tests/go-feature-flag-providerTests/ofrep_api_tests.swift @@ -0,0 +1,335 @@ +import XCTest +import Foundation +import OpenFeature +@testable import go_feature_flag_provider + +class OfrepApiTests: XCTestCase { + var defaultEvaluationContext: MutableContext! + var options = GoFeatureFlagProviderOptions(endpoint: "http://localhost:1031/") + override func setUp() { + super.setUp() + defaultEvaluationContext = MutableContext() + defaultEvaluationContext.setTargetingKey(targetingKey: "ede04e44-463d-40d1-8fc0-b1d6855578d0") + defaultEvaluationContext.add(key: "email", value: Value.string("john.doe@gofeatureflag.org")) + defaultEvaluationContext.add(key: "name", value: Value.string("John Doe")) + defaultEvaluationContext.add(key: "age", value: Value.integer(2)) + defaultEvaluationContext.add(key: "category", value: Value.double(2.2)) + defaultEvaluationContext.add(key: "struct", value: Value.structure(["test" : Value.string("test")])) + defaultEvaluationContext.add(key: "list", value: Value.list([Value.string("test1"), Value.string("test2")])) + } + override func tearDown() { + defaultEvaluationContext = nil + super.tearDown() + } + + func testShouldReturnAValidEvaluationResponse() async throws{ + let mockResponse = """ + { + "flags": [ + { + "key": "badge-class", + "value": "green", + "reason": "DEFAULT", + "variant": "nocolor" + }, + { + "key": "hide-logo", + "value": false, + "reason": "STATIC", + "variant": "var_false" + }, + { + "key": "title-flag", + "value": "GO Feature Flag", + "reason": "DEFAULT", + "variant": "default_title", + "metadata": { + "description": "This flag controls the title of the feature flag", + "title": "Feature Flag Title" + } + } + ] + } + """ + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8)) + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + + do { + let (evalResp, response) = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTAssertFalse(evalResp.isError()) + XCTAssertEqual(response.statusCode, 200, "wrong http status") + XCTAssertEqual(evalResp.flags.count, 3) + XCTAssertEqual(evalResp.errorCode, nil) + XCTAssertEqual(evalResp.errorDetails, nil) + + XCTAssertEqual(evalResp.flags[0].key, "badge-class") + XCTAssertEqual(evalResp.flags[0].value, JSONValue.string("green")) + XCTAssertEqual(evalResp.flags[0].reason, "DEFAULT") + XCTAssertEqual(evalResp.flags[0].variant, "nocolor") + XCTAssertEqual(evalResp.flags[0].errorCode, nil) + XCTAssertEqual(evalResp.flags[0].errorDetails, nil) + + XCTAssertEqual(evalResp.flags[1].key, "hide-logo") + XCTAssertEqual(evalResp.flags[1].value, JSONValue.bool(false)) + XCTAssertEqual(evalResp.flags[1].reason, "STATIC") + XCTAssertEqual(evalResp.flags[1].variant, "var_false") + XCTAssertEqual(evalResp.flags[1].errorCode, nil) + XCTAssertEqual(evalResp.flags[1].errorDetails, nil) + + XCTAssertEqual(evalResp.flags[2].key, "title-flag") + XCTAssertEqual(evalResp.flags[2].value, JSONValue.string("GO Feature Flag")) + XCTAssertEqual(evalResp.flags[2].reason, "DEFAULT") + XCTAssertEqual(evalResp.flags[2].variant, "default_title") + XCTAssertEqual(evalResp.flags[2].errorCode, nil) + XCTAssertEqual(evalResp.flags[2].errorDetails, nil) +// XCTAssertEqual(evalResp.flags[2].metadata?["description"], Value.string("This flag controls the title of the feature flag")) +// XCTAssertEqual(evalResp.flags[2].metadata?["title"], Value.string("Feature Flag Title")) + XCTAssertEqual(response.value(forHTTPHeaderField: "ETag"), "33a64df551425fcc55e4d42a148795d9f25f89d4") + } catch { + XCTFail("exception thrown when doing the evaluation: \(error)") + } + } + + func testShouldThrowAnUnauthorizedError() async throws{ + let mockResponse = "{}" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 401) + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + _ = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTFail("Should throw an exception") + } catch let error as OfrepError { + switch error { + case .apiUnauthorizedError(let response): + XCTAssertNotNil(response) + break + default: + XCTFail("Caught an unexpected OFREP error type: \(error)") + } + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldThrowAForbiddenError() async throws{ + let mockResponse = "{}" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 403) + + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + _ = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTFail("Should throw an exception") + } catch let error as OfrepError { + switch error { + case .forbiddenError(let response): + XCTAssertNotNil(response) + break + default: + XCTFail("Caught an unexpected OFREP error type: \(error)") + } + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldThrowTooManyRequest() async throws{ + let mockResponse = "{}" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 429) + + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + _ = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTFail("Should throw an exception") + } catch let error as OfrepError { + switch error { + case .apiTooManyRequestsError(let response): + XCTAssertNotNil(response) + XCTAssertEqual(response.allHeaderFields["Retry-After"] as! String, "120") + break + default: + XCTFail("Caught an unexpected OFREP error type: \(error)") + } + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldThrowUnexpectedError() async throws{ + let mockResponse = "{}" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 500) + + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + _ = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTFail("Should throw an exception") + } catch let error as OfrepError { + switch error { + case .unexpectedResponseError(let response): + XCTAssertNotNil(response) + break + default: + XCTFail("Caught an unexpected OFREP error type: \(error)") + } + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldReturnaEvaluationResponseInError() async throws{ + let mockResponse = """ +{"errorCode": "INVALID_CONTEXT", "errorDetails":"explanation of the error"} +""" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 400) + + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + let (evalResp, httpResp) = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTAssertTrue(evalResp.isError()) + XCTAssertEqual(evalResp.errorCode, ErrorCode.invalidContext) + XCTAssertEqual(evalResp.errorDetails, "explanation of the error") + XCTAssertEqual(httpResp.statusCode, 400) + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldReturnaEvaluationResponseIfWeReceiveA304() async throws{ + let mockResponse = "" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 304) + + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + let (evalResp, httpResp) = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTAssertFalse(evalResp.isError()) + XCTAssertEqual(httpResp.statusCode, 304) + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldThrowInvalidContextWithNilContext() async throws{ + let mockResponse = "{}" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 500) + + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + _ = try await ofrepAPI.postBulkEvaluateFlags(context: nil) + XCTFail("Should throw an exception") + } catch let error as OpenFeatureError { + switch error { + case .invalidContextError: + break + default: + XCTFail("Caught an unexpected OpenFeatureError error type: \(error)") + } + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldThrowTargetingKeyMissingErrorWithNoTargetingKey() async throws{ + let mockResponse = "{}" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 200) + + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + let ctx = MutableContext() + ctx.add(key: "email", value: Value.string("john.doe@gofeatureflag.org")) + _ = try await ofrepAPI.postBulkEvaluateFlags(context: ctx) + XCTFail("Should throw an exception") + } catch let error as OpenFeatureError { + switch error { + case .targetingKeyMissingError: + break + default: + XCTFail("Caught an unexpected OpenFeatureError error type: \(error)") + } + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldThrowUnmarshallErrorWithInvalidJson() async throws{ + let mockResponse = """ + { + "flags": [ + { + "key": "badge-class", + "value": "", + "reason": "DEFAULT", + "variant": "nocolor" + } + } + } + """ + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 200) + + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + _ = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTFail("Should throw an exception") + } catch let error as OfrepError { + switch error { + case .unmarshallError: + break + default: + XCTFail("Caught an unexpected OpenFeatureError error type: \(error)") + } + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldThrowWithInvalidOptions() async throws{ + let mockResponse = """ + { + "flags": [ + { + "key": "badge-class", + "value": "", + "reason": "DEFAULT", + "variant": "nocolor" + } + ] + } + """ + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 200) + let testOptions = GoFeatureFlagProviderOptions(endpoint: "") + let ofrepAPI = OfrepAPI(networkingService: mockService, options:testOptions) + do { + _ = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTFail("Should throw an exception") + } catch _ as InvalidOptions { + return + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + + func testShouldETagShouldNotMatch() async throws{ + let mockResponse = """ + { + "flags": [ + { + "key": "badge-class", + "value": "green", + "reason": "DEFAULT", + "variant": "nocolor" + } + ] + } + """ + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 200) + let ofrepAPI = OfrepAPI(networkingService: mockService, options:options) + do { + let (_, httpResp) = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTAssertNotNil(httpResp.value(forHTTPHeaderField: "ETag")) + + let (_, httpResp2) = try await ofrepAPI.postBulkEvaluateFlags(context: defaultEvaluationContext) + XCTAssertEqual(httpResp2.statusCode, 304) + + } catch { + XCTFail("Caught an unexpected error type: \(error)") + } + } + +} diff --git a/Tests/go-feature-flag-providerTests/provider_tests.swift b/Tests/go-feature-flag-providerTests/provider_tests.swift new file mode 100644 index 0000000..7bba2fb --- /dev/null +++ b/Tests/go-feature-flag-providerTests/provider_tests.swift @@ -0,0 +1,775 @@ +import XCTest +import Combine +import Foundation +import OpenFeature +@testable import go_feature_flag_provider + +class ProviderTests: XCTestCase { + var defaultEvaluationContext: MutableContext! + var cancellables: Set = [] + + override func setUp() { + super.setUp() + cancellables = [] + defaultEvaluationContext = MutableContext() + defaultEvaluationContext.setTargetingKey(targetingKey: "ede04e44-463d-40d1-8fc0-b1d6855578d0") + defaultEvaluationContext.add(key: "email", value: Value.string("john.doe@gofeatureflag.org")) + defaultEvaluationContext.add(key: "name", value: Value.string("John Doe")) + defaultEvaluationContext.add(key: "age", value: Value.integer(2)) + defaultEvaluationContext.add(key: "category", value: Value.double(2.2)) + defaultEvaluationContext.add(key: "struct", value: Value.structure(["test" : Value.string("test")])) + defaultEvaluationContext.add(key: "list", value: Value.list([Value.string("test1"), Value.string("test2")])) + } + + override func tearDown() { + cancellables = [] + defaultEvaluationContext = nil + super.tearDown() + } + + func testShouldBeInFATALStatusIf401ErrorDuringInitialise() async { + // TODO: PROVIDER_FATAL event does not exist for now, we will test that the provider is in ERROR + // issue open for the fatal state: https://github.com/open-feature/swift-sdk/issues/40 + + let mockResponse = "{}" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 401) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + // TODO: Move to FATAL when the event will be handled by the SDK + if(event != ProviderEvent.error){ + XCTFail("If OFREP API returns a 401 we should receive a FATAL event, received: \(event)") + } + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 3.0) + } + + func testShouldBeInFATALStatusIf403ErrorDuringInitialise() async { + // TODO: PROVIDER_FATAL event does not exist for now, we will test that the provider is in ERROR + // issue open for the fatal state: https://github.com/open-feature/swift-sdk/issues/40 + + let mockResponse = "{}" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 403) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + // TODO: Move to FATAL when the event will be handled by the SDK + if(event != ProviderEvent.error){ + XCTFail("If OFREP API returns a 403 we should receive a FATAL event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3.0) + } + + func testShouldBeInErrorStatusIf429ErrorDuringInitialise() async { + let mockResponse = "{}" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 429) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.error){ + XCTFail("If OFREP API returns a 429 we should receive an ERROR event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3.0) + } + + func testShouldBeInErrorStatusIfErrorTargetingKeyIsMissing() async { + let mockResponse = """ +{ + "errorCode": "TARGETING_KEY_MISSING", + "errorDetails": "Error details about TARGETING_KEY_MISSING" + +} +""" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 400) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.error){ + XCTFail("If OFREP API returns a 400 for TARGETING_KEY_MISSING we should receive an ERROR event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + } + + func testShouldBeInErrorStatusIfErrorInvalidContext() async { + let mockResponse = """ +{ + "errorCode": "INVALID_CONTEXT", + "errorDetails": "Error details about INVALID_CONTEXT" +} +""" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 400) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + + + let expectation = XCTestExpectation(description: "waiting 1st event") + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + _ = api.observe().sink{ event in + if(event != ProviderEvent.error){ + XCTFail("If OFREP API returns a 400 for INVALID_CONTEXT we should receive an ERROR event, received: \(event)") + } + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 3) + } + + func testShouldBeInErrorStatusIfErrorParseError() async { + let mockResponse = """ +{ + "errorCode": "PARSE_ERROR", + "errorDetails": "Error details about PARSE_ERROR" +} +""" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 400) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.error){ + XCTFail("If OFREP API returns a 400 for PARSE_ERROR we should receive an ERROR event, received: \(event)") + } + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 3) + } + + func testShouldReturnAFlagNotFoundErrorIfTheFlagDoesNotExist() async { + let mockService = MockNetworkingService( mockStatus: 200) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getBooleanDetails(key: "non-existant-flag", defaultValue: false) + XCTAssertEqual(details.errorCode, ErrorCode.flagNotFound) + } + + func testShouldReturnEvaluationDetailsIfTheFlagExists() async { + let mockService = MockNetworkingService( mockStatus: 400) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getBooleanDetails(key: "my-flag", defaultValue: false) + XCTAssertEqual(details.errorCode, nil) + XCTAssertEqual(details.value, true) + XCTAssertEqual(details.errorMessage, nil) + XCTAssertEqual(details.flagKey, "my-flag") + XCTAssertEqual(details.reason, "STATIC") + XCTAssertEqual(details.variant, "variantA") + } + + func testShouldReturnParseErrorIfTheAPIReturnTheError() async { + let mockResponse = """ +{ + "flags": [ + { + "value": true, + "key": "my-flag", + "reason": "STATIC", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + }, + { + "key": "my-other-flag", + "errorCode": "PARSE_ERROR", + "errorDetails": "Error details about PARSE_ERROR" + } + ] +} +""" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 400) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getBooleanDetails(key: "my-other-flag", defaultValue: false) + XCTAssertEqual(details.errorCode, ErrorCode.parseError) + XCTAssertEqual(details.value, false) + XCTAssertEqual(details.errorMessage, "Parse error: Error details about PARSE_ERROR") + XCTAssertEqual(details.flagKey, "my-other-flag") + XCTAssertEqual(details.reason, "error") + XCTAssertEqual(details.variant, nil) + } + + + func testShouldSendAContextChangedEventIfContextChanged() async { + let mockService = MockNetworkingService(mockStatus: 200) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + pollInterval: 0, + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expect = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + switch event{ + case ProviderEvent.ready: + expect.fulfill() + default: + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + expect.fulfill() + } + } + await fulfillment(of: [expect], timeout: 3) + + let client = api.getClient() + let details = client.getBooleanDetails(key: "my-flag", defaultValue: false) + XCTAssertEqual(details.errorCode, nil) + XCTAssertEqual(details.value, true) + XCTAssertEqual(details.errorMessage, nil) + XCTAssertEqual(details.flagKey, "my-flag") + XCTAssertEqual(details.reason, "STATIC") + XCTAssertEqual(details.variant, "variantA") + + let newContext = MutableContext() + newContext.setTargetingKey(targetingKey: "second-context") + newContext.add(key: "email", value: Value.string("batman@gofeatureflag.org")) + + let expectation1 = expectation(description: "event 1") + let expectation2 = expectation(description: "event 2") + let expectation3 = expectation(description: "event 3") + var receivedEvents = [ProviderEvent]() + api.observe().sink{ event in + receivedEvents.append(event) + switch receivedEvents.count{ + case 1: + expectation1.fulfill() + case 2: + expectation2.fulfill() + case 3: + expectation3.fulfill() + default: + break + } + + }.store(in: &cancellables) + api.setEvaluationContext(evaluationContext: newContext) + await fulfillment(of:[expectation1, expectation2, expectation3], timeout: 5) + let expectedEvents: [ProviderEvent] = [.ready, .stale, .ready] + XCTAssertEqual(receivedEvents, expectedEvents, "The events were not received in the expected order.") + + let details2 = client.getBooleanDetails(key: "my-flag", defaultValue: false) + XCTAssertEqual(details2.errorCode, nil) + XCTAssertEqual(details2.value, false) + XCTAssertEqual(details2.errorMessage, nil) + XCTAssertEqual(details2.flagKey, "my-flag") + XCTAssertEqual(details2.reason, "TARGETING_MATCH") + XCTAssertEqual(details2.variant, "variantB") + } + + + func testShouldNotTryToCallTheAPIBeforeRetryAfterHeader() async { + let mockService = MockNetworkingService(mockStatus: 200) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + pollInterval: 1, + networkService: mockService) + let provider = GoFeatureFlagProvider(options: options) + let api = OpenFeatureAPI() + + let ctx = MutableContext() + ctx.setTargetingKey(targetingKey: "429") + + await api.setProviderAndWait(provider: provider, initialContext: ctx) + + let expectation1 = expectation(description: "Ready event") + let expectation2 = expectation(description: "Stale event") + var receivedEvents = [ProviderEvent]() + api.observe().sink{ event in + receivedEvents.append(event) + switch receivedEvents.count{ + case 1: + expectation1.fulfill() + case 2: + expectation2.fulfill() + default: + break + } + }.store(in: &cancellables) + await fulfillment(of:[expectation1, expectation2], timeout: 5) + let expectedEvents: [ProviderEvent] = [.ready, .stale] + XCTAssertEqual(receivedEvents, expectedEvents, "The events were not received in the expected order.") + XCTAssertEqual(2, mockService.callCounter, "we should stop calling the API if we got a 429") + } + + func testShouldSendAConfigurationChangedEventWhenNewFlagIsSend() async { + let mockResponse = """ +{ + "flags": [ + { + "value": true, + "key": "my-flag", + "reason": "STATIC", + "variant": "variantA", + "metadata": { + "additionalProp1": true, + "additionalProp2": true, + "additionalProp3": true + } + } + ] +} +""" + let mockService = MockNetworkingService(mockData: mockResponse.data(using: .utf8), mockStatus: 200) + + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + pollInterval: 1, + networkService: mockService) + let provider = GoFeatureFlagProvider(options: options) + let api = OpenFeatureAPI() + + let ctx = MutableContext() + ctx.setTargetingKey(targetingKey: "test-change-config") + + await api.setProviderAndWait(provider: provider, initialContext: ctx) + let client = api.getClient() + + let details = client.getBooleanDetails(key: "my-flag", defaultValue: false) + XCTAssertEqual(details.errorCode, nil) + XCTAssertEqual(details.value, true) + XCTAssertEqual(details.errorMessage, nil) + XCTAssertEqual(details.flagKey, "my-flag") + XCTAssertEqual(details.reason, "STATIC") + XCTAssertEqual(details.variant, "variantA") + + let expectation1 = expectation(description: "Ready event") + let expectation2 = expectation(description: "ConfigurationChanged event") + var receivedEvents = [ProviderEvent]() + api.observe().sink{ event in + receivedEvents.append(event) + switch receivedEvents.count{ + case 1: + expectation1.fulfill() + case 2: + expectation2.fulfill() + default: + break + } + }.store(in: &cancellables) + await fulfillment(of:[expectation1, expectation2], timeout: 7) + let expectedEvents: [ProviderEvent] = [.ready, .configurationChanged] + XCTAssertEqual(receivedEvents, expectedEvents, "The events were not received in the expected order.") + + let details2 = client.getBooleanDetails(key: "my-flag", defaultValue: false) + XCTAssertEqual(details2.errorCode, nil) + XCTAssertEqual(details2.value, false) + XCTAssertEqual(details2.errorMessage, nil) + XCTAssertEqual(details2.flagKey, "my-flag") + XCTAssertEqual(details2.reason, "TARGETING_MATCH") + XCTAssertEqual(details2.variant, "variantB") + } + + func testShouldReturnAValidEvaluationForBool() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getBooleanDetails(key: "bool-flag", defaultValue: false) + XCTAssertEqual(details.errorCode, nil) + XCTAssertEqual(details.errorMessage, nil) + XCTAssertEqual(details.value, true) + XCTAssertEqual(details.flagKey, "bool-flag") + XCTAssertEqual(details.reason, "TARGETING_MATCH") + XCTAssertEqual(details.variant, "variantA") + } + + func testShouldReturnAValidEvaluationForInt() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getIntegerDetails(key: "int-flag", defaultValue: 1) + XCTAssertEqual(details.errorCode, nil) + XCTAssertEqual(details.errorMessage, nil) + XCTAssertEqual(details.value, 1234) + XCTAssertEqual(details.flagKey, "int-flag") + XCTAssertEqual(details.reason, "TARGETING_MATCH") + XCTAssertEqual(details.variant, "variantA") + } + + func testShouldReturnAValidEvaluationForDouble() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getDoubleDetails(key: "double-flag", defaultValue: 1.1) + XCTAssertEqual(details.errorCode, nil) + XCTAssertEqual(details.errorMessage, nil) + XCTAssertEqual(details.value, 12.34) + XCTAssertEqual(details.flagKey, "double-flag") + XCTAssertEqual(details.reason, "TARGETING_MATCH") + XCTAssertEqual(details.variant, "variantA") + } + + func testShouldReturnAValidEvaluationForString() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getStringDetails(key: "string-flag", defaultValue: "1") + XCTAssertEqual(details.errorCode, nil) + XCTAssertEqual(details.errorMessage, nil) + XCTAssertEqual(details.value, "1234value") + XCTAssertEqual(details.flagKey, "string-flag") + XCTAssertEqual(details.reason, "TARGETING_MATCH") + XCTAssertEqual(details.variant, "variantA") + } + + func testShouldReturnAValidEvaluationForArray() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getObjectDetails(key: "array-flag", defaultValue: Value.list([Value.string("1")])) + XCTAssertEqual(details.errorCode, nil) + XCTAssertEqual(details.errorMessage, nil) + XCTAssertEqual(details.value, Value.list([Value.integer(1234),Value.integer(5678)])) + XCTAssertEqual(details.flagKey, "array-flag") + XCTAssertEqual(details.reason, "TARGETING_MATCH") + XCTAssertEqual(details.variant, "variantA") + } + + func testShouldReturnAValidEvaluationForObject() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getObjectDetails(key: "object-flag", defaultValue: Value.list([Value.string("1")])) + XCTAssertEqual(details.errorCode, nil) + XCTAssertEqual(details.errorMessage, nil) + XCTAssertEqual(details.value, Value.structure(["testValue": Value.structure(["toto":Value.integer(1234)])])) + XCTAssertEqual(details.flagKey, "object-flag") + XCTAssertEqual(details.reason, "TARGETING_MATCH") + XCTAssertEqual(details.variant, "variantA") + } + + func testShouldReturnTypeMismatchBool() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getBooleanDetails(key: "object-flag", defaultValue: false) + XCTAssertEqual(details.errorCode, ErrorCode.typeMismatch) + XCTAssertEqual(details.value, false) + XCTAssertEqual(details.flagKey, "object-flag") + } + + func testShouldReturnTypeMismatchString() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getStringDetails(key: "object-flag", defaultValue: "default") + XCTAssertEqual(details.errorCode, ErrorCode.typeMismatch) + XCTAssertEqual(details.value, "default") + XCTAssertEqual(details.flagKey, "object-flag") + } + + func testShouldReturnTypeMismatchInt() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getIntegerDetails(key: "object-flag", defaultValue: 1) + XCTAssertEqual(details.errorCode, ErrorCode.typeMismatch) + XCTAssertEqual(details.value, 1) + XCTAssertEqual(details.flagKey, "object-flag") + } + + func testShouldReturnTypeMismatchDouble() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getDoubleDetails(key: "object-flag", defaultValue: 1.1) + XCTAssertEqual(details.errorCode, ErrorCode.typeMismatch) + XCTAssertEqual(details.value, 1.1) + XCTAssertEqual(details.flagKey, "object-flag") + } + + func testShouldReturnTypeMismatchObject() async { + let mockService = MockNetworkingService( mockStatus: 200) + let options = GoFeatureFlagProviderOptions( + endpoint: "http://localhost:1031/", + networkService: mockService + ) + let provider = GoFeatureFlagProvider(options: options) + + let api = OpenFeatureAPI() + await api.setProviderAndWait(provider: provider, initialContext: defaultEvaluationContext) + let expectation = XCTestExpectation(description: "waiting 1st event") + _ = api.observe().sink{ event in + if(event != ProviderEvent.ready){ + XCTFail("If OFREP API returns a 200 we should receive a ready event, received: \(event)") + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 3) + + let client = api.getClient() + let details = client.getObjectDetails(key: "bool-flag", defaultValue: Value.list([Value.string("1")])) + XCTAssertEqual(details.errorCode, ErrorCode.typeMismatch) + XCTAssertEqual(details.value, Value.list([Value.string("1")])) + XCTAssertEqual(details.flagKey, "bool-flag") + } +}