From b29645d5aad3e7774b266ea19f1378d500e276c0 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Tue, 7 Feb 2023 11:32:26 +0100 Subject: [PATCH 1/3] Tranfer codebase --- .gitignore | 12 + .swift-format | 56 +++ .swiftlint.yml | 116 ++++++ Package.swift | 28 ++ README.md | 59 ++- Sources/OpenFeature/BaseEvaluation.swift | 10 + Sources/OpenFeature/Client.swift | 17 + Sources/OpenFeature/EvaluationContext.swift | 9 + Sources/OpenFeature/FeatureProvider.swift | 23 ++ Sources/OpenFeature/Features.swift | 78 ++++ .../OpenFeature/FlagEvaluationDetails.swift | 36 ++ .../OpenFeature/FlagEvaluationOptions.swift | 6 + Sources/OpenFeature/FlagValueType.swift | 9 + Sources/OpenFeature/Hook.swift | 92 +++++ Sources/OpenFeature/HookContext.swift | 10 + Sources/OpenFeature/HookSupport.swift | 151 +++++++ Sources/OpenFeature/Metadata.swift | 5 + Sources/OpenFeature/MutableContext.swift | 61 +++ Sources/OpenFeature/MutableStructure.swift | 62 +++ Sources/OpenFeature/NoOpProvider.swift | 49 +++ Sources/OpenFeature/OpenFeatureAPI.swift | 66 +++ Sources/OpenFeature/OpenFeatureClient.swift | 389 ++++++++++++++++++ Sources/OpenFeature/ProviderEvaluation.swift | 23 ++ Sources/OpenFeature/Reason.swift | 16 + Sources/OpenFeature/Structure.swift | 8 + Sources/OpenFeature/Value.swift | 323 +++++++++++++++ .../OpenFeature/exceptions/ErrorCode.swift | 11 + .../exceptions/OpenFeatureError.swift | 51 +++ .../DeveloperExperienceTests.swift | 85 ++++ Tests/OpenFeatureTests/EvalContextTests.swift | 157 +++++++ .../FlagEvaluationTests.swift | 202 +++++++++ .../Helpers/AlwaysBrokenProvider.swift | 44 ++ .../Helpers/BooleanHookMock.swift | 48 +++ .../Helpers/DoSomethingProvider.swift | 52 +++ Tests/OpenFeatureTests/HookSpecTests.swift | 87 ++++ Tests/OpenFeatureTests/HookSupportTests.swift | 89 ++++ .../OpenFeatureClientTests.swift | 88 ++++ .../OpenFeatureTests/ProviderSpecTests.swift | 58 +++ Tests/OpenFeatureTests/StructureTests.swift | 39 ++ Tests/OpenFeatureTests/ValueTests.swift | 103 +++++ Tools/SwiftFormat/Package.resolved | 52 +++ Tools/SwiftFormat/Package.swift | 11 + Tools/SwiftLinter/Package.resolved | 79 ++++ Tools/SwiftLinter/Package.swift | 11 + Tools/swift-format | 13 + Tools/swift-lint | 13 + scripts/run_tests.sh | 13 + scripts/swift-format | 8 + scripts/swift-lint | 8 + 49 files changed, 3035 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .swift-format create mode 100644 .swiftlint.yml create mode 100644 Package.swift create mode 100644 Sources/OpenFeature/BaseEvaluation.swift create mode 100644 Sources/OpenFeature/Client.swift create mode 100644 Sources/OpenFeature/EvaluationContext.swift create mode 100644 Sources/OpenFeature/FeatureProvider.swift create mode 100644 Sources/OpenFeature/Features.swift create mode 100644 Sources/OpenFeature/FlagEvaluationDetails.swift create mode 100644 Sources/OpenFeature/FlagEvaluationOptions.swift create mode 100644 Sources/OpenFeature/FlagValueType.swift create mode 100644 Sources/OpenFeature/Hook.swift create mode 100644 Sources/OpenFeature/HookContext.swift create mode 100644 Sources/OpenFeature/HookSupport.swift create mode 100644 Sources/OpenFeature/Metadata.swift create mode 100644 Sources/OpenFeature/MutableContext.swift create mode 100644 Sources/OpenFeature/MutableStructure.swift create mode 100644 Sources/OpenFeature/NoOpProvider.swift create mode 100644 Sources/OpenFeature/OpenFeatureAPI.swift create mode 100644 Sources/OpenFeature/OpenFeatureClient.swift create mode 100644 Sources/OpenFeature/ProviderEvaluation.swift create mode 100644 Sources/OpenFeature/Reason.swift create mode 100644 Sources/OpenFeature/Structure.swift create mode 100644 Sources/OpenFeature/Value.swift create mode 100644 Sources/OpenFeature/exceptions/ErrorCode.swift create mode 100644 Sources/OpenFeature/exceptions/OpenFeatureError.swift create mode 100644 Tests/OpenFeatureTests/DeveloperExperienceTests.swift create mode 100644 Tests/OpenFeatureTests/EvalContextTests.swift create mode 100644 Tests/OpenFeatureTests/FlagEvaluationTests.swift create mode 100644 Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift create mode 100644 Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift create mode 100644 Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift create mode 100644 Tests/OpenFeatureTests/HookSpecTests.swift create mode 100644 Tests/OpenFeatureTests/HookSupportTests.swift create mode 100644 Tests/OpenFeatureTests/OpenFeatureClientTests.swift create mode 100644 Tests/OpenFeatureTests/ProviderSpecTests.swift create mode 100644 Tests/OpenFeatureTests/StructureTests.swift create mode 100644 Tests/OpenFeatureTests/ValueTests.swift create mode 100644 Tools/SwiftFormat/Package.resolved create mode 100644 Tools/SwiftFormat/Package.swift create mode 100644 Tools/SwiftLinter/Package.resolved create mode 100644 Tools/SwiftLinter/Package.swift create mode 100755 Tools/swift-format create mode 100755 Tools/swift-lint create mode 100755 scripts/run_tests.sh create mode 100755 scripts/swift-format create mode 100755 scripts/swift-lint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc8936d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +.build +.mockingbird +project.json diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..e4e1012 --- /dev/null +++ b/.swift-format @@ -0,0 +1,56 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentation" : { + "spaces" : 4 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 120, + "maximumBlankLines" : 1, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "UseEarlyExits" : false, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "tabWidth" : 8, + "version" : 1 +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..1255dc1 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,116 @@ +excluded: + - ${PWD}/Carthage + - ${PWD}/Pods + - ${PWD}/DerivedData + - ${PWD}/.build + - ${PWD}/Tools/.build + - ${PWD}/Tests/OpenFeatureTests/MockingbirdMocks/ + - ${PWD}/Sources/OpenFeature/FlagResolver + +disabled_rules: + - discarded_notification_center_observer + - notification_center_detachment + - orphaned_doc_comment + - todo + - unused_capture_list + - opening_brace + +analyzer_rules: + - unused_import + +opt_in_rules: + - array_init + - attributes + - closure_end_indentation + - closure_spacing + - collection_alignment + - colon # promote to error + - convenience_type + - discouraged_object_literal + - empty_collection_literal + - empty_count + - empty_string + - enum_case_associated_values_count + - fatal_error_message + - first_where + - force_unwrapping + - implicitly_unwrapped_optional + - indentation_width + - last_where + - legacy_random + - literal_expression_end_indentation + - multiline_arguments + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - multiline_parameters_brackets + - operator_usage_whitespace + - overridden_super_call + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - redundant_nil_coalescing + - redundant_type_annotation + - strict_fileprivate + - toggle_bool + - trailing_closure + - unneeded_parentheses_in_closure_argument + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - yoda_condition + + +custom_rules: + array_constructor: + name: "Array/Dictionary initializer" + regex: '[let,var] .+ = (\[.+\]\(\))' + capture_group: 1 + message: "Use explicit type annotation when initializing empty arrays and dictionaries" + severity: warning + + +attributes: + always_on_same_line: + - "@IBSegueAction" + - "@IBAction" + - "@NSManaged" + - "@objc" + +force_cast: warning +force_try: warning +function_body_length: + warning: 60 + +legacy_hashing: error + +identifier_name: + excluded: + - i + - id + - x + - y + - z + +indentation_width: + indentation_width: 4 + +line_length: + ignores_urls: true + ignores_function_declarations: true + ignores_comments: true + +multiline_arguments: + first_argument_location: next_line + only_enforce_after_first_closure_on_first_line: true + +private_over_fileprivate: + validate_extensions: true + +trailing_comma: + mandatory_comma: true + +trailing_whitespace: + ignores_empty_lines: false + ignores_comments: true + +vertical_whitespace: + max_empty_lines: 2 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..8dd37cb --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// 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: "OpenFeature", + platforms: [ + .iOS(.v14), + .macOS(.v12) + ], + products: [ + .library( + name: "OpenFeature", + targets: ["OpenFeature"]) + ], + dependencies: [ + ], + targets: [ + .target( + name: "OpenFeature", + dependencies: [] + ), + .testTarget( + name: "OpenFeatureTests", + dependencies: ["OpenFeature"]) + ] +) diff --git a/README.md b/README.md index 8de24fe..643534b 100644 --- a/README.md +++ b/README.md @@ -1 +1,58 @@ -# openfeature-swift-sdk \ No newline at end of file +# OpenFeature + +Swift implementation of the OpenFeature SDK. + +## Usage + +### Adding the package dependency + +If you manage dependencies through XCode go to "Add package" and enter `git@ghe.spotify.net:konfidens/openfeature-swift-sdk.git`. + +If you manage dependencies through SPM, in the dependencies section of Package.swift add: +```swift +.package(url: "git@ghe.spotify.net:konfidens/openfeature-swift-sdk.git", from: "0.1.3") +``` + +and in the target dependencies section add: +```swift +.product(name: "OpenFeature", package: "openfeature-swift-sdk"), +``` + +### Resolving a flag + +To enable the provider and start resolving flags add the following: + +```swift +import OpenFeature + +// Change this to your actual provider +OpenFeatureAPI.shared.provider = NoOpProvider() + +let client = OpenFeatureAPI.shared.getClient() +let value = client.getBooleanValue(key: "flag", defaultValue: false) +``` + +## Development + +Open the project in XCode and build by Product -> Build. + +### Linting code + +Code is automatically linted during build in XCode, if you need to manually lint: +```shell +brew install swiftlint +swiftlint +``` + +### Formatting code + +You can automatically format your code using: +```shell +./scripts/swift-format +``` + +## Running tests from cmd-line + +```shell +./scripts/run_tests.sh +``` diff --git a/Sources/OpenFeature/BaseEvaluation.swift b/Sources/OpenFeature/BaseEvaluation.swift new file mode 100644 index 0000000..1f5e458 --- /dev/null +++ b/Sources/OpenFeature/BaseEvaluation.swift @@ -0,0 +1,10 @@ +import Foundation + +public protocol BaseEvaluation { + associatedtype ValueType + var value: ValueType { get } + var variant: String? { get } + var reason: String? { get } + var errorCode: ErrorCode? { get } + var errorMessage: String? { get } +} diff --git a/Sources/OpenFeature/Client.swift b/Sources/OpenFeature/Client.swift new file mode 100644 index 0000000..4083a86 --- /dev/null +++ b/Sources/OpenFeature/Client.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Interface used to resolve flags of varying types. +public protocol Client: Features { + var metadata: Metadata { get } + + /// Return an optional client-level evaluation context. + var evaluationContext: EvaluationContext? { get set } + + /// The hooks associated to this client. + var hooks: [AnyHook] { get } + + /// Adds hooks for evaluation. + /// Hooks are run in the order they're added in the before stage. They are run in reverse order for all + /// other stages. + func addHooks(_ hooks: AnyHook...) +} diff --git a/Sources/OpenFeature/EvaluationContext.swift b/Sources/OpenFeature/EvaluationContext.swift new file mode 100644 index 0000000..1ad50a9 --- /dev/null +++ b/Sources/OpenFeature/EvaluationContext.swift @@ -0,0 +1,9 @@ +import Foundation + +public protocol EvaluationContext: Structure { + func getTargetingKey() -> String + + func setTargetingKey(targetingKey: String) + + func merge(overridingContext: EvaluationContext) -> EvaluationContext +} diff --git a/Sources/OpenFeature/FeatureProvider.swift b/Sources/OpenFeature/FeatureProvider.swift new file mode 100644 index 0000000..8b8bb2f --- /dev/null +++ b/Sources/OpenFeature/FeatureProvider.swift @@ -0,0 +1,23 @@ +import Foundation + +/// The interface implemented by upstream flag providers to resolve flags for their service. +public protocol FeatureProvider { + var hooks: [AnyHook] { get } + var metadata: Metadata { get } + + func getBooleanEvaluation(key: String, defaultValue: Bool, ctx: EvaluationContext) throws -> ProviderEvaluation< + Bool + > + func getStringEvaluation(key: String, defaultValue: String, ctx: EvaluationContext) throws -> ProviderEvaluation< + String + > + func getIntegerEvaluation(key: String, defaultValue: Int64, ctx: EvaluationContext) throws -> ProviderEvaluation< + Int64 + > + func getDoubleEvaluation(key: String, defaultValue: Double, ctx: EvaluationContext) throws -> ProviderEvaluation< + Double + > + func getObjectEvaluation(key: String, defaultValue: Value, ctx: EvaluationContext) throws -> ProviderEvaluation< + Value + > +} diff --git a/Sources/OpenFeature/Features.swift b/Sources/OpenFeature/Features.swift new file mode 100644 index 0000000..7a510d2 --- /dev/null +++ b/Sources/OpenFeature/Features.swift @@ -0,0 +1,78 @@ +import Foundation + +public protocol Features { + // MARK: Bool + func getBooleanValue(key: String, defaultValue: Bool) -> Bool + + func getBooleanValue(key: String, defaultValue: Bool, ctx: EvaluationContext?) -> Bool + + func getBooleanValue(key: String, defaultValue: Bool, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> Bool + + func getBooleanDetails(key: String, defaultValue: Bool) -> FlagEvaluationDetails + + func getBooleanDetails(key: String, defaultValue: Bool, ctx: EvaluationContext?) -> FlagEvaluationDetails + + func getBooleanDetails(key: String, defaultValue: Bool, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> FlagEvaluationDetails + + // MARK: String + func getStringValue(key: String, defaultValue: String) -> String + + func getStringValue(key: String, defaultValue: String, ctx: EvaluationContext?) -> String + + func getStringValue(key: String, defaultValue: String, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> String + + func getStringDetails(key: String, defaultValue: String) -> FlagEvaluationDetails + + func getStringDetails(key: String, defaultValue: String, ctx: EvaluationContext?) -> FlagEvaluationDetails + + func getStringDetails(key: String, defaultValue: String, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> FlagEvaluationDetails + + // MARK: Int + func getIntegerValue(key: String, defaultValue: Int64) -> Int64 + + func getIntegerValue(key: String, defaultValue: Int64, ctx: EvaluationContext?) -> Int64 + + func getIntegerValue(key: String, defaultValue: Int64, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> Int64 + + func getIntegerDetails(key: String, defaultValue: Int64) -> FlagEvaluationDetails + + func getIntegerDetails(key: String, defaultValue: Int64, ctx: EvaluationContext?) -> FlagEvaluationDetails + + func getIntegerDetails(key: String, defaultValue: Int64, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> FlagEvaluationDetails + + // MARK: Double + func getDoubleValue(key: String, defaultValue: Double) -> Double + + func getDoubleValue(key: String, defaultValue: Double, ctx: EvaluationContext?) -> Double + + func getDoubleValue(key: String, defaultValue: Double, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> Double + + func getDoubleDetails(key: String, defaultValue: Double) -> FlagEvaluationDetails + + func getDoubleDetails(key: String, defaultValue: Double, ctx: EvaluationContext?) -> FlagEvaluationDetails + + func getDoubleDetails(key: String, defaultValue: Double, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> FlagEvaluationDetails + + // MARK: Object + func getObjectValue(key: String, defaultValue: Value) -> Value + + func getObjectValue(key: String, defaultValue: Value, ctx: EvaluationContext?) -> Value + + func getObjectValue(key: String, defaultValue: Value, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> Value + + func getObjectDetails(key: String, defaultValue: Value) -> FlagEvaluationDetails + + func getObjectDetails(key: String, defaultValue: Value, ctx: EvaluationContext?) -> FlagEvaluationDetails + + func getObjectDetails(key: String, defaultValue: Value, ctx: EvaluationContext?, options: FlagEvaluationOptions) + -> FlagEvaluationDetails +} diff --git a/Sources/OpenFeature/FlagEvaluationDetails.swift b/Sources/OpenFeature/FlagEvaluationDetails.swift new file mode 100644 index 0000000..3191078 --- /dev/null +++ b/Sources/OpenFeature/FlagEvaluationDetails.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct FlagEvaluationDetails: BaseEvaluation, Equatable { + public var flagKey: String + public var value: T + public var variant: String? + public var reason: String? + public var errorCode: ErrorCode? + public var errorMessage: String? + + public init( + flagKey: String, + value: T, + variant: String? = nil, + reason: String? = nil, + errorCode: ErrorCode? = nil, + errorMessage: String? = nil + ) { + self.flagKey = flagKey + self.value = value + self.variant = variant + self.reason = reason + self.errorCode = errorCode + self.errorMessage = errorMessage + } + + public static func from(providerEval: ProviderEvaluation, flagKey: String) -> FlagEvaluationDetails { + return FlagEvaluationDetails( + flagKey: flagKey, + value: providerEval.value, + variant: providerEval.variant, + reason: providerEval.reason, + errorCode: providerEval.errorCode, + errorMessage: providerEval.errorMessage) + } +} diff --git a/Sources/OpenFeature/FlagEvaluationOptions.swift b/Sources/OpenFeature/FlagEvaluationOptions.swift new file mode 100644 index 0000000..9ed7a23 --- /dev/null +++ b/Sources/OpenFeature/FlagEvaluationOptions.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct FlagEvaluationOptions { + var hooks: [AnyHook] = [] + var hookHints: [String: Any] = [:] +} diff --git a/Sources/OpenFeature/FlagValueType.swift b/Sources/OpenFeature/FlagValueType.swift new file mode 100644 index 0000000..34b9f99 --- /dev/null +++ b/Sources/OpenFeature/FlagValueType.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum FlagValueType { + case string + case integer + case double + case object + case boolean +} diff --git a/Sources/OpenFeature/Hook.swift b/Sources/OpenFeature/Hook.swift new file mode 100644 index 0000000..cca0ffd --- /dev/null +++ b/Sources/OpenFeature/Hook.swift @@ -0,0 +1,92 @@ +import Foundation + +public protocol Hook { + associatedtype HookValue: Equatable + + func before(ctx: HookContext, hints: [String: Any]) -> EvaluationContext? + + func after(ctx: HookContext, details: FlagEvaluationDetails, hints: [String: Any]) + + func error(ctx: HookContext, error: Error, hints: [String: Any]) + + func finallyAfter(ctx: HookContext, hints: [String: Any]) + + func supportsFlagValueType(flagValueType: FlagValueType) -> Bool +} + +extension Hook { + func before(ctx: HookContext, hints: [String: Any]) -> EvaluationContext? { + return nil + } + + func after(ctx: HookContext, details: FlagEvaluationDetails, hints: [String: Any]) { + } + + func error(ctx: HookContext, error: Error, hints: [String: Any]) { + } + + func finallyAfter(ctx: HookContext, hints: [String: Any]) { + } + + func supportsFlagValueType(flagValueType: FlagValueType) -> Bool { + return true + } +} + +public protocol BooleanHook: Hook where HookValue == Bool {} +extension BooleanHook { + func supportsFlagValueType(flagValueType: FlagValueType) -> Bool { + return flagValueType == .boolean + } +} + +public protocol StringHook: Hook where HookValue == String {} +extension StringHook { + func supportsFlagValueType(flagValueType: FlagValueType) -> Bool { + return flagValueType == .string + } +} + +public protocol IntegerHook: Hook where HookValue == Int64 {} +extension IntegerHook { + func supportsFlagValueType(flagValueType: FlagValueType) -> Bool { + return flagValueType == .integer + } +} + +public protocol DoubleHook: Hook where HookValue == Double {} +extension DoubleHook { + func supportsFlagValueType(flagValueType: FlagValueType) -> Bool { + return flagValueType == .double + } +} + +public protocol ObjectHook: Hook where HookValue == Value {} +extension ObjectHook { + func supportsFlagValueType(flagValueType: FlagValueType) -> Bool { + return flagValueType == .object + } +} + +public enum AnyHook { + case boolean(any BooleanHook) + case string(any StringHook) + case integer(any IntegerHook) + case double(any DoubleHook) + case object(any ObjectHook) + + public func suppoprtsFlagValueType(flagValueType: FlagValueType) -> Bool { + switch self { + case .boolean(let booleanHook): + return booleanHook.supportsFlagValueType(flagValueType: flagValueType) + case .string(let stringHook): + return stringHook.supportsFlagValueType(flagValueType: flagValueType) + case .integer(let integerHook): + return integerHook.supportsFlagValueType(flagValueType: flagValueType) + case .double(let doubleHook): + return doubleHook.supportsFlagValueType(flagValueType: flagValueType) + case .object(let objectHook): + return objectHook.supportsFlagValueType(flagValueType: flagValueType) + } + } +} diff --git a/Sources/OpenFeature/HookContext.swift b/Sources/OpenFeature/HookContext.swift new file mode 100644 index 0000000..8d66620 --- /dev/null +++ b/Sources/OpenFeature/HookContext.swift @@ -0,0 +1,10 @@ +import Foundation + +public struct HookContext { + var flagKey: String + var type: FlagValueType + var defaultValue: T + var ctx: EvaluationContext + var clientMetadata: Metadata? + var providerMetadata: Metadata? +} diff --git a/Sources/OpenFeature/HookSupport.swift b/Sources/OpenFeature/HookSupport.swift new file mode 100644 index 0000000..5aa6a88 --- /dev/null +++ b/Sources/OpenFeature/HookSupport.swift @@ -0,0 +1,151 @@ +import Foundation +import os + +class HookSupport { + var logger = Logger() + func errorHooks( + flagValueType: FlagValueType, hookCtx: HookContext, error: Error, hooks: [AnyHook], hints: [String: Any] + ) { + hooks + .filter { hook in hook.suppoprtsFlagValueType(flagValueType: flagValueType) } + .forEach { hook in + switch hook { + case .boolean(let booleanHook): + if let booleanCtx = hookCtx as? HookContext { + booleanHook.error(ctx: booleanCtx, error: error, hints: hints) + } + case .integer(let integerHook): + if let integerCtx = hookCtx as? HookContext { + integerHook.error(ctx: integerCtx, error: error, hints: hints) + } + case .double(let doubleHook): + if let doubleCtx = hookCtx as? HookContext { + doubleHook.error(ctx: doubleCtx, error: error, hints: hints) + } + case .string(let stringHook): + if let stringCtx = hookCtx as? HookContext { + stringHook.error(ctx: stringCtx, error: error, hints: hints) + } + case .object(let objectHook): + if let objectCtx = hookCtx as? HookContext { + objectHook.error(ctx: objectCtx, error: error, hints: hints) + } + } + } + } + + func afterAllHooks(flagValueType: FlagValueType, hookCtx: HookContext, hooks: [AnyHook], hints: [String: Any]) + { + hooks + .filter { hook in hook.suppoprtsFlagValueType(flagValueType: flagValueType) } + .forEach { hook in + switch hook { + case .boolean(let booleanHook): + if let booleanCtx = hookCtx as? HookContext { + booleanHook.finallyAfter(ctx: booleanCtx, hints: hints) + } + case .integer(let integerHook): + if let integerCtx = hookCtx as? HookContext { + integerHook.finallyAfter(ctx: integerCtx, hints: hints) + } + case .double(let doubleHook): + if let doubleCtx = hookCtx as? HookContext { + doubleHook.finallyAfter(ctx: doubleCtx, hints: hints) + } + case .string(let stringHook): + if let stringCtx = hookCtx as? HookContext { + stringHook.finallyAfter(ctx: stringCtx, hints: hints) + } + case .object(let objectHook): + if let objectCtx = hookCtx as? HookContext { + objectHook.finallyAfter(ctx: objectCtx, hints: hints) + } + } + } + } + + func afterHooks( + flagValueType: FlagValueType, + hookCtx: HookContext, + details: FlagEvaluationDetails, + hooks: [AnyHook], + hints: [String: Any] + ) throws { + hooks + .filter { hook in hook.suppoprtsFlagValueType(flagValueType: flagValueType) } + .forEach { hook in + switch hook { + case .boolean(let booleanHook): + if let booleanCtx = hookCtx as? HookContext, + let booleanDetails = details as? FlagEvaluationDetails + { + booleanHook.after(ctx: booleanCtx, details: booleanDetails, hints: hints) + } + case .integer(let integerHook): + if let integerCtx = hookCtx as? HookContext, + let integerDetails = details as? FlagEvaluationDetails + { + integerHook.after(ctx: integerCtx, details: integerDetails, hints: hints) + } + case .double(let doubleHook): + if let doubleCtx = hookCtx as? HookContext, + let doubleDetails = details as? FlagEvaluationDetails + { + doubleHook.after(ctx: doubleCtx, details: doubleDetails, hints: hints) + } + case .string(let stringHook): + if let stringCtx = hookCtx as? HookContext, + let stringDetails = details as? FlagEvaluationDetails + { + stringHook.after(ctx: stringCtx, details: stringDetails, hints: hints) + } + case .object(let objectHook): + if let objectCtx = hookCtx as? HookContext, + let objectDetails = details as? FlagEvaluationDetails + { + objectHook.after(ctx: objectCtx, details: objectDetails, hints: hints) + } + } + } + } + + func beforeHooks(flagValueType: FlagValueType, hookCtx: HookContext, hooks: [AnyHook], hints: [String: Any]) + -> EvaluationContext + { + let result = + hooks + .reversed() + .filter { hook in hook.suppoprtsFlagValueType(flagValueType: flagValueType) } + .compactMap { hook in + switch hook { + case .boolean(let booleanHook): + if let booleanCtx = hookCtx as? HookContext { + return booleanHook.before(ctx: booleanCtx, hints: hints) + } + case .integer(let integerHook): + if let integerCtx = hookCtx as? HookContext { + return integerHook.before(ctx: integerCtx, hints: hints) + } + case .double(let doubleHook): + if let doubleCtx = hookCtx as? HookContext { + return doubleHook.before(ctx: doubleCtx, hints: hints) + } + case .string(let stringHook): + if let stringCtx = hookCtx as? HookContext { + return stringHook.before(ctx: stringCtx, hints: hints) + } + case .object(let objectHook): + if let objectCtx = hookCtx as? HookContext { + return objectHook.before(ctx: objectCtx, hints: hints) + } + } + + return nil + } + + return hookCtx.ctx.merge( + overridingContext: result.reduce(hookCtx.ctx) { acc, cur in + acc.merge(overridingContext: cur) + }) + } +} diff --git a/Sources/OpenFeature/Metadata.swift b/Sources/OpenFeature/Metadata.swift new file mode 100644 index 0000000..08d7207 --- /dev/null +++ b/Sources/OpenFeature/Metadata.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol Metadata { + var name: String? { get } +} diff --git a/Sources/OpenFeature/MutableContext.swift b/Sources/OpenFeature/MutableContext.swift new file mode 100644 index 0000000..437c369 --- /dev/null +++ b/Sources/OpenFeature/MutableContext.swift @@ -0,0 +1,61 @@ +import Foundation + +public class MutableContext: EvaluationContext { + private var targetingKey: String + private var structure: MutableStructure + + public init(targetingKey: String = "", structure: MutableStructure = MutableStructure()) { + self.targetingKey = targetingKey + self.structure = structure + } + + public convenience init(attributes: [String: Value]) { + self.init(structure: MutableStructure(attributes: attributes)) + } + + public func getTargetingKey() -> String { + return self.targetingKey + } + + public func setTargetingKey(targetingKey: String) { + self.targetingKey = targetingKey + } + + public func merge(overridingContext: EvaluationContext) -> EvaluationContext { + let merged = self.asMap().merging(overridingContext.asMap()) { _, new in new } + let mergedContext = MutableContext(attributes: merged) + + if !self.targetingKey.isEmpty { + mergedContext.setTargetingKey(targetingKey: self.targetingKey) + } + if !overridingContext.getTargetingKey().trimmingCharacters(in: .whitespaces).isEmpty { + mergedContext.setTargetingKey(targetingKey: overridingContext.getTargetingKey()) + } + + return mergedContext + } + + public func keySet() -> Set { + return structure.keySet() + } + + public func getValue(key: String) -> Value? { + return structure.getValue(key: key) + } + + public func asMap() -> [String: Value] { + return structure.asMap() + } + + public func asObjectMap() -> [String: AnyHashable?] { + return structure.asObjectMap() + } +} + +extension MutableContext { + @discardableResult + public func add(key: String, value: Value) -> MutableContext { + self.structure.add(key: key, value: value) + return self + } +} diff --git a/Sources/OpenFeature/MutableStructure.swift b/Sources/OpenFeature/MutableStructure.swift new file mode 100644 index 0000000..c85c7e3 --- /dev/null +++ b/Sources/OpenFeature/MutableStructure.swift @@ -0,0 +1,62 @@ +import Foundation + +public class MutableStructure: Structure { + private var attributes: [String: Value] + + public init(attributes: [String: Value] = [:]) { + self.attributes = attributes + } + + public func keySet() -> Set { + return Set(attributes.keys) + } + + public func getValue(key: String) -> Value? { + return attributes[key] + } + + public func asMap() -> [String: Value] { + return attributes + } + + public func asObjectMap() -> [String: AnyHashable?] { + return attributes.mapValues(convertValue) + } +} + +extension MutableStructure { + private func convertValue(value: Value) -> AnyHashable? { + switch value { + case .boolean(let value): + return value + case .string(let value): + return value + case .integer(let value): + return value + case .double(let value): + return value + case .date(let value): + return value + case .list(let value): + return value.map(convertValue) + case .structure(let value): + return value.mapValues(convertValue) + case .null: + return nil + } + } +} + +extension MutableStructure { + public enum ConversionError: Error { + case valueNotConvertableError + } +} + +extension MutableStructure { + @discardableResult + public func add(key: String, value: Value) -> MutableStructure { + attributes[key] = value + return self + } +} diff --git a/Sources/OpenFeature/NoOpProvider.swift b/Sources/OpenFeature/NoOpProvider.swift new file mode 100644 index 0000000..aa4c223 --- /dev/null +++ b/Sources/OpenFeature/NoOpProvider.swift @@ -0,0 +1,49 @@ +import Foundation + +class NoOpProvider: FeatureProvider { + public static let passedInDefault = "Passed in default" + + var metadata: Metadata = NoOpMetadata(name: "No-op provider") + var hooks: [AnyHook] = [] + + func getBooleanEvaluation(key: String, defaultValue: Bool, ctx: EvaluationContext) throws -> ProviderEvaluation< + Bool + > { + return ProviderEvaluation( + value: defaultValue, variant: NoOpProvider.passedInDefault, reason: Reason.defaultReason.rawValue) + } + + func getStringEvaluation(key: String, defaultValue: String, ctx: EvaluationContext) throws -> ProviderEvaluation< + String + > { + return ProviderEvaluation( + value: defaultValue, variant: NoOpProvider.passedInDefault, reason: Reason.defaultReason.rawValue) + } + + func getIntegerEvaluation(key: String, defaultValue: Int64, ctx: EvaluationContext) throws -> ProviderEvaluation< + Int64 + > { + return ProviderEvaluation( + value: defaultValue, variant: NoOpProvider.passedInDefault, reason: Reason.defaultReason.rawValue) + } + + func getDoubleEvaluation(key: String, defaultValue: Double, ctx: EvaluationContext) throws -> ProviderEvaluation< + Double + > { + return ProviderEvaluation( + value: defaultValue, variant: NoOpProvider.passedInDefault, reason: Reason.defaultReason.rawValue) + } + + func getObjectEvaluation(key: String, defaultValue: Value, ctx: EvaluationContext) throws -> ProviderEvaluation< + Value + > { + return ProviderEvaluation( + value: defaultValue, variant: NoOpProvider.passedInDefault, reason: Reason.defaultReason.rawValue) + } +} + +extension NoOpProvider { + struct NoOpMetadata: Metadata { + var name: String? + } +} diff --git a/Sources/OpenFeature/OpenFeatureAPI.swift b/Sources/OpenFeature/OpenFeatureAPI.swift new file mode 100644 index 0000000..7a76894 --- /dev/null +++ b/Sources/OpenFeature/OpenFeatureAPI.swift @@ -0,0 +1,66 @@ +import Foundation + +/// A global singleton which holds base configuration for the OpenFeature library. +/// Configuration here will be shared across all ``Client``s. +public class OpenFeatureAPI { + // TODO: We use DispatchQueue here instead of being an actor to not lock into new versions of Swift + private let contextQueue = DispatchQueue(label: "dev.openfeature.api.context") + private let providerQueue = DispatchQueue(label: "dev.openfeature.api.provider") + private let hookQueue = DispatchQueue(label: "dev.openfeature.api.hook") + + private var _provider: FeatureProvider? + public var provider: FeatureProvider? { + get { + return self._provider + } + set { + self.providerQueue.sync { + self._provider = newValue + } + } + } + + private var _evaluationContext: EvaluationContext? + public var evaluationContext: EvaluationContext? { + get { + return self._evaluationContext + } + set { + self.contextQueue.sync { + self._evaluationContext = newValue + } + } + } + + private(set) var hooks: [AnyHook] = [] + + /// The ``OpenFeatureAPI`` singleton + static public let shared = OpenFeatureAPI() + + public init() { + } + + public func getProviderMetadata() -> Metadata? { + return self.provider?.metadata + } + + public func getClient() -> Client { + return OpenFeatureClient(openFeatureApi: self, name: nil, version: nil) + } + + public func getClient(name: String?, version: String?) -> Client { + return OpenFeatureClient(openFeatureApi: self, name: name, version: version) + } + + public func addHooks(hooks: AnyHook...) { + hookQueue.sync { + self.hooks.append(contentsOf: hooks) + } + } + + public func clearHooks() { + hookQueue.sync { + self.hooks.removeAll() + } + } +} diff --git a/Sources/OpenFeature/OpenFeatureClient.swift b/Sources/OpenFeature/OpenFeatureClient.swift new file mode 100644 index 0000000..aaa8e17 --- /dev/null +++ b/Sources/OpenFeature/OpenFeatureClient.swift @@ -0,0 +1,389 @@ +import Foundation +import os + +public class OpenFeatureClient: Client { + // TODO: We use DispatchQueue here instead of being an actor to not lock into new versions of Swift + private let hookQueue = DispatchQueue(label: "dev.openfeature.client.hook") + private let contextQueue = DispatchQueue(label: "dev.openfeature.client.context") + + private var openFeatureApi: OpenFeatureAPI + private(set) var name: String? + private(set) var version: String? + + private var _metadata: Metadata + public var metadata: Metadata { + return _metadata + } + + private var _hooks: [AnyHook] = [] + public var hooks: [AnyHook] { + return _hooks + } + + private var _evaluationContext: EvaluationContext? + public var evaluationContext: EvaluationContext? { + get { + return _evaluationContext + } + set { + contextQueue.sync { + self._evaluationContext = newValue + } + } + } + + private var hookSupport = HookSupport() + private var logger = Logger() + + public init(openFeatureApi: OpenFeatureAPI, name: String?, version: String?) { + self.openFeatureApi = openFeatureApi + self.name = name + self.version = version + self._metadata = ClientMetadata(name: name) + } + + public func addHooks(_ hooks: AnyHook...) { + self.hookQueue.sync { + self._hooks.append(contentsOf: hooks) + } + } +} + +extension OpenFeatureClient { + // MARK: Boolean + public func getBooleanValue(key: String, defaultValue: Bool) -> Bool { + return getBooleanDetails(key: key, defaultValue: defaultValue).value + } + + public func getBooleanValue(key: String, defaultValue: Bool, ctx: EvaluationContext?) -> Bool { + return getBooleanDetails(key: key, defaultValue: defaultValue, ctx: ctx).value + } + + public func getBooleanValue( + key: String, defaultValue: Bool, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> Bool + { + return getBooleanDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: options).value + } + + public func getBooleanDetails(key: String, defaultValue: Bool) -> FlagEvaluationDetails { + return getBooleanDetails(key: key, defaultValue: defaultValue, ctx: nil) + } + + public func getBooleanDetails(key: String, defaultValue: Bool, ctx: EvaluationContext?) -> FlagEvaluationDetails< + Bool + > { + return getBooleanDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: FlagEvaluationOptions()) + } + + public func getBooleanDetails( + key: String, defaultValue: Bool, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> FlagEvaluationDetails + { + return evaluateFlag( + flagValueType: .boolean, key: key, defaultValue: defaultValue, ctx: ctx, options: options + ) + } +} + +extension OpenFeatureClient { + // MARK: String + public func getStringValue(key: String, defaultValue: String) -> String { + return getStringDetails(key: key, defaultValue: defaultValue).value + } + + public func getStringValue(key: String, defaultValue: String, ctx: EvaluationContext?) -> String { + return getStringDetails(key: key, defaultValue: defaultValue, ctx: ctx).value + } + + public func getStringValue( + key: String, defaultValue: String, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> String + { + return getStringDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: options).value + } + + public func getStringDetails(key: String, defaultValue: String) -> FlagEvaluationDetails { + return getStringDetails(key: key, defaultValue: defaultValue, ctx: nil) + } + + public func getStringDetails(key: String, defaultValue: String, ctx: EvaluationContext?) -> FlagEvaluationDetails< + String + > { + return getStringDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: FlagEvaluationOptions()) + } + + public func getStringDetails( + key: String, defaultValue: String, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> FlagEvaluationDetails + { + return evaluateFlag( + flagValueType: .string, key: key, defaultValue: defaultValue, ctx: ctx, options: options) + } +} + +extension OpenFeatureClient { + // MARK: Integer + public func getIntegerValue(key: String, defaultValue: Int64) -> Int64 { + return getIntegerDetails(key: key, defaultValue: defaultValue).value + } + + public func getIntegerValue(key: String, defaultValue: Int64, ctx: EvaluationContext?) -> Int64 { + return getIntegerDetails(key: key, defaultValue: defaultValue, ctx: ctx).value + } + + public func getIntegerValue( + key: String, defaultValue: Int64, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> Int64 + { + return getIntegerDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: options).value + } + + public func getIntegerDetails(key: String, defaultValue: Int64) -> FlagEvaluationDetails { + return getIntegerDetails(key: key, defaultValue: defaultValue, ctx: nil) + } + + public func getIntegerDetails(key: String, defaultValue: Int64, ctx: EvaluationContext?) -> FlagEvaluationDetails< + Int64 + > { + return getIntegerDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: FlagEvaluationOptions()) + } + + public func getIntegerDetails( + key: String, defaultValue: Int64, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> FlagEvaluationDetails + { + return evaluateFlag( + flagValueType: .integer, key: key, defaultValue: defaultValue, ctx: ctx, options: options + ) + } +} + +extension OpenFeatureClient { + // MARK: Double + public func getDoubleValue(key: String, defaultValue: Double) -> Double { + return getDoubleDetails(key: key, defaultValue: defaultValue).value + } + + public func getDoubleValue(key: String, defaultValue: Double, ctx: EvaluationContext?) -> Double { + return getDoubleDetails(key: key, defaultValue: defaultValue, ctx: ctx).value + } + + public func getDoubleValue( + key: String, defaultValue: Double, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> Double + { + return getDoubleDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: options).value + } + + public func getDoubleDetails(key: String, defaultValue: Double) -> FlagEvaluationDetails { + return getDoubleDetails(key: key, defaultValue: defaultValue, ctx: nil) + } + + public func getDoubleDetails(key: String, defaultValue: Double, ctx: EvaluationContext?) -> FlagEvaluationDetails< + Double + > { + return getDoubleDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: FlagEvaluationOptions()) + } + + public func getDoubleDetails( + key: String, defaultValue: Double, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> FlagEvaluationDetails + { + return evaluateFlag( + flagValueType: .double, key: key, defaultValue: defaultValue, ctx: ctx, options: options) + } +} + +extension OpenFeatureClient { + // MARK: Object + public func getObjectValue(key: String, defaultValue: Value) -> Value { + return getObjectDetails(key: key, defaultValue: defaultValue).value + } + + public func getObjectValue(key: String, defaultValue: Value, ctx: EvaluationContext?) -> Value { + return getObjectDetails(key: key, defaultValue: defaultValue, ctx: ctx).value + } + + public func getObjectValue( + key: String, defaultValue: Value, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> Value + { + return getObjectDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: options).value + } + + public func getObjectDetails(key: String, defaultValue: Value) -> FlagEvaluationDetails { + return getObjectDetails(key: key, defaultValue: defaultValue, ctx: nil) + } + + public func getObjectDetails(key: String, defaultValue: Value, ctx: EvaluationContext?) -> FlagEvaluationDetails< + Value + > { + return getObjectDetails(key: key, defaultValue: defaultValue, ctx: ctx, options: FlagEvaluationOptions()) + } + + public func getObjectDetails( + key: String, defaultValue: Value, ctx: EvaluationContext?, options: FlagEvaluationOptions + ) + -> FlagEvaluationDetails + { + return evaluateFlag( + flagValueType: .object, key: key, defaultValue: defaultValue, ctx: ctx, options: options) + } +} + +extension OpenFeatureClient { + public struct ClientMetadata: Metadata { + public var name: String? + } +} + +extension OpenFeatureClient { + private func evaluateFlag( + flagValueType: FlagValueType, + key: String, + defaultValue: T, + ctx: EvaluationContext?, + options: FlagEvaluationOptions? + ) -> FlagEvaluationDetails { + let options = options ?? FlagEvaluationOptions(hooks: [], hookHints: [:]) + let hints = options.hookHints + + let ctx = ctx ?? MutableContext() + var details = FlagEvaluationDetails(flagKey: key, value: defaultValue) + let provider = openFeatureApi.provider ?? NoOpProvider() + let mergedHooks = provider.hooks + options.hooks + hooks + openFeatureApi.hooks + let hookCtx = HookContext( + flagKey: key, + type: flagValueType, + defaultValue: defaultValue, + ctx: ctx, + clientMetadata: self.metadata, + providerMetadata: provider.metadata) + + do { + let apiContext = openFeatureApi.evaluationContext ?? MutableContext() + let clientContext = self.evaluationContext ?? MutableContext() + + let ctxFromHook = hookSupport.beforeHooks( + flagValueType: flagValueType, hookCtx: hookCtx, hooks: mergedHooks, hints: hints) + let invocationCtx = ctx.merge(overridingContext: ctxFromHook) + let mergedCtx = apiContext.merge(overridingContext: clientContext.merge(overridingContext: invocationCtx)) + + let providerEval = try createProviderEvaluation( + flagValueType: flagValueType, + key: key, + defaultValue: defaultValue, + provider: provider, + invocationContext: mergedCtx) + + let evalDetails = FlagEvaluationDetails.from(providerEval: providerEval, flagKey: key) + details = evalDetails + + try hookSupport.afterHooks( + flagValueType: flagValueType, hookCtx: hookCtx, details: evalDetails, hooks: mergedHooks, hints: hints) + } catch { + logger.error("Unable to correctly evaluate flag with key \(key) due to exception \(error)") + + if let error = error as? OpenFeatureError { + details.errorCode = error.errorCode() + } else { + details.errorCode = .general + } + + details.errorMessage = "\(error)" + details.reason = Reason.error.rawValue + + hookSupport.errorHooks( + flagValueType: flagValueType, hookCtx: hookCtx, error: error, hooks: mergedHooks, hints: hints) + } + + hookSupport.afterAllHooks( + flagValueType: flagValueType, hookCtx: hookCtx, hooks: mergedHooks, hints: hints) + + return details + } + + // swiftlint:disable:next cyclomatic_complexity + private func createProviderEvaluation( + flagValueType: FlagValueType, + key: String, + defaultValue: V, + provider: FeatureProvider, + invocationContext: EvaluationContext + ) throws -> ProviderEvaluation { + switch flagValueType { + case .boolean: + guard let defaultValue = defaultValue as? Bool else { + break + } + + if let evaluation = try provider.getBooleanEvaluation( + key: key, + defaultValue: defaultValue, + ctx: invocationContext) as? ProviderEvaluation + { + return evaluation + } + case .string: + guard let defaultValue = defaultValue as? String else { + break + } + + if let evaluation = try provider.getStringEvaluation( + key: key, + defaultValue: defaultValue, + ctx: invocationContext) as? ProviderEvaluation + { + return evaluation + } + case .integer: + guard let defaultValue = defaultValue as? Int64 else { + break + } + + if let evaluation = try provider.getIntegerEvaluation( + key: key, + defaultValue: defaultValue, + ctx: invocationContext) as? ProviderEvaluation + { + return evaluation + } + case .double: + guard let defaultValue = defaultValue as? Double else { + break + } + + if let evaluation = try provider.getDoubleEvaluation( + key: key, + defaultValue: defaultValue, + ctx: invocationContext) as? ProviderEvaluation + { + return evaluation + } + case .object: + guard let defaultValue = defaultValue as? Value else { + break + } + + if let evaluation = try provider.getObjectEvaluation( + key: key, + defaultValue: defaultValue, + ctx: invocationContext) as? ProviderEvaluation + { + return evaluation + } + } + + throw OpenFeatureError.generalError(message: "Unable to match default value type with flag value type") + } +} diff --git a/Sources/OpenFeature/ProviderEvaluation.swift b/Sources/OpenFeature/ProviderEvaluation.swift new file mode 100644 index 0000000..27350f3 --- /dev/null +++ b/Sources/OpenFeature/ProviderEvaluation.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct ProviderEvaluation { + public var value: T + public var variant: String? + public var reason: String? + public var errorCode: ErrorCode? + public var errorMessage: String? + + public init( + value: T, + variant: String? = nil, + reason: String? = nil, + errorCode: ErrorCode? = nil, + errorMessage: String? = nil + ) { + self.value = value + self.variant = variant + self.reason = reason + self.errorCode = errorCode + self.errorMessage = errorMessage + } +} diff --git a/Sources/OpenFeature/Reason.swift b/Sources/OpenFeature/Reason.swift new file mode 100644 index 0000000..e722b7b --- /dev/null +++ b/Sources/OpenFeature/Reason.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum Reason: String { + /// The resolved value was the result of the flag being disabled in the management system. + case disabled + /// The resolved value was the result of pseudorandom assignment. + case split + /// The resolved value was the result of a dynamic evaluation, such as a rule or specific user-targeting. + case targetingMatch + /// The resolved value was configured statically, or otherwise fell back to a pre-configured value. + case defaultReason + /// The reason for the resolved value could not be determined. + case unknown + /// The resolved value was the result of an error. + case error +} diff --git a/Sources/OpenFeature/Structure.swift b/Sources/OpenFeature/Structure.swift new file mode 100644 index 0000000..fd6afe8 --- /dev/null +++ b/Sources/OpenFeature/Structure.swift @@ -0,0 +1,8 @@ +import Foundation + +public protocol Structure { + func keySet() -> Set + func getValue(key: String) -> Value? + func asMap() -> [String: Value] + func asObjectMap() -> [String: AnyHashable?] +} diff --git a/Sources/OpenFeature/Value.swift b/Sources/OpenFeature/Value.swift new file mode 100644 index 0000000..00e5571 --- /dev/null +++ b/Sources/OpenFeature/Value.swift @@ -0,0 +1,323 @@ +import Foundation + +public enum Value: Equatable { + case boolean(Bool) + case string(String) + case integer(Int64) + case double(Double) + case date(Date) + case list([Value]) + case structure([String: Value]) + case null + + public static func of(_ value: T) -> Value { + if let value = value as? Bool { + return .boolean(value) + } else if let value = value as? String { + return .string(value) + } else if let value = value as? Int64 { + return .integer(value) + } else if let value = value as? Double { + return .double(value) + } else if let value = value as? Date { + return .date(value) + } else { + return .null + } + } + + // swiftlint:disable:next cyclomatic_complexity + public func getTyped() -> T? { + if let value = self as? T { + return value + } + + switch self { + case .boolean(let value): + if let value = value as? T { + return value + } + case .string(let value): + if let value = value as? T { + return value + } + case .integer(let value): + if let value = value as? T { + return value + } + case .double(let value): + if let value = value as? T { + return value + } + case .date(let value): + if let value = value as? T { + return value + } + case .list(let value): + if let value = value as? T { + return value + } + case .structure(let value): + if let value = value as? T { + return value + } + case .null: + return nil + } + + return nil + } + + public func asBoolean() -> Bool? { + if case let .boolean(bool) = self { + return bool + } + + return nil + } + + public func asString() -> String? { + if case let .string(string) = self { + return string + } + + return nil + } + + public func asInteger() -> Int64? { + if case let .integer(int64) = self { + return int64 + } + + return nil + } + + public func asDouble() -> Double? { + if case let .double(double) = self { + return double + } + + return nil + } + + public func asDate() -> Date? { + if case let .date(date) = self { + return date + } + + return nil + } + + public func asList() -> [Value]? { + if case let .list(values) = self { + return values + } + + return nil + } + + public func asStructure() -> [String: Value]? { + if case let .structure(values) = self { + return values + } + + return nil + } + + public func isNull() -> Bool { + if case .null = self { + return true + } + + return false + } +} + +extension Value: CustomStringConvertible { + public var description: String { + switch self { + case .boolean(let value): + return "\(value)" + case .string(let value): + return value + case .integer(let value): + return "\(value)" + case .double(let value): + return "\(value)" + case .date(let value): + return "\(value)" + case .list(value: let values): + return "\(values.map { value in value.description })" + case .structure(value: let values): + return "\(values.mapValues { value in value.description })" + case .null: + return "null" + } + } +} + +extension Value: Codable { + enum EncodedValueCodingKeys: String, CodingKey { + case key + case type + case value + } + + enum EncodedValueTypeCodingKeys: String, Codable { + case boolean + case string + case integer + case double + case date + case list + case structure + case null + + static func fromValue(_ value: Value) -> EncodedValueTypeCodingKeys { + switch value { + case .boolean: + return .boolean + case .string: + return .string + case .integer: + return .integer + case .double: + return .double + case .date: + return .date + case .list: + return .list + case .structure: + return .structure + case .null: + return .null + } + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: EncodedValueCodingKeys.self) + + try Value.encodeValue(value: self, container: &container) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: EncodedValueCodingKeys.self) + + self = try Value.decodeValue(container: container) + } + + static private func encodeValue(value: Value, container: inout KeyedEncodingContainer) + throws + { + try container.encode(EncodedValueTypeCodingKeys.fromValue(value), forKey: .type) + switch value { + case .boolean(let bool): + try container.encode(bool, forKey: .value) + case .string(let string): + try container.encode(string, forKey: .value) + case .integer(let int64): + try container.encode(int64, forKey: .value) + case .double(let double): + try container.encode(double, forKey: .value) + case .date(let date): + try container.encode(date, forKey: .value) + case .list(let values): + var listContainer = container.nestedUnkeyedContainer(forKey: .value) + + for (index, listValue) in values.enumerated() { + var nestedContainer = listContainer.nestedContainer(keyedBy: EncodedValueCodingKeys.self) + try nestedContainer.encode("\(index)", forKey: .key) + try encodeValue(value: listValue, container: &nestedContainer) + } + case .structure(let values): + var mapContainer = container.nestedUnkeyedContainer(forKey: .value) + + for (key, mapValue) in values { + var nestedContainer = mapContainer.nestedContainer(keyedBy: EncodedValueCodingKeys.self) + try nestedContainer.encode(key, forKey: .key) + try encodeValue(value: mapValue, container: &nestedContainer) + } + case .null: + try container.encodeNil(forKey: .value) + } + } + + static private func decodeValue(container: KeyedDecodingContainer) throws -> Value { + let type = try container.decode(EncodedValueTypeCodingKeys.self, forKey: .type) + switch type { + case .boolean: + let value = try container.decode(Bool.self, forKey: .value) + return .boolean(value) + case .string: + let value = try container.decode(String.self, forKey: .value) + return .string(value) + case .integer: + let value = try container.decode(Int64.self, forKey: .value) + return .integer(value) + case .double: + let value = try container.decode(Double.self, forKey: .value) + return .double(value) + case .date: + let value = try container.decode(Date.self, forKey: .value) + return .date(value) + case .list: + var listContainer = try container.nestedUnkeyedContainer(forKey: .value) + + var values: [Value] = [] + while !listContainer.isAtEnd { + let nestedContainer = try listContainer.nestedContainer(keyedBy: EncodedValueCodingKeys.self) + + let element = try decodeValue(container: nestedContainer) + values.append(element) + } + + return .list(values) + case .structure: + var mapContainer = try container.nestedUnkeyedContainer(forKey: .value) + + var values: [String: Value] = [:] + while !mapContainer.isAtEnd { + let nestedContainer = try mapContainer.nestedContainer(keyedBy: EncodedValueCodingKeys.self) + + let key = try nestedContainer.decode(String.self, forKey: .key) + let element = try decodeValue(container: nestedContainer) + values[key] = element + } + + return .structure(values) + case .null: + return .null + } + } +} + +extension Value { + // swiftlint:disable:next identifier_name + public func decode(to: T.Type) throws -> T { + let data = try JSONSerialization.data(withJSONObject: toJson(value: self)) + + return try JSONDecoder().decode(to, from: data) + } + + func toJson(value: Value) -> Any { + switch value { + case .boolean(let bool): + return bool + case .string(let string): + return string + case .integer(let int64): + return int64 + case .double(let double): + return double + case .date(let date): + return date.timeIntervalSinceReferenceDate + case .list(let list): + return list.map(self.toJson) + case .structure(let structure): + return structure.mapValues(self.toJson) + case .null: + return NSNull() + } + } +} diff --git a/Sources/OpenFeature/exceptions/ErrorCode.swift b/Sources/OpenFeature/exceptions/ErrorCode.swift new file mode 100644 index 0000000..23e2a2b --- /dev/null +++ b/Sources/OpenFeature/exceptions/ErrorCode.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum ErrorCode: Int { + case providerNotReady = 1 + case flagNotFound + case parseError + case typeMismatch + case targetingKeyMissing + case invalidContext + case general +} diff --git a/Sources/OpenFeature/exceptions/OpenFeatureError.swift b/Sources/OpenFeature/exceptions/OpenFeatureError.swift new file mode 100644 index 0000000..a4ea9bf --- /dev/null +++ b/Sources/OpenFeature/exceptions/OpenFeatureError.swift @@ -0,0 +1,51 @@ +import Foundation + +public enum OpenFeatureError: Error, Equatable { + case flagNotFoundError(key: String) + case generalError(message: String) + case invalidContextError + case parseError(message: String) + case targetingKeyMissingError + case typeMismatchError + case valueNotConvertableError + + public func errorCode() -> ErrorCode { + switch self { + case .flagNotFoundError: + return .flagNotFound + case .generalError: + return .general + case .invalidContextError: + return .invalidContext + case .parseError: + return .parseError + case .targetingKeyMissingError: + return .targetingKeyMissing + case .typeMismatchError: + return .typeMismatch + case .valueNotConvertableError: + return .general + } + } +} + +extension OpenFeatureError: CustomStringConvertible { + public var description: String { + switch self { + case .flagNotFoundError(let key): + return "Could not find flag for key: \(key)" + case .generalError(let message): + return "General error: \(message)" + case .invalidContextError: + return "Invalid context" + case .parseError(let message): + return "Parse error: \(message)" + case .targetingKeyMissingError: + return "Targeting key missing in resolve" + case .typeMismatchError: + return "Type mismatch" + case .valueNotConvertableError: + return "Could not convert value" + } + } +} diff --git a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift new file mode 100644 index 0000000..b1eeb6d --- /dev/null +++ b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift @@ -0,0 +1,85 @@ +import XCTest + +@testable import OpenFeature + +final class DeveloperExperienceTests: XCTestCase { + func testNoProviderSet() { + OpenFeatureAPI.shared.provider = nil + let client = OpenFeatureAPI.shared.getClient() + + let flagValue = client.getStringValue(key: "test", defaultValue: "no-op") + XCTAssertEqual(flagValue, "no-op") + } + + func testSimpleBooleanFlag() { + OpenFeatureAPI.shared.provider = NoOpProvider() + let client = OpenFeatureAPI.shared.getClient() + + let flagValue = client.getBooleanValue(key: "test", defaultValue: false) + XCTAssertFalse(flagValue) + } + + func testClientHooks() { + OpenFeatureAPI.shared.provider = NoOpProvider() + let client = OpenFeatureAPI.shared.getClient() + + let hook = BooleanHookMock() + client.addHooks(.boolean(hook)) + + _ = client.getBooleanValue(key: "test", defaultValue: false) + XCTAssertEqual(hook.finallyAfterCalled, 1) + } + + func testEvalHooks() { + OpenFeatureAPI.shared.provider = NoOpProvider() + let client = OpenFeatureAPI.shared.getClient() + + let hook = BooleanHookMock() + let options = FlagEvaluationOptions(hooks: [.boolean(hook)]) + _ = client.getBooleanValue(key: "test", defaultValue: false, ctx: nil, options: options) + + XCTAssertEqual(hook.finallyAfterCalled, 1) + } + + func testProvidingContext() { + let provider = NoOpProviderMock() + OpenFeatureAPI.shared.provider = provider + let client = OpenFeatureAPI.shared.getClient() + + let ctx = MutableContext() + .add(key: "int-val", value: .integer(3)) + .add(key: "double-val", value: .double(4.0)) + .add(key: "bool-val", value: .boolean(false)) + .add(key: "str-val", value: .string("test")) + .add(key: "value-val", value: .list([.integer(2), .integer(4)])) + + _ = client.getBooleanValue(key: "test", defaultValue: false, ctx: ctx) + + XCTAssertEqual(ctx.asMap(), provider.ctxWhenCalled?.asMap()) + } + + func testBrokenProvider() { + OpenFeatureAPI.shared.provider = AlwaysBrokenProvider() + let client = OpenFeatureAPI.shared.getClient() + + let details = client.getBooleanDetails(key: "test", defaultValue: false) + + XCTAssertEqual(details.errorCode, .flagNotFound) + XCTAssertEqual(details.errorMessage, "Could not find flag for key: test") + XCTAssertEqual(details.reason, Reason.error.rawValue) + } +} + +extension DeveloperExperienceTests { + class NoOpProviderMock: NoOpProvider { + var ctxWhenCalled: EvaluationContext? + + override func getBooleanEvaluation(key: String, defaultValue: Bool, ctx: EvaluationContext) throws + -> ProviderEvaluation + { + self.ctxWhenCalled = ctx + + return try super.getBooleanEvaluation(key: key, defaultValue: defaultValue, ctx: ctx) + } + } +} diff --git a/Tests/OpenFeatureTests/EvalContextTests.swift b/Tests/OpenFeatureTests/EvalContextTests.swift new file mode 100644 index 0000000..905cee5 --- /dev/null +++ b/Tests/OpenFeatureTests/EvalContextTests.swift @@ -0,0 +1,157 @@ +import Foundation +import OpenFeature +import XCTest + +final class EvalContextTests: XCTestCase { + func testContextStoresTargetingKey() { + let ctx = MutableContext() + ctx.setTargetingKey(targetingKey: "test") + XCTAssertEqual(ctx.getTargetingKey(), "test") + } + + func testContextStoresPrimitiveValues() { + let ctx = MutableContext() + + ctx.add(key: "string", value: .string("value")) + XCTAssertEqual(ctx.getValue(key: "string")?.asString(), "value") + + ctx.add(key: "bool", value: .boolean(true)) + XCTAssertEqual(ctx.getValue(key: "bool")?.asBoolean(), true) + + ctx.add(key: "int", value: .integer(3)) + XCTAssertEqual(ctx.getValue(key: "int")?.asInteger(), 3) + + ctx.add(key: "double", value: .double(3.14)) + XCTAssertEqual(ctx.getValue(key: "double")?.asDouble(), 3.14) + + let date = Date.now + ctx.add(key: "date", value: .date(date)) + XCTAssertEqual(ctx.getValue(key: "date")?.asDate(), date) + } + + func testContextStoresLists() { + let ctx = MutableContext() + + ctx.add(key: "list", value: .list([.integer(3), .integer(4)])) + XCTAssertEqual(ctx.getValue(key: "list")?.asList()?[0].asInteger(), 3) + XCTAssertEqual(ctx.getValue(key: "list")?.asList()?[1].asInteger(), 4) + } + + func testContextStoresStructures() { + let ctx = MutableContext() + + ctx.add(key: "struct", value: .structure(["string": .string("test"), "int": .integer(3)])) + XCTAssertEqual(ctx.getValue(key: "struct")?.asStructure()?["string"]?.asString(), "test") + XCTAssertEqual(ctx.getValue(key: "struct")?.asStructure()?["int"]?.asInteger(), 3) + } + + func testContextCanConvertToMap() { + let ctx = MutableContext() + + ctx.add(key: "str", value: .string("test")) + ctx.add(key: "str2", value: .string("test2")) + + ctx.add(key: "bool", value: .boolean(true)) + ctx.add(key: "bool2", value: .boolean(false)) + + ctx.add(key: "int", value: .integer(4)) + ctx.add(key: "int2", value: .integer(2)) + + let date = Date.now + ctx.add(key: "dt", value: .date(date)) + + ctx.add(key: "obj", value: .structure(["val1": .integer(1), "val2": .string("2")])) + + let map = ctx.asMap() + XCTAssertEqual(map["str"]?.asString(), "test") + XCTAssertEqual(map["str2"]?.asString(), "test2") + + XCTAssertEqual(map["bool"]?.asBoolean(), true) + XCTAssertEqual(map["bool2"]?.asBoolean(), false) + + XCTAssertEqual(map["int"]?.asInteger(), 4) + XCTAssertEqual(map["int2"]?.asInteger(), 2) + + XCTAssertEqual(map["dt"]?.asDate(), date) + + let structure = map["obj"]?.asStructure() + XCTAssertEqual(structure?["val1"]?.asInteger(), 1) + XCTAssertEqual(structure?["val2"]?.asString(), "2") + } + + func testContextHasUniqueKeyAcrossTypes() { + let ctx = MutableContext() + + ctx.add(key: "key", value: .string("val")) + ctx.add(key: "key", value: .string("val2")) + XCTAssertEqual(ctx.getValue(key: "key")?.asString(), "val2") + + ctx.add(key: "key", value: .integer(3)) + XCTAssertNil(ctx.getValue(key: "key")?.asString()) + XCTAssertEqual(ctx.getValue(key: "key")?.asInteger(), 3) + } + + func testContextCanChainAttributeAddition() { + let ctx = MutableContext() + + let result = + ctx + .add(key: "key1", value: .string("val")) + .add(key: "key2", value: .string("val2")) + + XCTAssertEqual(result.getValue(key: "key1")?.asString(), "val") + XCTAssertEqual(result.getValue(key: "key2")?.asString(), "val2") + } + + func testContextCanAddNull() { + let ctx = MutableContext() + + ctx.add(key: "null", value: .null) + + XCTAssertEqual(ctx.getValue(key: "null")?.isNull(), true) + XCTAssertNil(ctx.getValue(key: "null")?.asString()) + } + + func testContextCanMergeTargetingKey() { + let key1 = "key1" + let ctx1 = MutableContext(targetingKey: key1) + let ctx2 = MutableContext() + + let merged = ctx1.merge(overridingContext: ctx2) + XCTAssertEqual(merged.getTargetingKey(), key1) + + let key2 = "key2" + ctx2.setTargetingKey(targetingKey: "key2") + let merged2 = ctx1.merge(overridingContext: ctx2) + XCTAssertEqual(merged2.getTargetingKey(), key2) + + ctx2.setTargetingKey(targetingKey: " ") + let merged3 = ctx1.merge(overridingContext: ctx2) + XCTAssertEqual(merged3.getTargetingKey(), key1) + } + + func testContextConvertsToObjectMap() { + let key1 = "key1" + let date = Date.now + let ctx = MutableContext(targetingKey: key1) + ctx.add(key: "string", value: .string("value")) + ctx.add(key: "bool", value: .boolean(false)) + ctx.add(key: "integer", value: .integer(1)) + ctx.add(key: "double", value: .double(1.2)) + ctx.add(key: "date", value: .date(date)) + ctx.add(key: "list", value: .list([.string("item1"), .string("item2")])) + ctx.add(key: "structure", value: .structure(["field1": .integer(3), "field2": .double(3.14)])) + + let expected: [String: AnyHashable] = [ + "string": "value", + "bool": false, + "integer": 1, + "double": 1.2, + "date": date, + "list": ["item1", "item2"], + "structure": ["field1": 3, "field2": 3.14], + ] + + XCTAssertEqual(ctx.asObjectMap(), expected) + } +} diff --git a/Tests/OpenFeatureTests/FlagEvaluationTests.swift b/Tests/OpenFeatureTests/FlagEvaluationTests.swift new file mode 100644 index 0000000..d1ed25e --- /dev/null +++ b/Tests/OpenFeatureTests/FlagEvaluationTests.swift @@ -0,0 +1,202 @@ +import Foundation +import XCTest + +@testable import OpenFeature + +final class FlagEvaluationTests: XCTestCase { + func testSingletonPersists() { + XCTAssertTrue(OpenFeatureAPI.shared === OpenFeatureAPI.shared) + } + + func testApiSetsProvider() { + let provider = NoOpProvider() + OpenFeatureAPI.shared.provider = provider + + XCTAssertTrue((OpenFeatureAPI.shared.provider as? NoOpProvider) === provider) + } + + func testProviderMetadata() { + OpenFeatureAPI.shared.provider = DoSomethingProvider() + + XCTAssertEqual(OpenFeatureAPI.shared.getProviderMetadata()?.name, DoSomethingProvider.name) + } + + func testHooksPersist() { + let hook1: AnyHook = .boolean(BooleanHookMock()) + let hook2: AnyHook = .boolean(BooleanHookMock()) + + OpenFeatureAPI.shared.addHooks(hooks: hook1) + + XCTAssertEqual(OpenFeatureAPI.shared.hooks.count, 1) + + OpenFeatureAPI.shared.addHooks(hooks: hook2) + XCTAssertEqual(OpenFeatureAPI.shared.hooks.count, 2) + } + + func testNamedClient() { + let client = OpenFeatureAPI.shared.getClient(name: "test", version: nil) + XCTAssertEqual((client as? OpenFeatureClient)?.name, "test") + } + + func testClientHooksPersist() { + let hook1: AnyHook = .boolean(BooleanHookMock()) + let hook2: AnyHook = .boolean(BooleanHookMock()) + + let client = OpenFeatureAPI.shared.getClient() + client.addHooks(hook1) + + XCTAssertEqual(client.hooks.count, 1) + + client.addHooks(hook2) + XCTAssertEqual(client.hooks.count, 2) + } + + func testSimpleFlagEvaluation() { + OpenFeatureAPI.shared.provider = DoSomethingProvider() + let client = OpenFeatureAPI.shared.getClient() + let key = "key" + + XCTAssertEqual(client.getBooleanValue(key: key, defaultValue: false), true) + XCTAssertEqual(client.getBooleanValue(key: key, defaultValue: false, ctx: MutableContext()), true) + XCTAssertEqual( + client.getBooleanValue( + key: key, defaultValue: false, ctx: MutableContext(), options: FlagEvaluationOptions()), true) + + XCTAssertEqual(client.getStringValue(key: key, defaultValue: "test"), "tset") + XCTAssertEqual(client.getStringValue(key: key, defaultValue: "test", ctx: MutableContext()), "tset") + XCTAssertEqual( + client.getStringValue( + key: key, defaultValue: "test", ctx: MutableContext(), options: FlagEvaluationOptions()), "tset") + + XCTAssertEqual(client.getIntegerValue(key: key, defaultValue: 4), 400) + XCTAssertEqual(client.getIntegerValue(key: key, defaultValue: 4, ctx: MutableContext()), 400) + XCTAssertEqual( + client.getIntegerValue(key: key, defaultValue: 4, ctx: MutableContext(), options: FlagEvaluationOptions()), + 400) + + XCTAssertEqual(client.getDoubleValue(key: key, defaultValue: 0.4), 40.0) + XCTAssertEqual(client.getDoubleValue(key: key, defaultValue: 0.4, ctx: MutableContext()), 40.0) + XCTAssertEqual( + client.getDoubleValue(key: key, defaultValue: 0.4, ctx: MutableContext(), options: FlagEvaluationOptions()), + 40.0) + + XCTAssertEqual(client.getObjectValue(key: key, defaultValue: .structure([:])), .null) + XCTAssertEqual(client.getObjectValue(key: key, defaultValue: .structure([:]), ctx: MutableContext()), .null) + XCTAssertEqual( + client.getObjectValue( + key: key, defaultValue: .structure([:]), ctx: MutableContext(), options: FlagEvaluationOptions()), .null + ) + } + + func testDetailedFlagEvaluation() { + OpenFeatureAPI.shared.provider = DoSomethingProvider() + let client = OpenFeatureAPI.shared.getClient() + let key = "key" + + let booleanDetails = FlagEvaluationDetails(flagKey: key, value: true, variant: nil) + XCTAssertEqual(client.getBooleanDetails(key: key, defaultValue: false), booleanDetails) + XCTAssertEqual(client.getBooleanDetails(key: key, defaultValue: false, ctx: MutableContext()), booleanDetails) + XCTAssertEqual( + client.getBooleanDetails( + key: key, defaultValue: false, ctx: MutableContext(), options: FlagEvaluationOptions()), booleanDetails) + + let stringDetails = FlagEvaluationDetails(flagKey: key, value: "tset", variant: nil) + XCTAssertEqual(client.getStringDetails(key: key, defaultValue: "test"), stringDetails) + XCTAssertEqual(client.getStringDetails(key: key, defaultValue: "test", ctx: MutableContext()), stringDetails) + XCTAssertEqual( + client.getStringDetails( + key: key, defaultValue: "test", ctx: MutableContext(), options: FlagEvaluationOptions()), stringDetails) + + let integerDetails = FlagEvaluationDetails(flagKey: key, value: Int64(400), variant: nil) + XCTAssertEqual(client.getIntegerDetails(key: key, defaultValue: 4), integerDetails) + XCTAssertEqual(client.getIntegerDetails(key: key, defaultValue: 4, ctx: MutableContext()), integerDetails) + XCTAssertEqual( + client.getIntegerDetails( + key: key, defaultValue: 4, ctx: MutableContext(), options: FlagEvaluationOptions()), integerDetails) + + let doubleDetails = FlagEvaluationDetails(flagKey: key, value: 40.0, variant: nil) + XCTAssertEqual(client.getDoubleDetails(key: key, defaultValue: 0.4), doubleDetails) + XCTAssertEqual(client.getDoubleDetails(key: key, defaultValue: 0.4, ctx: MutableContext()), doubleDetails) + XCTAssertEqual( + client.getDoubleDetails( + key: key, defaultValue: 0.4, ctx: MutableContext(), options: FlagEvaluationOptions()), doubleDetails) + + let objectDetails = FlagEvaluationDetails(flagKey: key, value: Value.null, variant: nil) + XCTAssertEqual(client.getObjectDetails(key: key, defaultValue: .structure([:])), objectDetails) + XCTAssertEqual( + client.getObjectDetails(key: key, defaultValue: .structure([:]), ctx: MutableContext()), objectDetails) + XCTAssertEqual( + client.getObjectDetails( + key: key, defaultValue: .structure([:]), ctx: MutableContext(), options: FlagEvaluationOptions()), + objectDetails) + } + + func testHooksAreFired() { + OpenFeatureAPI.shared.provider = NoOpProvider() + let client = OpenFeatureAPI.shared.getClient() + + let clientHook = BooleanHookMock() + let invocationHook = BooleanHookMock() + + client.addHooks(.boolean(clientHook)) + _ = client.getBooleanValue( + key: "key", + defaultValue: false, + ctx: MutableContext(), + options: FlagEvaluationOptions(hooks: [.boolean(invocationHook)])) + + XCTAssertEqual(clientHook.beforeCalled, 1) + XCTAssertEqual(invocationHook.beforeCalled, 1) + } + + func testBrokenProvider() { + OpenFeatureAPI.shared.provider = AlwaysBrokenProvider() + let client = OpenFeatureAPI.shared.getClient() + + XCTAssertFalse(client.getBooleanValue(key: "testkey", defaultValue: false)) + let details = client.getBooleanDetails(key: "testkey", defaultValue: false) + + XCTAssertEqual(details.errorCode, .flagNotFound) + XCTAssertEqual(details.reason, Reason.error.rawValue) + XCTAssertEqual(details.errorMessage, "Could not find flag for key: testkey") + } + + func testClientMetadata() { + let client1 = OpenFeatureAPI.shared.getClient() + XCTAssertNil(client1.metadata.name) + + let client = OpenFeatureAPI.shared.getClient(name: "test", version: nil) + XCTAssertEqual(client.metadata.name, "test") + } + + func testMultilayerContextMergesCorrectly() { + let provider = DoSomethingProvider() + OpenFeatureAPI.shared.provider = provider + + let apiCtx = MutableContext() + apiCtx.add(key: "common", value: .string("1")) + apiCtx.add(key: "common2", value: .string("1")) + apiCtx.add(key: "api", value: .string("2")) + OpenFeatureAPI.shared.evaluationContext = apiCtx + + var client = OpenFeatureAPI.shared.getClient() + let clientCtx = MutableContext() + clientCtx.add(key: "common", value: .string("3")) + clientCtx.add(key: "common2", value: .string("3")) + clientCtx.add(key: "client", value: .string("4")) + client.evaluationContext = clientCtx + + let invocationCtx = MutableContext() + invocationCtx.add(key: "common", value: .string("5")) + invocationCtx.add(key: "invocation", value: .string("6")) + + _ = client.getBooleanValue(key: "key", defaultValue: false, ctx: invocationCtx) + + let merged = provider.mergedContext + XCTAssertEqual(merged?.getValue(key: "invocation")?.asString(), "6") + XCTAssertEqual(merged?.getValue(key: "common")?.asString(), "5") + XCTAssertEqual(merged?.getValue(key: "client")?.asString(), "4") + XCTAssertEqual(merged?.getValue(key: "common2")?.asString(), "3") + XCTAssertEqual(merged?.getValue(key: "api")?.asString(), "2") + } +} diff --git a/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift b/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift new file mode 100644 index 0000000..413afa4 --- /dev/null +++ b/Tests/OpenFeatureTests/Helpers/AlwaysBrokenProvider.swift @@ -0,0 +1,44 @@ +import Foundation + +@testable import OpenFeature + +class AlwaysBrokenProvider: FeatureProvider { + var metadata: Metadata = AlwaysBrokenMetadata() + var hooks: [AnyHook] = [] + + func getBooleanEvaluation(key: String, defaultValue: Bool, ctx: OpenFeature.EvaluationContext) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + func getStringEvaluation(key: String, defaultValue: String, ctx: OpenFeature.EvaluationContext) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + func getIntegerEvaluation(key: String, defaultValue: Int64, ctx: OpenFeature.EvaluationContext) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + func getDoubleEvaluation(key: String, defaultValue: Double, ctx: OpenFeature.EvaluationContext) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } + + func getObjectEvaluation(key: String, defaultValue: OpenFeature.Value, ctx: OpenFeature.EvaluationContext) throws + -> OpenFeature.ProviderEvaluation + { + throw OpenFeatureError.flagNotFoundError(key: key) + } +} + +extension AlwaysBrokenProvider { + struct AlwaysBrokenMetadata: Metadata { + var name: String? = "test" + } +} diff --git a/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift b/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift new file mode 100644 index 0000000..dacf11f --- /dev/null +++ b/Tests/OpenFeatureTests/Helpers/BooleanHookMock.swift @@ -0,0 +1,48 @@ +import Foundation +import OpenFeature + +class BooleanHookMock: BooleanHook { + public var beforeCalled = 0 + public var afterCalled = 0 + public var finallyAfterCalled = 0 + public var errorCalled = 0 + + private var prefix: String + private var addEval: (String) -> Void + + init() { + self.prefix = "" + self.addEval = { _ in } + } + + init(prefix: String, addEval: @escaping (String) -> Void) { + self.prefix = prefix + self.addEval = addEval + } + + func before(ctx: HookContext, hints: [String: Any]) -> EvaluationContext? { + beforeCalled += 1 + self.addEval(self.prefix.isEmpty ? "before" : "\(self.prefix) before") + + return nil + } + + func after(ctx: HookContext, details: FlagEvaluationDetails, hints: [String: Any]) { + afterCalled += 1 + self.addEval(self.prefix.isEmpty ? "after" : "\(self.prefix) after") + } + + func error(ctx: HookContext, error: Error, hints: [String: Any]) { + errorCalled += 1 + self.addEval(self.prefix.isEmpty ? "error" : "\(self.prefix) error") + } + + func finallyAfter(ctx: HookContext, hints: [String: Any]) { + finallyAfterCalled += 1 + self.addEval(self.prefix.isEmpty ? "finallyAfter" : "\(self.prefix) finallyAfter") + } + + func supportsFlagValueType(flagValueType: FlagValueType) -> Bool { + return true + } +} diff --git a/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift b/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift new file mode 100644 index 0000000..9a1f150 --- /dev/null +++ b/Tests/OpenFeatureTests/Helpers/DoSomethingProvider.swift @@ -0,0 +1,52 @@ +import Foundation +import OpenFeature + +class DoSomethingProvider: FeatureProvider { + public static let name = "Something" + private var savedContext: EvaluationContext? + var mergedContext: EvaluationContext? { + savedContext + } + + var hooks: [OpenFeature.AnyHook] = [] + var metadata: OpenFeature.Metadata = DoMetadata() + + func getBooleanEvaluation(key: String, defaultValue: Bool, ctx: EvaluationContext) throws -> ProviderEvaluation< + Bool + > { + savedContext = ctx + return ProviderEvaluation(value: !defaultValue) + } + + func getStringEvaluation(key: String, defaultValue: String, ctx: EvaluationContext) throws -> ProviderEvaluation< + String + > { + savedContext = ctx + return ProviderEvaluation(value: String(defaultValue.reversed())) + } + + func getIntegerEvaluation(key: String, defaultValue: Int64, ctx: EvaluationContext) throws -> ProviderEvaluation< + Int64 + > { + savedContext = ctx + return ProviderEvaluation(value: defaultValue * 100) + } + + func getDoubleEvaluation(key: String, defaultValue: Double, ctx: EvaluationContext) throws -> ProviderEvaluation< + Double + > { + savedContext = ctx + return ProviderEvaluation(value: defaultValue * 100) + } + + func getObjectEvaluation(key: String, defaultValue: Value, ctx: EvaluationContext) throws -> ProviderEvaluation< + Value + > { + savedContext = ctx + return ProviderEvaluation(value: .null) + } + + public struct DoMetadata: Metadata { + public var name: String? = DoSomethingProvider.name + } +} diff --git a/Tests/OpenFeatureTests/HookSpecTests.swift b/Tests/OpenFeatureTests/HookSpecTests.swift new file mode 100644 index 0000000..563415f --- /dev/null +++ b/Tests/OpenFeatureTests/HookSpecTests.swift @@ -0,0 +1,87 @@ +import Foundation +import XCTest + +@testable import OpenFeature + +final class HookSpecTests: XCTestCase { + func testNoErrorHookCalled() { + OpenFeatureAPI.shared.provider = NoOpProvider() + let client = OpenFeatureAPI.shared.getClient() + + let hook = BooleanHookMock() + + _ = client.getBooleanValue( + key: "key", + defaultValue: false, + ctx: MutableContext(), + options: FlagEvaluationOptions(hooks: [.boolean(hook)])) + + XCTAssertEqual(hook.beforeCalled, 1) + XCTAssertEqual(hook.afterCalled, 1) + XCTAssertEqual(hook.errorCalled, 0) + XCTAssertEqual(hook.finallyAfterCalled, 1) + } + + func testErrorHookButNoAfterCalled() { + OpenFeatureAPI.shared.provider = AlwaysBrokenProvider() + let client = OpenFeatureAPI.shared.getClient() + + let hook = BooleanHookMock() + + _ = client.getBooleanValue( + key: "key", + defaultValue: false, + ctx: MutableContext(), + options: FlagEvaluationOptions(hooks: [.boolean(hook)])) + + XCTAssertEqual(hook.beforeCalled, 1) + XCTAssertEqual(hook.afterCalled, 0) + XCTAssertEqual(hook.errorCalled, 1) + XCTAssertEqual(hook.finallyAfterCalled, 1) + } + + func testHookEvaluationOrder() { + var evalOrder: [String] = [] + let addEval: (String) -> Void = { eval in + evalOrder.append(eval) + } + + OpenFeatureAPI.shared.provider = NoOpProviderMock(hooks: [ + .boolean(BooleanHookMock(prefix: "provider", addEval: addEval)) + ]) + OpenFeatureAPI.shared.addHooks(hooks: .boolean(BooleanHookMock(prefix: "api", addEval: addEval))) + let client = OpenFeatureAPI.shared.getClient() + client.addHooks(.boolean(BooleanHookMock(prefix: "client", addEval: addEval))) + let flagOptions = FlagEvaluationOptions(hooks: [ + .boolean(BooleanHookMock(prefix: "invocation", addEval: addEval)) + ]) + + _ = client.getBooleanValue(key: "key", defaultValue: false, ctx: MutableContext(), options: flagOptions) + + XCTAssertEqual( + evalOrder, + [ + "api before", + "client before", + "invocation before", + "provider before", + "provider after", + "invocation after", + "client after", + "api after", + "provider finallyAfter", + "invocation finallyAfter", + "client finallyAfter", + "api finallyAfter", + ]) + } +} + +extension HookSpecTests { + class NoOpProviderMock: NoOpProvider { + init(hooks: [AnyHook]) { + super.init() + self.hooks.append(contentsOf: hooks) + } + } +} diff --git a/Tests/OpenFeatureTests/HookSupportTests.swift b/Tests/OpenFeatureTests/HookSupportTests.swift new file mode 100644 index 0000000..acb2111 --- /dev/null +++ b/Tests/OpenFeatureTests/HookSupportTests.swift @@ -0,0 +1,89 @@ +import Foundation +import XCTest + +@testable import OpenFeature + +final class HookSupportTests: XCTestCase { + func testShouldMergeEvaluationContextsOnBeforeHooks() { + let metadata = OpenFeatureAPI.shared.getClient().metadata + let baseContext = MutableContext() + baseContext.add(key: "baseKey", value: .string("baseValue")) + + let hook1: AnyHook = .string(StringHookMock(key: "bla", value: "blubber")) + let hook2: AnyHook = .string(StringHookMock(key: "foo", value: "bar")) + + let hookSupport = HookSupport() + let hookContext: HookContext = HookContext( + flagKey: "flagKey", + type: .string, + defaultValue: "defaultValue", + ctx: baseContext, + clientMetadata: metadata, + providerMetadata: NoOpProvider().metadata) + + let result = hookSupport.beforeHooks( + flagValueType: .string, hookCtx: hookContext, hooks: [hook1, hook2], hints: [:]) + + XCTAssertEqual(result.getValue(key: "bla")?.asString(), "blubber") + XCTAssertEqual(result.getValue(key: "foo")?.asString(), "bar") + XCTAssertEqual(result.getValue(key: "baseKey")?.asString(), "baseValue") + } + + func testShouldAlwaysCallGenericHook() throws { + let metadata = OpenFeatureAPI.shared.getClient().metadata + let hook = BooleanHookMock() + let boolHook: AnyHook = .boolean(hook) + let hookContext: HookContext = HookContext( + flagKey: "flagKey", + type: .boolean, + defaultValue: false, + ctx: MutableContext(), + clientMetadata: metadata, + providerMetadata: NoOpProvider().metadata) + + let hookSupport = HookSupport() + + _ = hookSupport.beforeHooks( + flagValueType: .boolean, + hookCtx: hookContext, + hooks: [boolHook], + hints: [:]) + try hookSupport.afterHooks( + flagValueType: .boolean, + hookCtx: hookContext, + details: FlagEvaluationDetails(flagKey: "", value: false), + hooks: [boolHook], + hints: [:]) + hookSupport.afterAllHooks( + flagValueType: .boolean, + hookCtx: hookContext, + hooks: [boolHook], + hints: [:]) + hookSupport.errorHooks( + flagValueType: .boolean, + hookCtx: hookContext, + error: OpenFeatureError.invalidContextError, + hooks: [boolHook], + hints: [:]) + + XCTAssertEqual(hook.beforeCalled, 1) + XCTAssertEqual(hook.afterCalled, 1) + XCTAssertEqual(hook.finallyAfterCalled, 1) + XCTAssertEqual(hook.errorCalled, 1) + } +} +extension HookSupportTests { + class StringHookMock: StringHook { + private var value: EvaluationContext + + init(key: String, value: String) { + let ctx = MutableContext() + ctx.add(key: key, value: .string(value)) + self.value = ctx + } + + public func before(ctx: HookContext, hints: [String: Any]) -> EvaluationContext? { + return value + } + } +} diff --git a/Tests/OpenFeatureTests/OpenFeatureClientTests.swift b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift new file mode 100644 index 0000000..05bf15a --- /dev/null +++ b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift @@ -0,0 +1,88 @@ +import Foundation +import XCTest + +@testable import OpenFeature + +final class OpenFeatureClientTests: XCTestCase { + func testShouldNowThrowIfHookHasDifferentTypeArgument() { + OpenFeatureAPI.shared.provider = DoSomethingProvider() + OpenFeatureAPI.shared.addHooks(hooks: .boolean(BooleanHookMock())) + + let client = OpenFeatureAPI.shared.getClient() + + let details = client.getStringDetails(key: "key", defaultValue: "test") + + XCTAssertEqual(details.value, "tset") + } + + func testMergeContexts() { + let targetingKey = "targetingKey" + OpenFeatureAPI.shared.provider = TestProvider(targetingKey: targetingKey) + let ctx = MutableContext(targetingKey: targetingKey) + + var client = OpenFeatureAPI.shared.getClient() + client.evaluationContext = ctx + + let details = client.getBooleanDetails(key: "flag", defaultValue: false) + + XCTAssertEqual(details.value, true) + } +} + +extension OpenFeatureClientTests { + class BooleanHookMock: BooleanHook { + var numCalls = 0 + public func finallyAfter(ctx: HookContext, hints: [String: Any]) { + numCalls += 1 + } + } + + class TestProvider: FeatureProvider { + var hooks: [OpenFeature.AnyHook] = [] + + var metadata: OpenFeature.Metadata = TestMetadata() + private var targetingKey: String + + init(targetingKey: String) { + self.targetingKey = targetingKey + } + + func getBooleanEvaluation(key: String, defaultValue: Bool, ctx: OpenFeature.EvaluationContext) throws + -> OpenFeature.ProviderEvaluation + { + if ctx.getTargetingKey() == self.targetingKey { + return ProviderEvaluation(value: true) + } else { + return ProviderEvaluation(value: false) + } + } + + func getStringEvaluation(key: String, defaultValue: String, ctx: OpenFeature.EvaluationContext) throws + -> OpenFeature.ProviderEvaluation + { + return ProviderEvaluation(value: "") + } + + func getIntegerEvaluation(key: String, defaultValue: Int64, ctx: OpenFeature.EvaluationContext) throws + -> OpenFeature.ProviderEvaluation + { + return ProviderEvaluation(value: 0) + } + + func getDoubleEvaluation(key: String, defaultValue: Double, ctx: OpenFeature.EvaluationContext) throws + -> OpenFeature.ProviderEvaluation + { + return ProviderEvaluation(value: 0.0) + } + + func getObjectEvaluation(key: String, defaultValue: OpenFeature.Value, ctx: OpenFeature.EvaluationContext) + throws -> OpenFeature.ProviderEvaluation + { + return ProviderEvaluation(value: .null) + } + } + + public struct TestMetadata: Metadata { + public var name: String? = "test" + } +} diff --git a/Tests/OpenFeatureTests/ProviderSpecTests.swift b/Tests/OpenFeatureTests/ProviderSpecTests.swift new file mode 100644 index 0000000..305b7ff --- /dev/null +++ b/Tests/OpenFeatureTests/ProviderSpecTests.swift @@ -0,0 +1,58 @@ +import Foundation +import XCTest + +@testable import OpenFeature + +final class ProviderSpecTests: XCTestCase { + func testFlagValueSet() throws { + let provider = NoOpProvider() + + let boolResult = try provider.getBooleanEvaluation(key: "key", defaultValue: false, ctx: MutableContext()) + XCTAssertNotNil(boolResult.value) + + let stringResult = try provider.getStringEvaluation(key: "key", defaultValue: "test", ctx: MutableContext()) + XCTAssertNotNil(stringResult.value) + + let intResult = try provider.getIntegerEvaluation(key: "key", defaultValue: 4, ctx: MutableContext()) + XCTAssertNotNil(intResult.value) + + let doubleResult = try provider.getDoubleEvaluation(key: "key", defaultValue: 0.4, ctx: MutableContext()) + XCTAssertNotNil(doubleResult.value) + + let objectResult = try provider.getObjectEvaluation(key: "key", defaultValue: .null, ctx: MutableContext()) + XCTAssertNotNil(objectResult.value) + } + + func testHasReason() throws { + let provider = NoOpProvider() + + let boolResult = try provider.getBooleanEvaluation(key: "key", defaultValue: false, ctx: MutableContext()) + XCTAssertEqual(boolResult.reason, Reason.defaultReason.rawValue) + } + + func testNoErrorCodeByDefault() throws { + let provider = NoOpProvider() + + let boolResult = try provider.getBooleanEvaluation(key: "key", defaultValue: false, ctx: MutableContext()) + XCTAssertNil(boolResult.errorCode) + } + + func testVariantIsSet() throws { + let provider = NoOpProvider() + + let boolResult = try provider.getBooleanEvaluation(key: "key", defaultValue: false, ctx: MutableContext()) + XCTAssertNotNil(boolResult.variant) + + let stringResult = try provider.getStringEvaluation(key: "key", defaultValue: "test", ctx: MutableContext()) + XCTAssertNotNil(stringResult.variant) + + let intResult = try provider.getIntegerEvaluation(key: "key", defaultValue: 4, ctx: MutableContext()) + XCTAssertNotNil(intResult.variant) + + let doubleResult = try provider.getDoubleEvaluation(key: "key", defaultValue: 0.4, ctx: MutableContext()) + XCTAssertNotNil(doubleResult.variant) + + let objectResult = try provider.getObjectEvaluation(key: "key", defaultValue: .null, ctx: MutableContext()) + XCTAssertNotNil(objectResult.variant) + } +} diff --git a/Tests/OpenFeatureTests/StructureTests.swift b/Tests/OpenFeatureTests/StructureTests.swift new file mode 100644 index 0000000..359556a --- /dev/null +++ b/Tests/OpenFeatureTests/StructureTests.swift @@ -0,0 +1,39 @@ +import Foundation +import OpenFeature +import XCTest + +final class StructureTests: XCTestCase { + func testNoArgIsEmpty() { + let structure = MutableStructure() + XCTAssertTrue(structure.asMap().keys.isEmpty) + } + + func testArgShouldContainNewMap() { + let map = ["key": Value.string("test")] + + let structure = MutableStructure(attributes: map) + + XCTAssertEqual(structure.getValue(key: "key")?.asString(), "test") + XCTAssertEqual(structure.asMap(), map) + } + + func testAddAndGetReturnValues() { + let date = Date.now + let structure = MutableStructure() + structure.add(key: "bool", value: .boolean(true)) + structure.add(key: "string", value: .string("val")) + structure.add(key: "int", value: .integer(13)) + structure.add(key: "double", value: .double(0.5)) + structure.add(key: "date", value: .date(date)) + structure.add(key: "list", value: .list([])) + structure.add(key: "structure", value: .structure([:])) + + XCTAssertEqual(structure.getValue(key: "bool")?.asBoolean(), true) + XCTAssertEqual(structure.getValue(key: "string")?.asString(), "val") + XCTAssertEqual(structure.getValue(key: "int")?.asInteger(), 13) + XCTAssertEqual(structure.getValue(key: "double")?.asDouble(), 0.5) + XCTAssertEqual(structure.getValue(key: "date")?.asDate(), date) + XCTAssertEqual(structure.getValue(key: "list")?.asList(), []) + XCTAssertEqual(structure.getValue(key: "structure")?.asStructure(), [:]) + } +} diff --git a/Tests/OpenFeatureTests/ValueTests.swift b/Tests/OpenFeatureTests/ValueTests.swift new file mode 100644 index 0000000..702cb4a --- /dev/null +++ b/Tests/OpenFeatureTests/ValueTests.swift @@ -0,0 +1,103 @@ +import OpenFeature +import XCTest + +final class ValueTests: XCTestCase { + func testNull() { + let value = Value.null + XCTAssertTrue(value.isNull()) + } + + func testIntShouldConvertToInt() { + let value: Value = .integer(3) + XCTAssertEqual(value.asInteger(), 3) + } + + func testDoubleShouldConvertToDouble() { + let value: Value = .double(3.14) + XCTAssertEqual(value.asDouble(), 3.14) + } + + func testBoolShouldConvertToBool() { + let value: Value = .boolean(true) + XCTAssertEqual(value.asBoolean(), true) + } + + func testStringShouldConvertToString() { + let value: Value = .string("test") + XCTAssertEqual(value.asString(), "test") + } + + func testListShouldConvertToList() { + let value: Value = .list([.integer(3), .integer(4)]) + XCTAssertEqual(value.asList(), [.integer(3), .integer(4)]) + } + + func testStructShouldConvertToStruct() { + let value: Value = .structure(["field1": .integer(3), "field2": .string("test")]) + XCTAssertEqual(value.asStructure(), ["field1": .integer(3), "field2": .string("test")]) + } + + func testEmptyListAllowed() { + let value: Value = .list([]) + XCTAssertEqual(value.asList(), []) + } + + func testEncodeDecode() throws { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + let value: Value = .structure([ + "null": .null, + "bool": .boolean(true), + "int": .integer(3), + "double": .double(4.5), + // swiftlint:disable:next force_unwrapping + "date": .date(formatter.date(from: "2022-01-01 12:00:00")!), + "list": .list([.boolean(false), .integer(4)]), + "structure": .structure(["int": .integer(5)]), + ]) + + let result = try JSONEncoder().encode(value) + let decodedValue = try JSONDecoder().decode(Value.self, from: result) + + XCTAssertEqual(value, decodedValue) + } + + func testDecodeValue() throws { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + // swiftlint:disable:next force_unwrapping + let date = formatter.date(from: "2022-01-01 12:00:00")! + let value: Value = .structure([ + "null": .null, + "bool": .boolean(true), + "int": .integer(3), + "double": .double(4.5), + "date": .date(date), + "list": .list([.integer(3), .integer(5)]), + "structure": .structure(["field1": .string("test"), "field2": .integer(12)]), + ]) + let expected = TestValue( + bool: true, int: 3, double: 4.5, date: date, list: [3, 5], structure: .init(field1: "test", field2: 12)) + + let decodedValue = try value.decode(to: TestValue.self) + + XCTAssertEqual(decodedValue, expected) + } + + struct TestValue: Codable, Equatable { + var null: Bool? + var bool: Bool + var int: Int64 + var double: Double + var date: Date + var list: [Int64] + var structure: TestSubValue + } + + struct TestSubValue: Codable, Equatable { + var field1: String + var field2: Int64 + } +} diff --git a/Tools/SwiftFormat/Package.resolved b/Tools/SwiftFormat/Package.resolved new file mode 100644 index 0000000..1880d01 --- /dev/null +++ b/Tools/SwiftFormat/Package.resolved @@ -0,0 +1,52 @@ +{ + "object": { + "pins": [ + { + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser.git", + "state": { + "branch": null, + "revision": "9f39744e025c7d377987f30b03770805dcb0bcd1", + "version": "1.1.4" + } + }, + { + "package": "swift-format", + "repositoryURL": "https://github.com/apple/swift-format", + "state": { + "branch": null, + "revision": "5f184220d032a019a63df457cdea4b9c8241e911", + "version": "0.50700.1" + } + }, + { + "package": "SwiftSyntax", + "repositoryURL": "https://github.com/apple/swift-syntax", + "state": { + "branch": null, + "revision": "72d3da66b085c2299dd287c2be3b92b5ebd226de", + "version": "0.50700.1" + } + }, + { + "package": "swift-system", + "repositoryURL": "https://github.com/apple/swift-system.git", + "state": { + "branch": null, + "revision": "836bc4557b74fe6d2660218d56e3ce96aff76574", + "version": "1.1.1" + } + }, + { + "package": "swift-tools-support-core", + "repositoryURL": "https://github.com/apple/swift-tools-support-core.git", + "state": { + "branch": null, + "revision": "4f07be3dc201f6e2ee85b6942d0c220a16926811", + "version": "0.2.7" + } + } + ] + }, + "version": 1 +} diff --git a/Tools/SwiftFormat/Package.swift b/Tools/SwiftFormat/Package.swift new file mode 100644 index 0000000..4976617 --- /dev/null +++ b/Tools/SwiftFormat/Package.swift @@ -0,0 +1,11 @@ +// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "SwiftFormat", + platforms: [.macOS(.v10_14)], + dependencies: [ + .package(url: "https://github.com/apple/swift-format", from: "0.50700.1") + ], + targets: [.target(name: "SwiftFormat", path: "")] +) diff --git a/Tools/SwiftLinter/Package.resolved b/Tools/SwiftLinter/Package.resolved new file mode 100644 index 0000000..898b937 --- /dev/null +++ b/Tools/SwiftLinter/Package.resolved @@ -0,0 +1,79 @@ +{ + "object": { + "pins": [ + { + "package": "CollectionConcurrencyKit", + "repositoryURL": "https://github.com/JohnSundell/CollectionConcurrencyKit.git", + "state": { + "branch": null, + "revision": "b4f23e24b5a1bff301efc5e70871083ca029ff95", + "version": "0.2.0" + } + }, + { + "package": "SourceKitten", + "repositoryURL": "https://github.com/jpsim/SourceKitten.git", + "state": { + "branch": null, + "revision": "fc12c0f182c5cf80781dd933b17a82eb98bd7c61", + "version": "0.33.1" + } + }, + { + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser.git", + "state": { + "branch": null, + "revision": "fddd1c00396eed152c45a46bea9f47b98e59301d", + "version": "1.2.0" + } + }, + { + "package": "SwiftSyntax", + "repositoryURL": "https://github.com/apple/swift-syntax.git", + "state": { + "branch": null, + "revision": "76d01195182593ff34f5ada1ab0910fae190fc9c", + "version": null + } + }, + { + "package": "SwiftLint", + "repositoryURL": "https://github.com/realm/SwiftLint", + "state": { + "branch": "0.50.3", + "revision": "a876e860ee0e166a05428f430888de5d798c0f8d", + "version": null + } + }, + { + "package": "SwiftyTextTable", + "repositoryURL": "https://github.com/scottrhoyt/SwiftyTextTable.git", + "state": { + "branch": null, + "revision": "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", + "version": "0.9.0" + } + }, + { + "package": "SWXMLHash", + "repositoryURL": "https://github.com/drmohundro/SWXMLHash.git", + "state": { + "branch": null, + "revision": "4d0f62f561458cbe1f732171e625f03195151b60", + "version": "7.0.1" + } + }, + { + "package": "Yams", + "repositoryURL": "https://github.com/jpsim/Yams.git", + "state": { + "branch": null, + "revision": "01835dc202670b5bb90d07f3eae41867e9ed29f6", + "version": "5.0.1" + } + } + ] + }, + "version": 1 +} diff --git a/Tools/SwiftLinter/Package.swift b/Tools/SwiftLinter/Package.swift new file mode 100644 index 0000000..068df51 --- /dev/null +++ b/Tools/SwiftLinter/Package.swift @@ -0,0 +1,11 @@ +// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "SwiftLinter", + platforms: [.macOS(.v10_14)], + dependencies: [ + .package(url: "https://github.com/realm/SwiftLint", revision: "0.50.3") + ], + targets: [.target(name: "SwiftLinter", path: "")] +) diff --git a/Tools/swift-format b/Tools/swift-format new file mode 100755 index 0000000..103f742 --- /dev/null +++ b/Tools/swift-format @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +script_dir=$(dirname $0) +root_dir="$script_dir/../" + +if [ ! -f "$script_dir/SwiftFormat/.build/release/swift-format" ]; +then + (cd $root_dir && swift run -c release --package-path Tools/SwiftFormat swift-format "$@") +else + (cd $root_dir && swift run --skip-build -c release --package-path Tools/SwiftFormat swift-format "$@") +fi diff --git a/Tools/swift-lint b/Tools/swift-lint new file mode 100755 index 0000000..c6b4099 --- /dev/null +++ b/Tools/swift-lint @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +script_dir=$(dirname $0) +root_dir="$script_dir/../" + +if [ ! -f "$script_dir/SwiftLinter/.build/release/swiftlint" ]; +then + (cd $root_dir && swift run -c release --package-path Tools/SwiftLinter swiftlint "$@") +else + (cd $root_dir && swift run --skip-build -c release --package-path Tools/SwiftLinter swiftlint "$@") +fi diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100755 index 0000000..fa36e14 --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +script_dir=$(dirname $0) +root_dir="$script_dir/../" + +(cd $root_dir && + xcodebuild \ + -scheme OpenFeature \ + -sdk "iphonesimulator" \ + -destination 'platform=iOS Simulator,name=iPhone 14 Pro,OS=16.0' \ + test) diff --git a/scripts/swift-format b/scripts/swift-format new file mode 100755 index 0000000..7e80fa2 --- /dev/null +++ b/scripts/swift-format @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +script_dir=$(dirname $0) +root_dir="$script_dir/.." + +$root_dir/Tools/swift-format format -i --recursive --configuration $root_dir/.swift-format $root_dir/Sources $root_dir/Tests diff --git a/scripts/swift-lint b/scripts/swift-lint new file mode 100755 index 0000000..a0875f2 --- /dev/null +++ b/scripts/swift-lint @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +script_dir=$(dirname $0) +root_dir="$script_dir/.." + +$root_dir/Tools/swift-lint --config $root_dir/.swiftlint.yml $root_dir/Sources $root_dir/Tests From c6938f2881eb48cadd41e5787aa352b06bddfb8d Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Tue, 7 Feb 2023 11:34:05 +0100 Subject: [PATCH 2/3] Update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 643534b..e91d6c0 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Swift implementation of the OpenFeature SDK. ### Adding the package dependency -If you manage dependencies through XCode go to "Add package" and enter `git@ghe.spotify.net:konfidens/openfeature-swift-sdk.git`. +If you manage dependencies through Xcode go to "Add package" and enter `git@github.com:spotify/openfeature-swift-sdk.git`. If you manage dependencies through SPM, in the dependencies section of Package.swift add: ```swift -.package(url: "git@ghe.spotify.net:konfidens/openfeature-swift-sdk.git", from: "0.1.3") +.package(url: "git@github.com:spotify/openfeature-swift-sdk.git", from: "0.1.0") ``` and in the target dependencies section add: From a3253d728f7e8c5ff44d90267263d513c3f720b6 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Tue, 7 Feb 2023 13:18:28 +0100 Subject: [PATCH 3/3] Add GitHub Action CI --- .github/workflows/ci.yaml | 26 ++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..10820d9 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + branches: + - 'main' + push: + branches: + - 'main' + +jobs: + test: + runs-on: macOS-latest + steps: + - uses: actions/checkout@v3 + - name: Build and Test + run: swift test + + swiftlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: GitHub Action for SwiftLint + uses: norio-nomura/action-swiftlint@3.2.1 + with: + args: --config .swiftlint.yml diff --git a/README.md b/README.md index e91d6c0..8ae5fff 100644 --- a/README.md +++ b/README.md @@ -54,5 +54,5 @@ You can automatically format your code using: ## Running tests from cmd-line ```shell -./scripts/run_tests.sh +swift test ```