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")
+ }
+}