From b5ba3e05e0b727bda13dd4868e277bef8a5e3394 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Mon, 15 Apr 2024 16:57:47 +0200 Subject: [PATCH] feat: Event Uploader (#91) * feat: add EventStorage * refactor: make getFolderURL() static * fix: initialize URLs in init() * feat: extend EventStorage API * refactor: add error handling * fix: handle continuing writing to batch from disk * fix: handle continuing writing to batch from disk, remove force unwrapping * fix: simplify batching * refactor: change handling of file creation * refactor: create a new file if no file left after previous session * fix creating file, folder and use file handler, fix appending events to the storage. fix tests * always seek to the end of the file to append * close file handler before moving it to ready * move private funcs down * cache folder url * test appending events * tear down test * fix: update .gitignore * refactor: refactor EventStorage * test: test EventStorage * fix: unalignment with main * fix: fix lint issues * fix: align links and add comments * Add RemoteClient to Confidence * Make NetworkClient endpoint independent * Generalie and reuse HTTP module * Add Common target for shared internal code * Finalize the network layer for events * Smaller refactoring * Move StructValue to Common * [WIP] One struct for all network * [WIP] Rename Struct * [WIP] Finish implementing NetworkTypeMapper * Remove generic number type * NetworkValue works with number * Network model better represents JSON types * Fix file name * Fix CI build * Rename NetworkValue * Create testbed with URLProtocol mock * Add ConfidenceClient tests * ConfidenceClient error handling/testing * Rebase on top of EventSenderEngine * Formatting * Name alignments and visibility tweaks * Main rebase * Send context in events and align naming * Fix demo app and def policy * Test more types in demo app --------- Co-authored-by: Nicky Bondarenko Co-authored-by: Nicklas Lundin Co-authored-by: vahid torkaman --- .../ConfidenceDemoApp/ConfidenceDemoApp.swift | 28 ++- Package.swift | 17 +- Sources/{Confidence => Common}/Backport.swift | 28 +-- Sources/Common/CaseIterableDefaultsLast.swift | 13 ++ .../ConfidenceError.swift | 0 Sources/Common/Http/HttpClient.swift | 48 ++++++ .../Http/HttpStatusCode.swift | 4 +- .../Http/NetworkClient.swift | 28 +-- .../Http/Retry.swift | 12 +- .../NetowrkValue.swift} | 48 +++--- Sources/Common/Sdk.swift | 11 ++ Sources/Common/TestHelpers/Extensions.swift | 31 ++++ .../Common/TestHelpers}/GrpcStatusCode.swift | 4 +- Sources/Confidence/Confidence.swift | 34 +++- .../ConfidenceClient/ConfidenceClient.swift | 7 + .../ConfidenceClientOptions.swift | 28 +++ .../ConfidenceClient/NetworkTypeMapper.swift | 62 +++++++ .../RemoteConfidenceClient.swift | 99 +++++++++++ Sources/Confidence/ConfidenceMetadata.swift | 6 + Sources/Confidence/ConfidenceValue.swift | 1 + Sources/Confidence/EventSenderEngine.swift | 76 +++++---- Sources/Confidence/EventSenderStorage.swift | 7 - Sources/Confidence/EventStorage.swift | 54 +++--- Sources/Confidence/EventStorageInMemory.swift | 27 +++ Sources/Confidence/SizeFlushPolicy.swift | 22 +++ .../Apply/FlagApplierWithRetries.swift | 1 + .../Cache/DefaultStorage.swift | 5 +- .../Cache/InMemoryProviderCache.swift | 1 + .../ConfidenceClient/ConfidenceClient.swift | 2 +- .../LocalStorageResolver.swift | 1 + ...ft => RemoteResolveConfidenceClient.swift} | 24 +-- .../ConfidenceFeatureProvider.swift | 15 +- .../ConfidenceProvider/Http/HttpClient.swift | 24 --- .../Utils/BaseUrlMapper.swift | 15 ++ .../ConfidenceProvider/Utils/Extensions.swift | 12 -- .../Utils/HttpStatusCode+Error.swift | 1 + .../ConfidenceProvider/Utils/TypeMapper.swift | 40 ++--- .../ConfidenceFeatureProviderTest.swift | 160 +++++++++--------- .../EventSenderEngineTest.swift | 27 +-- .../EventUploaderMock.swift | 35 ++-- .../Helpers/AlwaysFailCache.swift | 1 + .../Helpers/ClientMock.swift | 4 +- .../Helpers/HttpClientMock.swift | 12 +- ...t => MockedResolveClientURLProtocol.swift} | 58 ++----- .../LocalStorageResolverTest.swift | 1 + ...> RemoteResolveConfidenceClientTest.swift} | 14 +- Tests/ConfidenceTests/ConfidenceTests.swift | 35 +++- Tests/ConfidenceTests/EventStorageTests.swift | 35 +++- .../Helpers/ConfidenceClientMock.swift | 9 + .../Helpers/EventSenderEngineMock.swift | 12 ++ .../Helpers/MockedClientURLProtocol.swift | 113 +++++++++++++ .../RemoteConfidenceClientTests.swift | 107 ++++++++++++ 52 files changed, 1049 insertions(+), 410 deletions(-) rename Sources/{Confidence => Common}/Backport.swift (74%) create mode 100644 Sources/Common/CaseIterableDefaultsLast.swift rename Sources/{Confidence => Common}/ConfidenceError.swift (100%) create mode 100644 Sources/Common/Http/HttpClient.swift rename Sources/{ConfidenceProvider => Common}/Http/HttpStatusCode.swift (99%) rename Sources/{ConfidenceProvider => Common}/Http/NetworkClient.swift (89%) rename Sources/{ConfidenceProvider => Common}/Http/Retry.swift (79%) rename Sources/{ConfidenceProvider/Utils/Struct.swift => Common/NetowrkValue.swift} (58%) create mode 100644 Sources/Common/Sdk.swift create mode 100644 Sources/Common/TestHelpers/Extensions.swift rename {Tests/ConfidenceProviderTests/Helpers => Sources/Common/TestHelpers}/GrpcStatusCode.swift (94%) create mode 100644 Sources/Confidence/ConfidenceClient/ConfidenceClient.swift create mode 100644 Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift create mode 100644 Sources/Confidence/ConfidenceClient/NetworkTypeMapper.swift create mode 100644 Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift create mode 100644 Sources/Confidence/ConfidenceMetadata.swift delete mode 100644 Sources/Confidence/EventSenderStorage.swift create mode 100644 Sources/Confidence/EventStorageInMemory.swift create mode 100644 Sources/Confidence/SizeFlushPolicy.swift rename Sources/ConfidenceProvider/ConfidenceClient/{RemoteConfidenceClient.swift => RemoteResolveConfidenceClient.swift} (92%) delete mode 100644 Sources/ConfidenceProvider/Http/HttpClient.swift create mode 100644 Sources/ConfidenceProvider/Utils/BaseUrlMapper.swift rename Tests/ConfidenceProviderTests/Helpers/{MockedConfidenceClientURLProtocol.swift => MockedResolveClientURLProtocol.swift} (82%) rename Tests/ConfidenceProviderTests/{RemoteConfidenceClientTest.swift => RemoteResolveConfidenceClientTest.swift} (75%) create mode 100644 Tests/ConfidenceTests/Helpers/ConfidenceClientMock.swift create mode 100644 Tests/ConfidenceTests/Helpers/EventSenderEngineMock.swift create mode 100644 Tests/ConfidenceTests/Helpers/MockedClientURLProtocol.swift create mode 100644 Tests/ConfidenceTests/RemoteConfidenceClientTests.swift diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift index 07526a85..7e98fc2a 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift @@ -28,6 +28,7 @@ extension ConfidenceDemoApp { } let confidence = Confidence.Builder(clientSecret: secret) + .withRegion(region: .europe) .withInitializationstrategy(initializationStrategy: initializationStrategy) .build() let provider = ConfidenceFeatureProvider(confidence: confidence) @@ -38,9 +39,30 @@ extension ConfidenceDemoApp { structure: MutableStructure.init(attributes: ["country": .string("SE")])) Task { await OpenFeatureAPI.shared.setProviderAndWait(provider: provider, initialContext: ctx) - confidence.send( - definition: "my_event", - payload: ["my_string_field": ConfidenceValue(string: "hello_from_world")]) } + confidence.send( + definition: "all-types", + payload: [ + "my_string": ConfidenceValue(string: "hello_from_world"), + "my_timestamp": ConfidenceValue(timestamp: Date()), + "my_bool": ConfidenceValue(boolean: true), + "my_date": ConfidenceValue(date: DateComponents(year: 2024, month: 4, day: 3)), + "my_int": ConfidenceValue(integer: 2), + "my_double": ConfidenceValue(double: 3.14), + "my_list": ConfidenceValue(booleanList: [true, false]), + "my_struct": ConfidenceValue(structure: [ + "my_nested_struct": ConfidenceValue(structure: [ + "my_nested_nested_struct": ConfidenceValue(structure: [ + "my_nested_nested_nested_int": ConfidenceValue(integer: 666) + ]), + "my_nested_nested_list": ConfidenceValue(dateList: [ + DateComponents(year: 2024, month: 4, day: 4), + DateComponents(year: 2024, month: 4, day: 5) + ]) + ]), + "my_nested_string": ConfidenceValue(string: "nested_hello") + ]) + ] + ) } } diff --git a/Package.swift b/Package.swift index 545cf790..c1a923a7 100644 --- a/Package.swift +++ b/Package.swift @@ -21,16 +21,26 @@ let package = Package( .package(url: "git@github.com:open-feature/swift-sdk.git", from: "0.1.0"), ], targets: [ + // Internal definitions shared between Confidence and ConfidenceProvider + // These are not exposed to the consumers of Confidence or ConfidenceProvider .target( - name: "Confidence", + name: "Common", dependencies: [], plugins: [] ), + .target( + name: "Confidence", + dependencies: [ + "Common" + ], + plugins: [] + ), .target( name: "ConfidenceProvider", dependencies: [ .product(name: "OpenFeature", package: "swift-sdk"), - "Confidence" + "Confidence", + "Common" ], plugins: [] ), @@ -38,12 +48,13 @@ let package = Package( name: "ConfidenceProviderTests", dependencies: [ "ConfidenceProvider", + "Common", ] ), .testTarget( name: "ConfidenceTests", dependencies: [ - "Confidence" + "Confidence", ] ), ] diff --git a/Sources/Confidence/Backport.swift b/Sources/Common/Backport.swift similarity index 74% rename from Sources/Confidence/Backport.swift rename to Sources/Common/Backport.swift index 69fa6103..2b069b0c 100644 --- a/Sources/Confidence/Backport.swift +++ b/Sources/Common/Backport.swift @@ -1,21 +1,21 @@ import Foundation -public extension URL { - struct Backport { +extension URL { + public struct Backport { var base: URL - public init(base: URL) { + init(base: URL) { self.base = base } } - var backport: Backport { + public var backport: Backport { Backport(base: self) } } -public extension URL.Backport { - var path: String { +extension URL.Backport { + public var path: String { if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { return self.base.path(percentEncoded: false) } else { @@ -23,7 +23,7 @@ public extension URL.Backport { } } - func appending(components: S...) -> URL where S: StringProtocol { + public func appending(components: S...) -> URL where S: StringProtocol { if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { return components.reduce(self.base) { acc, cur in return acc.appending(component: cur) @@ -36,15 +36,15 @@ public extension URL.Backport { } } -public extension Date { - struct Backport { +extension Date { + public struct Backport { } - static var backport: Backport.Type { Backport.self } + static public var backport: Backport.Type { Backport.self } } -public extension Date.Backport { - static var now: Date { +extension Date.Backport { + static public var now: Date { if #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) { return Date.now } else { @@ -52,7 +52,7 @@ public extension Date.Backport { } } - static var nowISOString: String { + static public var nowISOString: String { if #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) { return toISOString(date: Date.now) } else { @@ -60,7 +60,7 @@ public extension Date.Backport { } } - static func toISOString(date: Date) -> String { + static public func toISOString(date: Date) -> String { if #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) { return date.ISO8601Format() } else { diff --git a/Sources/Common/CaseIterableDefaultsLast.swift b/Sources/Common/CaseIterableDefaultsLast.swift new file mode 100644 index 00000000..7369ae43 --- /dev/null +++ b/Sources/Common/CaseIterableDefaultsLast.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Used to default an enum to the last value if none matches, this should respresent unknown +public protocol CaseIterableDefaultsLast: Decodable & CaseIterable & RawRepresentable +where RawValue: Decodable, AllCases: BidirectionalCollection {} + +extension CaseIterableDefaultsLast { + public init(from decoder: Decoder) throws { + // All enums should contain at least one item so we allow force unwrap + // swiftlint:disable:next force_unwrapping + self = try Self(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? Self.allCases.last! + } +} diff --git a/Sources/Confidence/ConfidenceError.swift b/Sources/Common/ConfidenceError.swift similarity index 100% rename from Sources/Confidence/ConfidenceError.swift rename to Sources/Common/ConfidenceError.swift diff --git a/Sources/Common/Http/HttpClient.swift b/Sources/Common/Http/HttpClient.swift new file mode 100644 index 00000000..40f2f1f8 --- /dev/null +++ b/Sources/Common/Http/HttpClient.swift @@ -0,0 +1,48 @@ +import Foundation + +public typealias HttpClientResult = Result, Error> + +public protocol HttpClient { + func post(path: String, data: Encodable) async throws -> HttpClientResult +} + +public struct HttpClientResponse { + public init(decodedData: T? = nil, decodedError: HttpError? = nil, response: HTTPURLResponse) { + self.decodedData = decodedData + self.decodedError = decodedError + self.response = response + } + public var decodedData: T? + public var decodedError: HttpError? + public var response: HTTPURLResponse +} + +public struct HttpError: Codable { + public init(code: Int, message: String, details: [String]) { + self.code = code + self.message = message + self.details = details + } + public var code: Int + public var message: String + public var details: [String] +} + +public enum HttpClientError: Error { + case invalidResponse + case internalError +} + +extension HTTPURLResponse { + public func mapStatusToError(error: HttpError?) -> ConfidenceError { + let defaultError = ConfidenceError.internalError( + message: "General error: \(error?.message ?? "Unknown error")") + + switch self.status { + case .notFound, .badRequest: + return ConfidenceError.badRequest(message: error?.message ?? "") + default: + return defaultError + } + } +} diff --git a/Sources/ConfidenceProvider/Http/HttpStatusCode.swift b/Sources/Common/Http/HttpStatusCode.swift similarity index 99% rename from Sources/ConfidenceProvider/Http/HttpStatusCode.swift rename to Sources/Common/Http/HttpStatusCode.swift index 0510fdaa..4bd02052 100644 --- a/Sources/ConfidenceProvider/Http/HttpStatusCode.swift +++ b/Sources/Common/Http/HttpStatusCode.swift @@ -3,7 +3,7 @@ import Foundation /// This is a list of Hypertext Transfer Protocol (HTTP) response status codes. /// It includes codes from IETF internet standards, other IETF RFCs, other specifications, and some additional commonly used codes. /// The first digit of the status code specifies one of five classes of response; an HTTP client must recognise these five classes at a minimum. -enum HTTPStatusCode: Int, Error { +public enum HTTPStatusCode: Int, Error { /// The response class representation of status codes, these get grouped by their first digit. enum ResponseType { /// - informational: This class of status code indicates a provisional response, consisting only of the Status-Line and optional headers, and is terminated by an empty line. @@ -271,7 +271,7 @@ enum HTTPStatusCode: Int, Error { } extension HTTPURLResponse { - var status: HTTPStatusCode? { + public var status: HTTPStatusCode? { return HTTPStatusCode(rawValue: statusCode) } } diff --git a/Sources/ConfidenceProvider/Http/NetworkClient.swift b/Sources/Common/Http/NetworkClient.swift similarity index 89% rename from Sources/ConfidenceProvider/Http/NetworkClient.swift rename to Sources/Common/Http/NetworkClient.swift index ff43a61f..801891af 100644 --- a/Sources/ConfidenceProvider/Http/NetworkClient.swift +++ b/Sources/Common/Http/NetworkClient.swift @@ -1,27 +1,15 @@ import Foundation -import Confidence -final class NetworkClient: HttpClient { +final public class NetworkClient: HttpClient { private let headers: [String: String] private let retry: Retry private let timeout: TimeInterval private let session: URLSession - private let region: ConfidenceRegion - - private var baseUrl: String { - switch region { - case .global: - return "https://resolver.confidence.dev/v1/flags" - case .europe: - return "https://resolver.eu.confidence.dev/v1/flags" - case .usa: - return "https://resolver.us.confidence.dev/v1/flags" - } - } + private let baseUrl: String - init( + public init( session: URLSession? = nil, - region: ConfidenceRegion, + baseUrl: String, defaultHeaders: [String: String] = [:], timeout: TimeInterval = 30.0, retry: Retry = .none @@ -39,12 +27,12 @@ final class NetworkClient: HttpClient { self.headers = defaultHeaders self.retry = retry self.timeout = timeout - self.region = region + self.baseUrl = baseUrl } - func post( + public func post( path: String, - data: Codable + data: Encodable ) async throws -> HttpClientResult { let request = try buildRequest(path: path, data: data) let requestResult = await perform(request: request, retry: self.retry) @@ -109,7 +97,7 @@ extension NetworkClient { return URL(string: "\(normalisedBase)\(normalisedPath)") } - private func buildRequest(path: String, data: Codable) throws -> URLRequest { + private func buildRequest(path: String, data: Encodable) throws -> URLRequest { guard let url = constructURL(base: baseUrl, path: path) else { throw ConfidenceError.internalError(message: "Could not create service url") } diff --git a/Sources/ConfidenceProvider/Http/Retry.swift b/Sources/Common/Http/Retry.swift similarity index 79% rename from Sources/ConfidenceProvider/Http/Retry.swift rename to Sources/Common/Http/Retry.swift index 892e5261..5f1c6137 100644 --- a/Sources/ConfidenceProvider/Http/Retry.swift +++ b/Sources/Common/Http/Retry.swift @@ -1,6 +1,6 @@ import Foundation -enum Retry { +public enum Retry { case none case exponential(maxBackoff: TimeInterval, maxAttempts: UInt) @@ -14,11 +14,11 @@ enum Retry { } } -protocol RetryHandler { +public protocol RetryHandler { func retryIn() -> TimeInterval? } -class ExponentialBackoffRetryHandler: RetryHandler { +public class ExponentialBackoffRetryHandler: RetryHandler { private var currentAttempts: UInt = 0 private let maxBackoff: TimeInterval private let maxAttempts: UInt @@ -28,7 +28,7 @@ class ExponentialBackoffRetryHandler: RetryHandler { self.maxAttempts = maxAttempts } - func retryIn() -> TimeInterval? { + public func retryIn() -> TimeInterval? { if currentAttempts >= maxAttempts { return nil } @@ -40,8 +40,8 @@ class ExponentialBackoffRetryHandler: RetryHandler { } } -class NoneRetryHandler: RetryHandler { - func retryIn() -> TimeInterval? { +public class NoneRetryHandler: RetryHandler { + public func retryIn() -> TimeInterval? { return nil } } diff --git a/Sources/ConfidenceProvider/Utils/Struct.swift b/Sources/Common/NetowrkValue.swift similarity index 58% rename from Sources/ConfidenceProvider/Utils/Struct.swift rename to Sources/Common/NetowrkValue.swift index dc73a607..97d52ca1 100644 --- a/Sources/ConfidenceProvider/Utils/Struct.swift +++ b/Sources/Common/NetowrkValue.swift @@ -1,21 +1,23 @@ import Foundation -struct Struct: Equatable { - var fields: [String: StructValue] +public struct NetworkStruct: Equatable { + public init(fields: [String: NetworkValue]) { + self.fields = fields + } + public var fields: [String: NetworkValue] } -enum StructValue: Equatable { +public enum NetworkValue: Equatable { case null - case number(Double) case string(String) - case bool(Bool) - case date(Date) - case object(Struct) - case list([StructValue]) + case number(Double) + case boolean(Bool) + case structure(NetworkStruct) + case list([NetworkValue]) } -extension StructValue: Codable { - func encode(to encoder: Encoder) throws { +extension NetworkValue: Codable { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { @@ -25,12 +27,10 @@ extension StructValue: Codable { try container.encode(double) case .string(let string): try container.encode(string) - case .bool(let bool): - try container.encode(bool) - case .date(let date): - try container.encode(date) - case .object(let object): - try container.encode(object) + case .boolean(let boolean): + try container.encode(boolean) + case .structure(let structure): + try container.encode(structure) case .list(let list): try container.encode(list) } @@ -45,12 +45,10 @@ extension StructValue: Codable { } else if let string = try? container.decode(String.self) { self = .string(string) } else if let bool = try? container.decode(Bool.self) { - self = .bool(bool) - } else if let date = try? container.decode(Date.self) { - self = .date(date) - } else if let object = try? container.decode(Struct.self) { - self = .object(object) - } else if let list = try? container.decode([StructValue].self) { + self = .boolean(bool) + } else if let object = try? container.decode(NetworkStruct.self) { + self = .structure(object) + } else if let list = try? container.decode([NetworkValue].self) { self = .list(list) } else { throw DecodingError.dataCorrupted( @@ -59,14 +57,14 @@ extension StructValue: Codable { } } -extension Struct: Codable { - func encode(to encoder: Encoder) throws { +extension NetworkStruct: Codable { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(fields) } public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - self.fields = try container.decode([String: StructValue].self) + self.fields = try container.decode([String: NetworkValue].self) } } diff --git a/Sources/Common/Sdk.swift b/Sources/Common/Sdk.swift new file mode 100644 index 00000000..c33bc291 --- /dev/null +++ b/Sources/Common/Sdk.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct Sdk: Codable { + public init(id: String?, version: String?) { + self.id = id ?? "SDK_ID_SWIFT_PROVIDER" + self.version = version ?? "unknown" + } + + var id: String + var version: String +} diff --git a/Sources/Common/TestHelpers/Extensions.swift b/Sources/Common/TestHelpers/Extensions.swift new file mode 100644 index 00000000..976bdc72 --- /dev/null +++ b/Sources/Common/TestHelpers/Extensions.swift @@ -0,0 +1,31 @@ +import Foundation + +extension URLRequest { + public func decodeBody(type: T.Type) -> T? { + guard let bodyStream = self.httpBodyStream else { return nil } + + bodyStream.open() + + let bufferSize: Int = 128 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + + var data = Data() + while bodyStream.hasBytesAvailable { + let readBytes = bodyStream.read(buffer, maxLength: bufferSize) + data.append(buffer, count: readBytes) + } + + buffer.deallocate() + + bodyStream.close() + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + do { + return try decoder.decode(type, from: data) + } catch { + return nil + } + } +} diff --git a/Tests/ConfidenceProviderTests/Helpers/GrpcStatusCode.swift b/Sources/Common/TestHelpers/GrpcStatusCode.swift similarity index 94% rename from Tests/ConfidenceProviderTests/Helpers/GrpcStatusCode.swift rename to Sources/Common/TestHelpers/GrpcStatusCode.swift index 3c5f7e1c..ccfa7d57 100644 --- a/Tests/ConfidenceProviderTests/Helpers/GrpcStatusCode.swift +++ b/Sources/Common/TestHelpers/GrpcStatusCode.swift @@ -1,9 +1,9 @@ import Foundation -struct GrpcStatusCode { +public struct GrpcStatusCode { private let _rawValue: UInt8 - var rawValue: Int { + public var rawValue: Int { return Int(self._rawValue) } diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index e0006979..0da26064 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -6,17 +6,20 @@ public class Confidence: ConfidenceEventSender { public let clientSecret: String public var timeout: TimeInterval public var region: ConfidenceRegion + let eventSenderEngine: EventSenderEngine public var initializationStrategy: InitializationStrategy private var removedContextKeys: Set = Set() - required public init( + required init( clientSecret: String, timeout: TimeInterval, region: ConfidenceRegion, + eventSenderEngine: EventSenderEngine, initializationStrategy: InitializationStrategy, context: ConfidenceStruct = [:], parent: ConfidenceEventSender? = nil ) { + self.eventSenderEngine = eventSenderEngine self.clientSecret = clientSecret self.timeout = timeout self.region = region @@ -25,9 +28,8 @@ public class Confidence: ConfidenceEventSender { self.parent = parent } - // TODO: Implement actual event uploading to the backend public func send(definition: String, payload: ConfidenceStruct) { - print("Sending: \"\(definition)\".\nMessage: \(payload)\nContext: \(context)") + eventSenderEngine.emit(definition: definition, payload: payload, context: getContext()) } @@ -56,6 +58,7 @@ public class Confidence: ConfidenceEventSender { clientSecret: clientSecret, timeout: timeout, region: region, + eventSenderEngine: eventSenderEngine, initializationStrategy: initializationStrategy, context: context, parent: self) @@ -68,9 +71,15 @@ extension Confidence { var timeout: TimeInterval = 10.0 var region: ConfidenceRegion = .global var initializationStrategy: InitializationStrategy = .fetchAndActivate + let eventStorage: EventStorage public init(clientSecret: String) { self.clientSecret = clientSecret + do { + eventStorage = try EventStorageImpl() + } catch { + eventStorage = EventStorageInMemory() + } } public func withTimeout(timeout: TimeInterval) -> Builder { @@ -90,11 +99,28 @@ extension Confidence { } public func build() -> Confidence { + let uploader = RemoteConfidenceClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: clientSecret), + timeout: timeout, + region: region), + metadata: ConfidenceMetadata( + name: "SDK_ID_SWIFT_CONFIDENCE", + version: "0.1.4") // x-release-please-version + ) + let eventSenderEngine = EventSenderEngineImpl( + clientSecret: clientSecret, + uploader: uploader, + storage: eventStorage, + flushPolicies: [SizeFlushPolicy(batchSize: 1)]) return Confidence( clientSecret: clientSecret, timeout: timeout, region: region, - initializationStrategy: initializationStrategy + eventSenderEngine: eventSenderEngine, + initializationStrategy: initializationStrategy, + context: [:], + parent: nil ) } } diff --git a/Sources/Confidence/ConfidenceClient/ConfidenceClient.swift b/Sources/Confidence/ConfidenceClient/ConfidenceClient.swift new file mode 100644 index 00000000..cec60f45 --- /dev/null +++ b/Sources/Confidence/ConfidenceClient/ConfidenceClient.swift @@ -0,0 +1,7 @@ +import Foundation +import Common + +protocol ConfidenceClient { + // Returns true if the batch has been correctly processed by the backend + func upload(events: [NetworkEvent]) async throws -> Bool +} diff --git a/Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift b/Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift new file mode 100644 index 00000000..498148c1 --- /dev/null +++ b/Sources/Confidence/ConfidenceClient/ConfidenceClientOptions.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct ConfidenceClientOptions { + public var credentials: ConfidenceClientCredentials + public var timeout: TimeInterval + public var region: ConfidenceRegion + + public init( + credentials: ConfidenceClientCredentials, + timeout: TimeInterval? = nil, + region: ConfidenceRegion? = nil + ) { + self.credentials = credentials + self.timeout = timeout ?? 10.0 + self.region = region ?? .global + } +} + +public enum ConfidenceClientCredentials { + case clientSecret(secret: String) + + public func getSecret() -> String { + switch self { + case .clientSecret(let secret): + return secret + } + } +} diff --git a/Sources/Confidence/ConfidenceClient/NetworkTypeMapper.swift b/Sources/Confidence/ConfidenceClient/NetworkTypeMapper.swift new file mode 100644 index 00000000..04237a1f --- /dev/null +++ b/Sources/Confidence/ConfidenceClient/NetworkTypeMapper.swift @@ -0,0 +1,62 @@ +import Foundation +import Common + +public enum NetworkTypeMapper { + public static func from(value: ConfidenceStruct) throws -> NetworkStruct { + NetworkStruct(fields: try value.compactMapValues(convertValue)) + } + + // swiftlint:disable:next cyclomatic_complexity + public static func convertValue(_ value: ConfidenceValue) throws -> NetworkValue? { + switch value.type() { + case .boolean: + guard let value = value.asBoolean() else { + return nil + } + return NetworkValue.boolean(value) + case .string: + guard let value = value.asString() else { + return nil + } + return NetworkValue.string(value) + case .integer: + guard let value = value.asInteger() else { + return nil + } + return NetworkValue.number(Double(value)) + case .double: + guard let value = value.asDouble() else { + return nil + } + return NetworkValue.number(value) + case .date: + let dateFormatter = ISO8601DateFormatter() + dateFormatter.timeZone = TimeZone.current + dateFormatter.formatOptions = [.withFullDate] + guard let value = value.asDateComponents(), let dateString = Calendar.current.date(from: value) else { + throw ConfidenceError.internalError(message: "Could not create date from components") + } + return NetworkValue.string(dateFormatter.string(from: dateString)) + case .timestamp: + guard let value = value.asDate() else { + return nil + } + let timestampFormatter = ISO8601DateFormatter() + timestampFormatter.timeZone = TimeZone.init(identifier: "UTC") + let timestamp = timestampFormatter.string(from: value) + return NetworkValue.string(timestamp) + case .list: + guard let value = value.asList() else { + return nil + } + return try NetworkValue.list(value.compactMap(convertValue)) + case .structure: + guard let value = value.asStructure() else { + return nil + } + return try NetworkValue.structure(NetworkStruct(fields: value.compactMapValues(convertValue))) + case .null: + return nil + } + } +} diff --git a/Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift b/Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift new file mode 100644 index 00000000..c6a712d5 --- /dev/null +++ b/Sources/Confidence/ConfidenceClient/RemoteConfidenceClient.swift @@ -0,0 +1,99 @@ +import Foundation +import Common +import os + +public class RemoteConfidenceClient: ConfidenceClient { + private var options: ConfidenceClientOptions + private let metadata: ConfidenceMetadata + private var httpClient: HttpClient + private var baseUrl: String + + init( + options: ConfidenceClientOptions, + session: URLSession? = nil, + metadata: ConfidenceMetadata + ) { + self.options = options + switch options.region { + case .global: + self.baseUrl = "https://events.confidence.dev/v1/events" + case .europe: + self.baseUrl = "https://events.eu.confidence.dev/v1/events" + case .usa: + self.baseUrl = "https://events.us.confidence.dev/v1/events" + } + self.httpClient = NetworkClient(session: session, baseUrl: baseUrl) + self.metadata = metadata + } + + func upload(events: [NetworkEvent]) async throws -> Bool { + let timeString = Date.backport.nowISOString + let request = PublishEventRequest( + events: events.map { event in NetworkEvent( + eventDefinition: "eventDefinitions/\(event.eventDefinition)", + payload: event.payload, + eventTime: event.eventTime) + }, + clientSecret: options.credentials.getSecret(), + sendTime: timeString, + sdk: Sdk(id: metadata.name, version: metadata.version) + ) + + do { + let result: HttpClientResult = + try await self.httpClient.post(path: ":publish", data: request) + switch result { + case .success(let successData): + guard successData.response.status == .ok else { + throw successData.response.mapStatusToError(error: successData.decodedError) + } + let indexedErrorsCount = successData.decodedData?.errors.count ?? 0 + if indexedErrorsCount > 0 { + Logger(subsystem: "com.confidence.client", category: "network").error( + "Backend reported errors for \(indexedErrorsCount) event(s) in batch") + } + return true + case .failure(let errorData): + throw handleError(error: errorData) + } + } + } + + private func handleError(error: Error) -> Error { + if error is ConfidenceError { + return error + } else { + return ConfidenceError.internalError(message: "\(error)") + } + } +} + +struct PublishEventRequest: Codable { + var events: [NetworkEvent] + var clientSecret: String + var sendTime: String + var sdk: Sdk +} + +struct NetworkEvent: Codable { + var eventDefinition: String + var payload: NetworkStruct + var eventTime: String +} + +struct PublishEventResponse: Codable { + var errors: [EventError] +} + +struct EventError: Codable { + var index: Int + var reason: Reason + var message: String + + enum Reason: String, Codable, CaseIterableDefaultsLast { + case unspecified = "REASON_UNSPECIFIED" + case eventDefinitionNotFound = "EVENT_DEFINITION_NOT_FOUND" + case eventSchemaValidationFailed = "EVENT_SCHEMA_VALIDATION_FAILED" + case unknown + } +} diff --git a/Sources/Confidence/ConfidenceMetadata.swift b/Sources/Confidence/ConfidenceMetadata.swift new file mode 100644 index 00000000..8a51792a --- /dev/null +++ b/Sources/Confidence/ConfidenceMetadata.swift @@ -0,0 +1,6 @@ +import Foundation + +struct ConfidenceMetadata { + public var name: String + public var version: String +} diff --git a/Sources/Confidence/ConfidenceValue.swift b/Sources/Confidence/ConfidenceValue.swift index 303220c8..01b399a3 100644 --- a/Sources/Confidence/ConfidenceValue.swift +++ b/Sources/Confidence/ConfidenceValue.swift @@ -1,4 +1,5 @@ import Foundation +import Common public typealias ConfidenceStruct = [String: ConfidenceValue] diff --git a/Sources/Confidence/EventSenderEngine.swift b/Sources/Confidence/EventSenderEngine.swift index 8e975642..7b94f9fe 100644 --- a/Sources/Confidence/EventSenderEngine.swift +++ b/Sources/Confidence/EventSenderEngine.swift @@ -1,87 +1,91 @@ import Combine +import Common import Foundation -protocol EventsUploader { - func upload(request: [Event]) async -> Bool -} - protocol FlushPolicy { func reset() - func hit(event: Event) + func hit(event: ConfidenceEvent) func shouldFlush() -> Bool } -protocol Clock { - func now() -> Date -} - protocol EventSenderEngine { - func send(name: String, message: [String: ConfidenceValue]) + func emit(definition: String, payload: ConfidenceStruct, context: ConfidenceStruct) func shutdown() } final class EventSenderEngineImpl: EventSenderEngine { private static let sendSignalName: String = "FLUSH" private let storage: any EventStorage - private let writeReqChannel = PassthroughSubject() + private let writeReqChannel = PassthroughSubject() private let uploadReqChannel = PassthroughSubject() private var cancellables = Set() private let flushPolicies: [FlushPolicy] - private let uploader: EventsUploader + private let uploader: ConfidenceClient private let clientSecret: String - private let clock: Clock init( clientSecret: String, - uploader: EventsUploader, - clock: Clock, + uploader: ConfidenceClient, storage: EventStorage, flushPolicies: [FlushPolicy] ) { - self.clock = clock self.uploader = uploader self.clientSecret = clientSecret self.storage = storage self.flushPolicies = flushPolicies - writeReqChannel.sink(receiveValue: { [weak self] event in + writeReqChannel.sink { [weak self] event in guard let self = self else { return } do { try self.storage.writeEvent(event: event) } catch { - } - self.flushPolicies.forEach({ policy in policy.hit(event: event) }) - let shouldFlush = self.flushPolicies.contains(where: { policy in policy.shouldFlush() }) + self.flushPolicies.forEach { policy in policy.hit(event: event) } + let shouldFlush = self.flushPolicies.contains { policy in policy.shouldFlush() } if shouldFlush { self.uploadReqChannel.send(EventSenderEngineImpl.sendSignalName) - self.flushPolicies.forEach({ policy in policy.reset() }) + self.flushPolicies.forEach { policy in policy.reset() } } + } + .store(in: &cancellables) - }).store(in: &cancellables) - - uploadReqChannel.sink(receiveValue: { [weak self] _ in + uploadReqChannel.sink { [weak self] _ in do { guard let self = self else { return } try self.storage.startNewBatch() let ids = try storage.batchReadyIds() for id in ids { - let events = try self.storage.eventsFrom(id: id) - let shouldCleanup = await self.uploader.upload(request: events) + let events: [NetworkEvent] = try self.storage.eventsFrom(id: id) + .compactMap { event in + let networkPayload = event.payload.compactMapValues { payloadValue in + try? NetworkTypeMapper.convertValue(payloadValue) + } + return NetworkEvent( + eventDefinition: event.name, + payload: NetworkStruct(fields: networkPayload), + eventTime: Date.backport.toISOString(date: event.eventTime)) + } + let shouldCleanup = try await self.uploader.upload(events: events) if shouldCleanup { try storage.remove(id: id) } } } catch { - } - }).store(in: &cancellables) + } + .store(in: &cancellables) } - func send(name: String, message: [String: ConfidenceValue]) { - writeReqChannel.send(Event(name: name, payload: message, eventTime: Date())) + func emit(definition: String, payload: ConfidenceStruct, context: ConfidenceStruct) { + writeReqChannel.send(ConfidenceEvent( + name: definition, + payload: context.merging(payload) { _, new in + new + }, + eventTime: Date.backport.now) + ) } func shutdown() { @@ -90,11 +94,11 @@ final class EventSenderEngineImpl: EventSenderEngine { } private extension Publisher where Self.Failure == Never { - func sink(receiveValue: @escaping ((Self.Output) async -> Void)) -> AnyCancellable { - sink { value in - Task { - await receiveValue(value) - } + func sink(receiveValue: @escaping ((Self.Output) async -> Void)) -> AnyCancellable { + sink { value in + Task { + await receiveValue(value) + } + } } - } } diff --git a/Sources/Confidence/EventSenderStorage.swift b/Sources/Confidence/EventSenderStorage.swift deleted file mode 100644 index e0b07357..00000000 --- a/Sources/Confidence/EventSenderStorage.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -struct EventBatchRequest: Encodable { - let clientSecret: String - let sendTime: Date - let events: [Event] -} diff --git a/Sources/Confidence/EventStorage.swift b/Sources/Confidence/EventStorage.swift index 02bf7efa..fd492f2b 100644 --- a/Sources/Confidence/EventStorage.swift +++ b/Sources/Confidence/EventStorage.swift @@ -1,11 +1,18 @@ import Foundation +import Common import os +struct ConfidenceEvent: Codable { + let name: String + let payload: [String: ConfidenceValue] + let eventTime: Date +} + internal protocol EventStorage { func startNewBatch() throws - func writeEvent(event: Event) throws + func writeEvent(event: ConfidenceEvent) throws func batchReadyIds() throws -> [String] - func eventsFrom(id: String) throws -> [Event] + func eventsFrom(id: String) throws -> [ConfidenceEvent] func remove(id: String) throws } @@ -30,12 +37,14 @@ internal class EventStorageImpl: EventStorage { return } try currentFileHandle?.close() - try FileManager.default.moveItem(at: currentFileName, to: currentFileName.appendingPathExtension(READYTOSENDEXTENSION)) + try FileManager.default.moveItem( + at: currentFileName, + to: currentFileName.appendingPathExtension(READYTOSENDEXTENSION)) try resetCurrentFile() } } - func writeEvent(event: Event) throws { + func writeEvent(event: ConfidenceEvent) throws { try storageQueue.sync { guard let currentFileHandle = currentFileHandle else { return @@ -56,35 +65,45 @@ internal class EventStorageImpl: EventStorage { func batchReadyIds() throws -> [String] { try storageQueue.sync { let fileUrls = try FileManager.default.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil) - return fileUrls.filter({ url in url.pathExtension == READYTOSENDEXTENSION }).map({ url in url.lastPathComponent }) + return fileUrls.filter { url in + url.pathExtension == READYTOSENDEXTENSION + } + .map { url in + url.lastPathComponent + } } } - func eventsFrom(id: String) throws -> [Event] { + func eventsFrom(id: String) throws -> [ConfidenceEvent] { try storageQueue.sync { let decoder = JSONDecoder() let fileUrl = folderURL.appendingPathComponent(id) let data = try Data(contentsOf: fileUrl) let dataString = String(data: data, encoding: .utf8) return try dataString?.components(separatedBy: "\n") - .filter({ events in !events.isEmpty }) - .map({ eventString in try decoder.decode(Event.self, from: eventString.data(using: .utf8)!) }) ?? [] + .filter { events in + !events.isEmpty + } + .compactMap { eventString in + guard let stringData = eventString.data(using: .utf8) else { + return nil + } + return try decoder.decode(ConfidenceEvent.self, from: stringData) + } ?? [] } } func remove(id: String) throws { try storageQueue.sync { - let fileUrl = folderURL.appendingPathComponent(id) - try FileManager.default.removeItem(at: fileUrl) + let fileUrl = folderURL.appendingPathComponent(id) + try FileManager.default.removeItem(at: fileUrl) } } private func getLastWritingFile() throws -> URL? { let files = try FileManager.default.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil) - for fileUrl in files { - if fileUrl.pathExtension != READYTOSENDEXTENSION { - return fileUrl - } + for fileUrl in files where fileUrl.pathExtension != READYTOSENDEXTENSION { + return fileUrl } return nil } @@ -95,6 +114,7 @@ internal class EventStorageImpl: EventStorage { self.currentFileUrl = currentFile self.currentFileHandle = try FileHandle(forWritingTo: currentFile) } else { + // Create a brand new file let fileUrl = folderURL.appendingPathComponent(String(Date().timeIntervalSince1970)) FileManager.default.createFile(atPath: fileUrl.path, contents: nil) self.currentFileUrl = fileUrl @@ -118,9 +138,3 @@ internal class EventStorageImpl: EventStorage { components: "com.confidence.events.storage", "\(bundleIdentifier)", "events") } } - -struct Event: Encodable, Equatable, Decodable { - let name: String - let payload: [String: ConfidenceValue] - let eventTime: Date -} diff --git a/Sources/Confidence/EventStorageInMemory.swift b/Sources/Confidence/EventStorageInMemory.swift new file mode 100644 index 00000000..e3b342ef --- /dev/null +++ b/Sources/Confidence/EventStorageInMemory.swift @@ -0,0 +1,27 @@ +import Foundation + +final class EventStorageInMemory: EventStorage { + private var events: [ConfidenceEvent] = [] + private var batches: [String: [ConfidenceEvent]] = [:] + func startNewBatch() throws { + batches[("\(batches.count)")] = events + events.removeAll() + } + + func writeEvent(event: ConfidenceEvent) throws { + events.append(event) + } + + func batchReadyIds() -> [String] { + return batches.map { batch in batch.0 } + } + + func eventsFrom(id: String) throws -> [ConfidenceEvent] { + // swiftlint:disable:next force_unwrapping + return batches[id]! + } + + func remove(id: String) throws { + batches.removeValue(forKey: id) + } +} diff --git a/Sources/Confidence/SizeFlushPolicy.swift b/Sources/Confidence/SizeFlushPolicy.swift new file mode 100644 index 00000000..c3cf180e --- /dev/null +++ b/Sources/Confidence/SizeFlushPolicy.swift @@ -0,0 +1,22 @@ +import Foundation + +class SizeFlushPolicy: FlushPolicy { + private var currentSize = 0 + private let batchSize: Int + + init(batchSize: Int) { + self.batchSize = batchSize + } + + func reset() { + currentSize = 0 + } + + func hit(event: ConfidenceEvent) { + currentSize += 1 + } + + func shouldFlush() -> Bool { + currentSize >= batchSize + } +} diff --git a/Sources/ConfidenceProvider/Apply/FlagApplierWithRetries.swift b/Sources/ConfidenceProvider/Apply/FlagApplierWithRetries.swift index cf03c2ce..cd450c80 100644 --- a/Sources/ConfidenceProvider/Apply/FlagApplierWithRetries.swift +++ b/Sources/ConfidenceProvider/Apply/FlagApplierWithRetries.swift @@ -1,4 +1,5 @@ import Foundation +import Common import Confidence import OpenFeature import os diff --git a/Sources/ConfidenceProvider/Cache/DefaultStorage.swift b/Sources/ConfidenceProvider/Cache/DefaultStorage.swift index 2482a41c..800678c6 100644 --- a/Sources/ConfidenceProvider/Cache/DefaultStorage.swift +++ b/Sources/ConfidenceProvider/Cache/DefaultStorage.swift @@ -1,4 +1,5 @@ import Foundation +import Common import Confidence public class DefaultStorage: Storage { @@ -84,7 +85,9 @@ public class DefaultStorage: Storage { func getConfigUrl() throws -> URL { guard - let applicationSupportUrl: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + let applicationSupportUrl: URL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask) .last else { throw ConfidenceError.cacheError(message: "Could not get URL for application directory") diff --git a/Sources/ConfidenceProvider/Cache/InMemoryProviderCache.swift b/Sources/ConfidenceProvider/Cache/InMemoryProviderCache.swift index 24ca8a48..1573e5f3 100644 --- a/Sources/ConfidenceProvider/Cache/InMemoryProviderCache.swift +++ b/Sources/ConfidenceProvider/Cache/InMemoryProviderCache.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import Common import Confidence import OpenFeature import os diff --git a/Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClient.swift b/Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClient.swift index 9c607e1b..9cb6039e 100644 --- a/Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClient.swift +++ b/Sources/ConfidenceProvider/ConfidenceClient/ConfidenceClient.swift @@ -1,7 +1,7 @@ import Foundation import OpenFeature -public protocol ConfidenceClient { +public protocol ConfidenceResolveClient { // Async func resolve(ctx: EvaluationContext) async throws -> ResolvesResult } diff --git a/Sources/ConfidenceProvider/ConfidenceClient/LocalStorageResolver.swift b/Sources/ConfidenceProvider/ConfidenceClient/LocalStorageResolver.swift index ba395350..c3fb28aa 100644 --- a/Sources/ConfidenceProvider/ConfidenceClient/LocalStorageResolver.swift +++ b/Sources/ConfidenceProvider/ConfidenceClient/LocalStorageResolver.swift @@ -1,4 +1,5 @@ import Foundation +import Common import Confidence import OpenFeature diff --git a/Sources/ConfidenceProvider/ConfidenceClient/RemoteConfidenceClient.swift b/Sources/ConfidenceProvider/ConfidenceClient/RemoteResolveConfidenceClient.swift similarity index 92% rename from Sources/ConfidenceProvider/ConfidenceClient/RemoteConfidenceClient.swift rename to Sources/ConfidenceProvider/ConfidenceClient/RemoteResolveConfidenceClient.swift index 71b93fa8..d80bc5e5 100644 --- a/Sources/ConfidenceProvider/ConfidenceClient/RemoteConfidenceClient.swift +++ b/Sources/ConfidenceProvider/ConfidenceClient/RemoteResolveConfidenceClient.swift @@ -1,9 +1,9 @@ import Foundation +import Common import Confidence import OpenFeature - -public class RemoteConfidenceClient: ConfidenceClient { +public class RemoteConfidenceResolveClient: ConfidenceResolveClient { private let targetingKey = "targeting_key" private let flagApplier: FlagApplier private var options: ConfidenceClientOptions @@ -20,10 +20,12 @@ public class RemoteConfidenceClient: ConfidenceClient { metadata: ConfidenceMetadata ) { self.options = options - self.httpClient = NetworkClient(session: session, region: options.region) self.flagApplier = flagApplier self.applyOnResolve = applyOnResolve self.metadata = metadata + self.httpClient = NetworkClient( + session: session, + baseUrl: BaseUrlMapper.from(region: options.region)) } // MARK: Resolver @@ -85,7 +87,7 @@ public class RemoteConfidenceClient: ConfidenceClient { resolveReason: convert(resolveReason: resolvedFlag.reason)) } - private func getEvaluationContextStruct(ctx: EvaluationContext) throws -> Struct { + private func getEvaluationContextStruct(ctx: EvaluationContext) throws -> NetworkStruct { var evaluationContext = TypeMapper.from(value: ctx) evaluationContext.fields[targetingKey] = .string(ctx.getTargetingKey()) return evaluationContext @@ -112,7 +114,7 @@ public class RemoteConfidenceClient: ConfidenceClient { struct ResolveFlagsRequest: Codable { var flags: [String] - var evaluationContext: Struct + var evaluationContext: NetworkStruct var clientSecret: String var apply: Bool var sdk: Sdk @@ -125,7 +127,7 @@ struct ResolveFlagsResponse: Codable { struct ResolvedFlag: Codable { var flag: String - var value: Struct? = Struct(fields: [:]) + var value: NetworkStruct? = NetworkStruct(fields: [:]) var variant: String = "" var flagSchema: StructFlagSchema? = StructFlagSchema(schema: [:]) var reason: ResolveReason @@ -163,16 +165,6 @@ struct ApplyFlagsRequest: Codable { struct ApplyFlagsResponse: Codable { } -struct Sdk: Codable { - init(id: String?, version: String?) { - self.id = id ?? "SDK_ID_SWIFT_PROVIDER" - self.version = version ?? "unknown" - } - - var id: String - var version: String -} - private func displayName(resolvedFlag: ResolvedFlag) throws -> String { let flagNameComponents = resolvedFlag.flag.components(separatedBy: "/") if flagNameComponents.count <= 1 || flagNameComponents[0] != "flags" { diff --git a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift index 6ccde58f..07960fce 100644 --- a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift +++ b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift @@ -1,7 +1,8 @@ import Foundation +import Combine +import Common import Confidence import OpenFeature -import Combine import os /// The implementation of the Confidence Feature Provider. This implementation allows to pre-cache evaluations. @@ -15,7 +16,7 @@ public class ConfidenceFeatureProvider: FeatureProvider { public var hooks: [any Hook] = [] private let lock = UnfairLock() private var resolver: Resolver - private let client: ConfidenceClient + private let client: ConfidenceResolveClient private var cache: ProviderCache private var overrides: [String: LocalOverride] private let flagApplier: FlagApplier @@ -27,7 +28,7 @@ public class ConfidenceFeatureProvider: FeatureProvider { /// Should not be called externally, use `ConfidenceFeatureProvider.Builder`or init with `Confidence` instead. init( metadata: ProviderMetadata, - client: RemoteConfidenceClient, + client: RemoteConfidenceResolveClient, cache: ProviderCache, storage: Storage, overrides: [String: LocalOverride] = [:], @@ -58,11 +59,11 @@ public class ConfidenceFeatureProvider: FeatureProvider { self.storage = DefaultStorage.resolverFlagsCache() self.resolver = LocalStorageResolver(cache: cache) self.flagApplier = FlagApplierWithRetries( - httpClient: NetworkClient(region: options.region), + httpClient: NetworkClient(baseUrl: BaseUrlMapper.from(region: options.region)), storage: DefaultStorage.applierFlagsCache(), options: options, metadata: metadata) - self.client = RemoteConfidenceClient( + self.client = RemoteConfidenceResolveClient( options: options, applyOnResolve: false, flagApplier: flagApplier, @@ -611,7 +612,7 @@ extension ConfidenceFeatureProvider { let flagApplier = flagApplier ?? FlagApplierWithRetries( - httpClient: NetworkClient(region: options.region), + httpClient: NetworkClient(baseUrl: BaseUrlMapper.from(region: options.region)), storage: DefaultStorage.applierFlagsCache(), options: options, metadata: metadata @@ -619,7 +620,7 @@ extension ConfidenceFeatureProvider { let cache = cache ?? InMemoryProviderCache.from(storage: storage) - let client = RemoteConfidenceClient( + let client = RemoteConfidenceResolveClient( options: options, session: self.session, applyOnResolve: false, diff --git a/Sources/ConfidenceProvider/Http/HttpClient.swift b/Sources/ConfidenceProvider/Http/HttpClient.swift deleted file mode 100644 index c24dc4e9..00000000 --- a/Sources/ConfidenceProvider/Http/HttpClient.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -typealias HttpClientResult = Result, Error> - -protocol HttpClient { - func post(path: String, data: Codable) async throws -> HttpClientResult -} - -struct HttpClientResponse { - var decodedData: T? - var decodedError: HttpError? - var response: HTTPURLResponse -} - -struct HttpError: Codable { - var code: Int - var message: String - var details: [String] -} - -enum HttpClientError: Error { - case invalidResponse - case internalError -} diff --git a/Sources/ConfidenceProvider/Utils/BaseUrlMapper.swift b/Sources/ConfidenceProvider/Utils/BaseUrlMapper.swift new file mode 100644 index 00000000..9f3da939 --- /dev/null +++ b/Sources/ConfidenceProvider/Utils/BaseUrlMapper.swift @@ -0,0 +1,15 @@ +import Foundation +import Confidence + +public enum BaseUrlMapper { + static func from(region: ConfidenceRegion) -> String { + switch region { + case .global: + return "https://resolver.confidence.dev/v1/flags" + case .europe: + return "https://resolver.eu.confidence.dev/v1/flags" + case .usa: + return "https://resolver.us.confidence.dev/v1/flags" + } + } +} diff --git a/Sources/ConfidenceProvider/Utils/Extensions.swift b/Sources/ConfidenceProvider/Utils/Extensions.swift index 47b35cad..6a120375 100644 --- a/Sources/ConfidenceProvider/Utils/Extensions.swift +++ b/Sources/ConfidenceProvider/Utils/Extensions.swift @@ -1,18 +1,6 @@ import Foundation import OpenFeature -/// Used to default an enum to the last value if none matches, this should respresent unknown -protocol CaseIterableDefaultsLast: Decodable & CaseIterable & RawRepresentable -where RawValue: Decodable, AllCases: BidirectionalCollection {} - -extension CaseIterableDefaultsLast { - init(from decoder: Decoder) throws { - // All enums should contain at least one item so we allow force unwrap - // swiftlint:disable:next force_unwrapping - self = try Self(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? Self.allCases.last! - } -} - extension [ResolvedValue] { func toCacheData(context: EvaluationContext, resolveToken: String) -> StoredCacheData { var cacheValues: [String: ResolvedValue] = [:] diff --git a/Sources/ConfidenceProvider/Utils/HttpStatusCode+Error.swift b/Sources/ConfidenceProvider/Utils/HttpStatusCode+Error.swift index 7f5ae5a3..bfec2a2c 100644 --- a/Sources/ConfidenceProvider/Utils/HttpStatusCode+Error.swift +++ b/Sources/ConfidenceProvider/Utils/HttpStatusCode+Error.swift @@ -1,4 +1,5 @@ import Foundation +import Common import Confidence import OpenFeature diff --git a/Sources/ConfidenceProvider/Utils/TypeMapper.swift b/Sources/ConfidenceProvider/Utils/TypeMapper.swift index a40b0ece..adc0c8b8 100644 --- a/Sources/ConfidenceProvider/Utils/TypeMapper.swift +++ b/Sources/ConfidenceProvider/Utils/TypeMapper.swift @@ -1,21 +1,22 @@ import Foundation +import Common import OpenFeature public enum TypeMapper { - static func from(value: Structure) -> Struct { - return Struct(fields: value.asMap().compactMapValues(convertValueToStructValue)) + static func from(value: Structure) -> NetworkStruct { + return NetworkStruct(fields: value.asMap().compactMapValues(convertValueToStructValue)) } - static func from(value: Value) throws -> Struct { + static func from(value: Value) throws -> NetworkStruct { guard case .structure(let values) = value else { throw OpenFeatureError.parseError(message: "Value must be a .structure") } - return Struct(fields: values.compactMapValues(convertValueToStructValue)) + return NetworkStruct(fields: values.compactMapValues(convertValueToStructValue)) } static func from( - object: Struct, schema: StructFlagSchema + object: NetworkStruct, schema: StructFlagSchema ) throws -> Value @@ -27,30 +28,33 @@ public enum TypeMapper { })) } - static private func convertValueToStructValue(_ value: Value) -> StructValue? { + static private func convertValueToStructValue(_ value: Value) -> NetworkValue? { switch value { case .boolean(let value): - return StructValue.bool(value) + return NetworkValue.boolean(value) case .string(let value): - return StructValue.string(value) + return NetworkValue.string(value) case .integer(let value): - return StructValue.number(Double(value)) + return NetworkValue.number(Double(value)) case .double(let value): - return StructValue.number(value) + return NetworkValue.number(value) case .date(let value): - return StructValue.date(value) + let timestampFormatter = ISO8601DateFormatter() + timestampFormatter.timeZone = TimeZone.init(identifier: "UTC") + let timestamp = timestampFormatter.string(from: value) + return NetworkValue.string(timestamp) case .list(let values): return .list(values.compactMap(convertValueToStructValue)) case .structure(let values): - return .object(Struct(fields: values.compactMapValues(convertValueToStructValue))) + return .structure(NetworkStruct(fields: values.compactMapValues(convertValueToStructValue))) case .null: - return StructValue.null + return NetworkValue.null } } // swiftlint:disable:next cyclomatic_complexity static private func convertStructValueToValue( - _ structValue: StructValue, schema: FlagSchema? + _ structValue: NetworkValue, schema: FlagSchema? ) throws -> Value { guard let fieldType = schema else { throw OpenFeatureError.parseError(message: "Mismatch between schema and value") @@ -70,15 +74,12 @@ public enum TypeMapper { } case .string(let value): return .string(value) - case .bool(let value): + case .boolean(let value): return .boolean(value) - case .date(let value): - return .date(value) - case .object(let mapValue): + case .structure(let mapValue): guard case .structSchema(let structSchema) = fieldType else { throw OpenFeatureError.parseError(message: "Field is struct in schema but something else in value") } - return .structure( Dictionary( uniqueKeysWithValues: try mapValue.fields.map { field, fieldValue in @@ -88,7 +89,6 @@ public enum TypeMapper { guard case .listSchema(let listSchema) = fieldType else { throw OpenFeatureError.parseError(message: "Field is list in schema but something else in value") } - return .list( try listValue.map { fieldValue in try convertStructValueToValue(fieldValue, schema: listSchema) diff --git a/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift b/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift index 759d2bda..1b9bccca 100644 --- a/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift +++ b/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift @@ -18,14 +18,14 @@ class ConfidenceFeatureProviderTest: XCTestCase { override func setUp() { try? storage.clear() - MockedConfidenceClientURLProtocol.reset() + MockedResolveClientURLProtocol.reset() flagApplier = FlagApplierMock() super.setUp() } func testRefresh() throws { - var session = MockedConfidenceClientURLProtocol.mockedSession(flags: [:]) + var session = MockedResolveClientURLProtocol.mockedSession(flags: [:]) let provider = builder .with(session: session) @@ -53,16 +53,16 @@ class ConfidenceFeatureProviderTest: XCTestCase { OpenFeatureError.flagNotFoundError(key: "flag")) } - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user2": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] readyExpectation = XCTestExpectation(description: "Ready (2)") - session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + session = MockedResolveClientURLProtocol.mockedSession(flags: flags) provider.onContextSet( oldContext: MutableContext(targetingKey: "user1"), newContext: MutableContext(targetingKey: "user2")) wait(for: [readyExpectation], timeout: 5) @@ -79,21 +79,21 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, "control") wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 2) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 2) XCTAssertEqual(flagApplier.applyCallCount, 1) } } func testResolveIntegerFlag() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -123,21 +123,21 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, "control") wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 1) } } func testResolveAndApplyIntegerFlag() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -166,21 +166,21 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, "control") wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 1) } } func testResolveAndApplyIntegerFlagNoSegmentMatch() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -210,21 +210,21 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, nil) wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 1) } } func testResolveAndApplyIntegerFlagTwice() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -257,22 +257,22 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, "control") wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 2) } } func testResolveAndApplyIntegerFlagError() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - MockedConfidenceClientURLProtocol.failFirstApply = true - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + MockedResolveClientURLProtocol.failFirstApply = true + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -305,21 +305,21 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, "control") wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 2) } } func testStaleEvaluationContextInCache() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) // Simulating a cache with an old evaluation context let data = [ResolvedValue(flag: "flag", resolveReason: .match)] @@ -352,7 +352,7 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertNil(evaluation.variant) XCTAssertEqual(evaluation.errorCode, ErrorCode.providerNotReady) XCTAssertEqual(evaluation.reason, Reason.error.rawValue) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) // TODO: Check this - how do we check for something not called? XCTAssertEqual(flagApplier.applyCallCount, 0) @@ -360,15 +360,15 @@ class ConfidenceFeatureProviderTest: XCTestCase { } func testResolveDoubleFlag() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .double(3.1)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -396,21 +396,21 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, "control") wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 1) } } func testResolveBooleanFlag() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["visible": .boolean(false)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -438,21 +438,21 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, "control") wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 1) } } func testResolveObjectFlag() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -480,13 +480,13 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, "control") wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 1) } } func testResolveNullValues() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .null])) ] @@ -494,11 +494,11 @@ class ConfidenceFeatureProviderTest: XCTestCase { "user1": .init(schema: ["size": .intSchema]) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve, schemas: schemas) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -526,13 +526,13 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.variant, "control") wait(for: [flagApplier.applyExpectation], timeout: 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 1) } } func testProviderThrowsFlagNotFound() throws { - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: [:]) + let session = MockedResolveClientURLProtocol.mockedSession(flags: [:]) let provider = builder .with(session: session) @@ -561,13 +561,13 @@ class ConfidenceFeatureProviderTest: XCTestCase { } // TODO: Check this - how do we check for something not called? - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 0) } } func testProviderNoTargetingKey() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .null])) ] @@ -575,11 +575,11 @@ class ConfidenceFeatureProviderTest: XCTestCase { "user1": .init(schema: ["size": .intSchema]) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve, schemas: schemas) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -598,20 +598,20 @@ class ConfidenceFeatureProviderTest: XCTestCase { } // TODO: Check this - how do we check for something not called? - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 0) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 0) XCTAssertEqual(flagApplier.applyCallCount, 0) } func testProviderTargetingKeyError() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -644,21 +644,21 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.reason, Reason.error.rawValue) // TODO: Check this - how do we check for something not called? - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 0) } } func testProviderCannotParse() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) @@ -685,21 +685,21 @@ class ConfidenceFeatureProviderTest: XCTestCase { } // TODO: Check this - how do we check for something not called? - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) XCTAssertEqual(flagApplier.applyCallCount, 0) } } func testLocalOverrideReplacesFlag() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder.with(session: session) .with(flagApplier: flagApplier) .with(cache: AlwaysFailCache()) @@ -724,20 +724,20 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.reason, Reason.staticReason.rawValue) XCTAssertEqual(evaluation.value, 4) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) } } func testLocalOverridePartiallyReplacesFlag() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3), "color": .string("green")])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder.with(session: session) .with(flagApplier: flagApplier) .overrides(.field(path: "flag.size", variant: "treatment", value: .integer(4))) @@ -769,7 +769,7 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(colorEvaluation.variant, "control") XCTAssertEqual(colorEvaluation.reason, Reason.targetingMatch.rawValue) XCTAssertEqual(colorEvaluation.value, "green") - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) wait(for: [flagApplier.applyExpectation], timeout: 5) XCTAssertEqual(flagApplier.applyCallCount, 1) @@ -777,15 +777,15 @@ class ConfidenceFeatureProviderTest: XCTestCase { } func testLocalOverrideNoEvaluationContext() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3), "color": .string("green")])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder.with(session: session) .with(flagApplier: flagApplier) .with(cache: AlwaysFailCache()) @@ -818,20 +818,20 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(sizeEvaluation2.variant, "treatment") XCTAssertEqual(sizeEvaluation2.reason, Reason.staticReason.rawValue) XCTAssertEqual(sizeEvaluation2.value, 4) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) } } func testLocalOverrideTwiceTakesSecondOverride() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder.with(session: session) .with(flagApplier: flagApplier) .with(cache: AlwaysFailCache()) @@ -857,20 +857,20 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(evaluation.reason, Reason.staticReason.rawValue) XCTAssertEqual(evaluation.value, 5) - XCTAssertEqual(MockedConfidenceClientURLProtocol.resolveStats, 1) + XCTAssertEqual(MockedResolveClientURLProtocol.resolveStats, 1) } } func testOverridingInProvider() throws { - let resolve: [String: MockedConfidenceClientURLProtocol.ResolvedTestFlag] = [ + let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) ] - let flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [ + let flags: [String: MockedResolveClientURLProtocol.TestFlag] = [ "flags/flag": .init(resolve: resolve) ] - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let provider = builder .with(session: session) diff --git a/Tests/ConfidenceProviderTests/EventSenderEngineTest.swift b/Tests/ConfidenceProviderTests/EventSenderEngineTest.swift index e9ca010b..a1978b54 100644 --- a/Tests/ConfidenceProviderTests/EventSenderEngineTest.swift +++ b/Tests/ConfidenceProviderTests/EventSenderEngineTest.swift @@ -1,4 +1,5 @@ import Foundation +import Common import XCTest @testable import Confidence @@ -9,16 +10,14 @@ final class MinSizeFlushPolicy: FlushPolicy { func reset() { size = 0 } - - func hit(event: Event) { + + func hit(event: ConfidenceEvent) { size += 1 } - + func shouldFlush() -> Bool { return size >= maxSize } - - } final class EventSenderEngineTest: XCTestCase { @@ -28,30 +27,32 @@ final class EventSenderEngineTest: XCTestCase { let eventSenderEngine = EventSenderEngineImpl( clientSecret: "CLIENT_SECRET", uploader: uploader, - clock: ClockMock(), storage: EventStorageMock(), flushPolicies: flushPolicies ) let expectation = XCTestExpectation(description: "Upload finished") - let cancellable = uploader.subject.sink { value in + let cancellable = uploader.subject.sink { _ in expectation.fulfill() } - var events: [Event] = [] + var events: [ConfidenceEvent] = [] for i in 0..<5 { - events.append(Event(name: "\(i)", payload: [:], eventTime: Date())) - eventSenderEngine.send(name: "\(i)", message: [:]) + events.append(ConfidenceEvent( + name: "\(i)", + payload: [:], + eventTime: Date.backport.now) + ) + eventSenderEngine.emit(definition: "\(i)", payload: [:], context: [:]) } wait(for: [expectation], timeout: 5) let uploadRequest = try XCTUnwrap(uploader.calledRequest) - XCTAssertTrue(uploadRequest.map { $0.name } == events.map { $0.name }) + XCTAssertTrue(uploadRequest.map { $0.eventDefinition } == events.map { $0.name }) uploader.reset() - eventSenderEngine.send(name: "Hello", message: [:]) + eventSenderEngine.emit(definition: "Hello", payload: [:], context: [:]) XCTAssertNil(uploader.calledRequest) cancellable.cancel() } } - diff --git a/Tests/ConfidenceProviderTests/EventUploaderMock.swift b/Tests/ConfidenceProviderTests/EventUploaderMock.swift index f7008d02..4f79cbbc 100644 --- a/Tests/ConfidenceProviderTests/EventUploaderMock.swift +++ b/Tests/ConfidenceProviderTests/EventUploaderMock.swift @@ -2,11 +2,12 @@ import Foundation import Combine @testable import Confidence -final class EventUploaderMock: EventsUploader { - var calledRequest: [Event]? = nil +final class EventUploaderMock: ConfidenceClient { + var calledRequest: [NetworkEvent]? let subject: PassthroughSubject = PassthroughSubject() - func upload(request: [Event]) async -> Bool { - calledRequest = request + + func upload(events: [NetworkEvent]) async throws -> Bool { + calledRequest = events subject.send(1) return true } @@ -16,34 +17,28 @@ final class EventUploaderMock: EventsUploader { } } -final class ClockMock: Clock { - func now() -> Date { - return Date() - } -} - final class EventStorageMock: EventStorage { - private var events: [Event] = [] - private var batches: [String: [Event]] = [:] + private var events: [ConfidenceEvent] = [] + private var batches: [String: [ConfidenceEvent]] = [:] func startNewBatch() throws { batches[("\(batches.count)")] = events events.removeAll() } - - func writeEvent(event: Event) throws { + + func writeEvent(event: ConfidenceEvent) throws { events.append(event) } - + func batchReadyIds() -> [String] { - return batches.map({ batch in batch.0}) + return batches.map { batch in batch.0 } } - - func eventsFrom(id: String) throws -> [Event] { + + func eventsFrom(id: String) throws -> [ConfidenceEvent] { + // swiftlint:disable:next force_unwrapping return batches[id]! } - + func remove(id: String) throws { batches.removeValue(forKey: id) } - } diff --git a/Tests/ConfidenceProviderTests/Helpers/AlwaysFailCache.swift b/Tests/ConfidenceProviderTests/Helpers/AlwaysFailCache.swift index 566c34dd..ac51f6e6 100644 --- a/Tests/ConfidenceProviderTests/Helpers/AlwaysFailCache.swift +++ b/Tests/ConfidenceProviderTests/Helpers/AlwaysFailCache.swift @@ -1,4 +1,5 @@ import Foundation +import Common import Confidence import OpenFeature diff --git a/Tests/ConfidenceProviderTests/Helpers/ClientMock.swift b/Tests/ConfidenceProviderTests/Helpers/ClientMock.swift index 45d0b8dd..8a4d653b 100644 --- a/Tests/ConfidenceProviderTests/Helpers/ClientMock.swift +++ b/Tests/ConfidenceProviderTests/Helpers/ClientMock.swift @@ -1,9 +1,11 @@ import Foundation +import Common +import Confidence import OpenFeature @testable import ConfidenceProvider -class ClientMock: ConfidenceClient { +class ClientMock: ConfidenceResolveClient { var applyCount = 0 var batchApplyCount = 0 var testMode: TestMode diff --git a/Tests/ConfidenceProviderTests/Helpers/HttpClientMock.swift b/Tests/ConfidenceProviderTests/Helpers/HttpClientMock.swift index 662e10e7..8d9377cf 100644 --- a/Tests/ConfidenceProviderTests/Helpers/HttpClientMock.swift +++ b/Tests/ConfidenceProviderTests/Helpers/HttpClientMock.swift @@ -1,12 +1,12 @@ import Foundation +import Common +import Confidence import XCTest -@testable import ConfidenceProvider - final class HttpClientMock: HttpClient { var testMode: TestMode var postCallCounter = 0 - var data: [Codable]? + var data: [Encodable]? var expectation: XCTestExpectation? enum TestMode { @@ -19,13 +19,13 @@ final class HttpClientMock: HttpClient { self.testMode = testMode } - func post(path: String, data: Codable) async throws -> ConfidenceProvider.HttpClientResult where T: Decodable { + func post(path: String, data: Encodable) async throws -> HttpClientResult where T: Decodable { try handlePost(path: path, data: data) } private func handlePost( - path: String, data: Codable - ) throws -> ConfidenceProvider.HttpClientResult where T: Decodable { + path: String, data: Encodable + ) throws -> HttpClientResult where T: Decodable { defer { expectation?.fulfill() } diff --git a/Tests/ConfidenceProviderTests/Helpers/MockedConfidenceClientURLProtocol.swift b/Tests/ConfidenceProviderTests/Helpers/MockedResolveClientURLProtocol.swift similarity index 82% rename from Tests/ConfidenceProviderTests/Helpers/MockedConfidenceClientURLProtocol.swift rename to Tests/ConfidenceProviderTests/Helpers/MockedResolveClientURLProtocol.swift index d2dfaeaa..a9f4b412 100644 --- a/Tests/ConfidenceProviderTests/Helpers/MockedConfidenceClientURLProtocol.swift +++ b/Tests/ConfidenceProviderTests/Helpers/MockedResolveClientURLProtocol.swift @@ -1,31 +1,33 @@ import Foundation +import Common +import Confidence import OpenFeature @testable import ConfidenceProvider -class MockedConfidenceClientURLProtocol: URLProtocol { +class MockedResolveClientURLProtocol: URLProtocol { public static var callStats = 0 public static var resolveStats = 0 public static var flags: [String: TestFlag] = [:] public static var failFirstApply = false static func set(flags: [String: TestFlag]) { - MockedConfidenceClientURLProtocol.flags = flags + MockedResolveClientURLProtocol.flags = flags } static func mockedSession(flags: [String: TestFlag]) -> URLSession { - MockedConfidenceClientURLProtocol.set(flags: flags) + MockedResolveClientURLProtocol.set(flags: flags) let config = URLSessionConfiguration.default - config.protocolClasses = [MockedConfidenceClientURLProtocol.self] + config.protocolClasses = [MockedResolveClientURLProtocol.self] return URLSession(configuration: config) } static func reset() { - MockedConfidenceClientURLProtocol.flags = [:] - MockedConfidenceClientURLProtocol.callStats = 0 - MockedConfidenceClientURLProtocol.resolveStats = 0 - MockedConfidenceClientURLProtocol.failFirstApply = false + MockedResolveClientURLProtocol.flags = [:] + MockedResolveClientURLProtocol.callStats = 0 + MockedResolveClientURLProtocol.resolveStats = 0 + MockedResolveClientURLProtocol.failFirstApply = false } override class func canInit(with request: URLRequest) -> Bool { @@ -56,8 +58,8 @@ class MockedConfidenceClientURLProtocol: URLProtocol { } private func resolve() { - MockedConfidenceClientURLProtocol.callStats += 1 - MockedConfidenceClientURLProtocol.resolveStats += 1 + MockedResolveClientURLProtocol.callStats += 1 + MockedResolveClientURLProtocol.resolveStats += 1 guard let request = request.decodeBody(type: ResolveFlagsRequest.self) else { client?.urlProtocol( self, didFailWithError: NSError(domain: "test", code: URLError.cannotDecodeRawData.rawValue)) @@ -72,7 +74,7 @@ class MockedConfidenceClientURLProtocol: URLProtocol { return } - let flags = MockedConfidenceClientURLProtocol.flags + let flags = MockedResolveClientURLProtocol.flags .filter { _, flag in flag.isArchived == false } @@ -86,7 +88,7 @@ class MockedConfidenceClientURLProtocol: URLProtocol { guard let resolved = flag.resolve[targetingKey], let schema = flag.schemas[targetingKey] else { return ResolvedFlag(flag: flagName, reason: .noSegmentMatch) } - var responseValue: Struct? + var responseValue: NetworkStruct? do { responseValue = try TypeMapper.from(value: resolved.value) } catch { @@ -143,7 +145,7 @@ class MockedConfidenceClientURLProtocol: URLProtocol { } } -extension MockedConfidenceClientURLProtocol { +extension MockedResolveClientURLProtocol { struct ResolvedTestFlag { var variant: String var value: Value @@ -220,33 +222,3 @@ extension MockedConfidenceClientURLProtocol { } } } - -extension URLRequest { - func decodeBody(type: T.Type) -> T? { - guard let bodyStream = self.httpBodyStream else { return nil } - - bodyStream.open() - - let bufferSize: Int = 128 - let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) - - var data = Data() - while bodyStream.hasBytesAvailable { - let readBytes = bodyStream.read(buffer, maxLength: bufferSize) - data.append(buffer, count: readBytes) - } - - buffer.deallocate() - - bodyStream.close() - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - do { - return try decoder.decode(type, from: data) - } catch { - return nil - } - } -} diff --git a/Tests/ConfidenceProviderTests/LocalStorageResolverTest.swift b/Tests/ConfidenceProviderTests/LocalStorageResolverTest.swift index beb55a8f..af9f27ef 100644 --- a/Tests/ConfidenceProviderTests/LocalStorageResolverTest.swift +++ b/Tests/ConfidenceProviderTests/LocalStorageResolverTest.swift @@ -1,4 +1,5 @@ import Foundation +import Common import Confidence import OpenFeature import XCTest diff --git a/Tests/ConfidenceProviderTests/RemoteConfidenceClientTest.swift b/Tests/ConfidenceProviderTests/RemoteResolveConfidenceClientTest.swift similarity index 75% rename from Tests/ConfidenceProviderTests/RemoteConfidenceClientTest.swift rename to Tests/ConfidenceProviderTests/RemoteResolveConfidenceClientTest.swift index 6f9616e9..19193e76 100644 --- a/Tests/ConfidenceProviderTests/RemoteConfidenceClientTest.swift +++ b/Tests/ConfidenceProviderTests/RemoteResolveConfidenceClientTest.swift @@ -4,11 +4,11 @@ import XCTest @testable import ConfidenceProvider -class RemoteConfidenceClientTest: XCTestCase { - var flags: [String: MockedConfidenceClientURLProtocol.TestFlag] = [:] - let resolvedFlag1 = MockedConfidenceClientURLProtocol.ResolvedTestFlag( +class RemoteResolveConfidenceClientTest: XCTestCase { + var flags: [String: MockedResolveClientURLProtocol.TestFlag] = [:] + let resolvedFlag1 = MockedResolveClientURLProtocol.ResolvedTestFlag( variant: "control", value: .structure(["size": .integer(3)])) - let resolvedFlag2 = MockedConfidenceClientURLProtocol.ResolvedTestFlag( + let resolvedFlag2 = MockedResolveClientURLProtocol.ResolvedTestFlag( variant: "treatment", value: .structure(["size": .integer(2)])) override func setUp() { @@ -17,16 +17,16 @@ class RemoteConfidenceClientTest: XCTestCase { "flags/flag2": .init(resolve: ["user1": resolvedFlag2]), ] - MockedConfidenceClientURLProtocol.reset() + MockedResolveClientURLProtocol.reset() super.setUp() } func testResolveMultipleFlagsSucceeds() async throws { - let session = MockedConfidenceClientURLProtocol.mockedSession(flags: flags) + let session = MockedResolveClientURLProtocol.mockedSession(flags: flags) let flagApplier = FlagApplierMock() - let client = RemoteConfidenceClient( + let client = RemoteConfidenceResolveClient( options: .init(credentials: .clientSecret(secret: "test")), session: session, applyOnResolve: true, diff --git a/Tests/ConfidenceTests/ConfidenceTests.swift b/Tests/ConfidenceTests/ConfidenceTests.swift index b77e05a5..a5bbaad0 100644 --- a/Tests/ConfidenceTests/ConfidenceTests.swift +++ b/Tests/ConfidenceTests/ConfidenceTests.swift @@ -1,5 +1,5 @@ -import Confidence import XCTest +@testable import Confidence final class ConfidenceTests: XCTestCase { func testWithContext() { @@ -7,6 +7,7 @@ final class ConfidenceTests: XCTestCase { clientSecret: "", timeout: TimeInterval(), region: .europe, + eventSenderEngine: EventSenderEngineMock(), initializationStrategy: .activateAndFetchAsync, context: ["k1": ConfidenceValue(string: "v1")] ) @@ -25,8 +26,10 @@ final class ConfidenceTests: XCTestCase { clientSecret: "", timeout: TimeInterval(), region: .europe, + eventSenderEngine: EventSenderEngineMock(), initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + parent: nil ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] @@ -47,8 +50,10 @@ final class ConfidenceTests: XCTestCase { clientSecret: "", timeout: TimeInterval(), region: .europe, + eventSenderEngine: EventSenderEngineMock(), initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + parent: nil ) confidence.updateContextEntry( key: "k1", @@ -64,8 +69,10 @@ final class ConfidenceTests: XCTestCase { clientSecret: "", timeout: TimeInterval(), region: .europe, + eventSenderEngine: EventSenderEngineMock(), initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + parent: nil ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] @@ -85,8 +92,10 @@ final class ConfidenceTests: XCTestCase { clientSecret: "", timeout: TimeInterval(), region: .europe, + eventSenderEngine: EventSenderEngineMock(), initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + parent: nil ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] @@ -106,11 +115,13 @@ final class ConfidenceTests: XCTestCase { clientSecret: "", timeout: TimeInterval(), region: .europe, + eventSenderEngine: EventSenderEngineMock(), initializationStrategy: .activateAndFetchAsync, context: [ "k1": ConfidenceValue(string: "v1"), "k2": ConfidenceValue(string: "v2") - ] + ], + parent: nil ) confidence.removeContextEntry(key: "k2") let expected = [ @@ -124,8 +135,10 @@ final class ConfidenceTests: XCTestCase { clientSecret: "", timeout: TimeInterval(), region: .europe, + eventSenderEngine: EventSenderEngineMock(), initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + parent: nil ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] @@ -142,8 +155,10 @@ final class ConfidenceTests: XCTestCase { clientSecret: "", timeout: TimeInterval(), region: .europe, + eventSenderEngine: EventSenderEngineMock(), initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + parent: nil ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( [ @@ -163,8 +178,10 @@ final class ConfidenceTests: XCTestCase { clientSecret: "", timeout: TimeInterval(), region: .europe, + eventSenderEngine: EventSenderEngineMock(), initializationStrategy: .activateAndFetchAsync, - context: ["k1": ConfidenceValue(string: "v1")] + context: ["k1": ConfidenceValue(string: "v1")], + parent: nil ) let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( [ diff --git a/Tests/ConfidenceTests/EventStorageTests.swift b/Tests/ConfidenceTests/EventStorageTests.swift index 8a07a0e9..a8c64fce 100644 --- a/Tests/ConfidenceTests/EventStorageTests.swift +++ b/Tests/ConfidenceTests/EventStorageTests.swift @@ -5,16 +5,23 @@ import XCTest class EventStorageTest: XCTestCase { override func setUp() async throws { - let folderURL = try! EventStorageImpl.getFolderURL() + let folderURL = try XCTUnwrap(EventStorageImpl.getFolderURL()) if FileManager.default.fileExists(atPath: folderURL.path) { - try! FileManager.default.removeItem(at: folderURL) + try XCTUnwrap(FileManager.default.removeItem(at: folderURL)) } + try await super.setUp() } func testCreateNewBatch() throws { let eventStorage = try EventStorageImpl() - try eventStorage.writeEvent(event: Event(name: "some event", payload: ["pants": ConfidenceValue(string: "green")], eventTime: Date().self)) - try eventStorage.writeEvent(event: Event(name: "some event 2", payload: ["pants": ConfidenceValue(string: "red")], eventTime: Date().self)) + try eventStorage.writeEvent(event: ConfidenceEvent( + name: "some event", + payload: ["pants": ConfidenceValue(string: "green")], + eventTime: Date().self)) + try eventStorage.writeEvent(event: ConfidenceEvent( + name: "some event 2", + payload: ["pants": ConfidenceValue(string: "red")], + eventTime: Date().self)) try eventStorage.startNewBatch() try XCTAssertEqual(eventStorage.batchReadyIds().count, 1) let events = try eventStorage.eventsFrom(id: try eventStorage.batchReadyIds()[0]) @@ -24,10 +31,16 @@ class EventStorageTest: XCTestCase { func testContinueWritingToOldBatch() throws { let eventStorage = try EventStorageImpl() - try eventStorage.writeEvent(event: Event(name: "some event", payload: ["pants": ConfidenceValue(string: "green")], eventTime: Date().self)) + try eventStorage.writeEvent(event: ConfidenceEvent( + name: "some event", + payload: ["pants": ConfidenceValue(string: "green")], + eventTime: Date().self)) // user stops using app, new session after this let eventStorageNew = try EventStorageImpl() - try eventStorageNew.writeEvent(event: Event(name: "some event 2", payload: ["pants": ConfidenceValue(string: "red")], eventTime: Date().self)) + try eventStorageNew.writeEvent(event: ConfidenceEvent( + name: "some event 2", + payload: ["pants": ConfidenceValue(string: "red")], + eventTime: Date().self)) try eventStorageNew.startNewBatch() try XCTAssertEqual(eventStorageNew.batchReadyIds().count, 1) let events = try eventStorageNew.eventsFrom(id: try eventStorageNew.batchReadyIds()[0]) @@ -37,8 +50,14 @@ class EventStorageTest: XCTestCase { func testRemoveFile() throws { let eventStorage = try EventStorageImpl() - try eventStorage.writeEvent(event: Event(name: "some event", payload: ["pants": ConfidenceValue(string: "green")], eventTime: Date().self)) - try eventStorage.writeEvent(event: Event(name: "some event 2", payload: ["pants": ConfidenceValue(string: "red")], eventTime: Date().self)) + try eventStorage.writeEvent(event: ConfidenceEvent( + name: "some event", + payload: ["pants": ConfidenceValue(string: "green")], + eventTime: Date().self)) + try eventStorage.writeEvent(event: ConfidenceEvent( + name: "some event 2", + payload: ["pants": ConfidenceValue(string: "red")], + eventTime: Date().self)) try eventStorage.startNewBatch() try eventStorage.remove(id: eventStorage.batchReadyIds()[0]) try XCTAssertEqual(eventStorage.batchReadyIds().count, 0) diff --git a/Tests/ConfidenceTests/Helpers/ConfidenceClientMock.swift b/Tests/ConfidenceTests/Helpers/ConfidenceClientMock.swift new file mode 100644 index 00000000..b16fbb9d --- /dev/null +++ b/Tests/ConfidenceTests/Helpers/ConfidenceClientMock.swift @@ -0,0 +1,9 @@ +import Foundation + +@testable import Confidence + +class ConfidenceClientMock: ConfidenceClient { + func upload(events: [NetworkEvent]) async throws -> Bool { + return true + } +} diff --git a/Tests/ConfidenceTests/Helpers/EventSenderEngineMock.swift b/Tests/ConfidenceTests/Helpers/EventSenderEngineMock.swift new file mode 100644 index 00000000..2d3bbe9e --- /dev/null +++ b/Tests/ConfidenceTests/Helpers/EventSenderEngineMock.swift @@ -0,0 +1,12 @@ +import Foundation +@testable import Confidence + +class EventSenderEngineMock: EventSenderEngine { + func emit(definition: String, payload: ConfidenceStruct, context: ConfidenceStruct) { + // NO-OP + } + + func shutdown() { + // NO-OP + } +} diff --git a/Tests/ConfidenceTests/Helpers/MockedClientURLProtocol.swift b/Tests/ConfidenceTests/Helpers/MockedClientURLProtocol.swift new file mode 100644 index 00000000..ee5d80cf --- /dev/null +++ b/Tests/ConfidenceTests/Helpers/MockedClientURLProtocol.swift @@ -0,0 +1,113 @@ +import Foundation +import Common +import XCTest + +@testable import Confidence + +class MockedClientURLProtocol: URLProtocol { + public static var mockedOperation = MockedOperation.success + + enum MockedOperation { + case firstEventFails + case malformedResponse + case badRequest + case success + } + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + guard let path = request.url?.absoluteString, request.httpMethod == "POST" else { + client?.urlProtocol(self, didFailWithError: NSError(domain: "test", code: URLError.badURL.rawValue)) + return + } + + switch path { + case _ where path.hasSuffix("/events:publish"): + return upload() + default: + client?.urlProtocol(self, didFailWithError: NSError(domain: "test", code: URLError.badURL.rawValue)) + return + } + } + + override func stopLoading() { + // This is called if the request gets canceled or completed. + } + + static func mockedSession() -> URLSession { + let config = URLSessionConfiguration.default + config.protocolClasses = [MockedClientURLProtocol.self] + return URLSession(configuration: config) + } + + static func reset() { + MockedClientURLProtocol.mockedOperation = MockedOperation.success + } + + private func upload() { + guard let request = request.decodeBody(type: PublishEventRequest.self) else { + client?.urlProtocol( + self, didFailWithError: NSError(domain: "test", code: URLError.cannotDecodeRawData.rawValue)) + return + } + + XCTAssertNotNil(request.clientSecret) + XCTAssertNotNil(request.sendTime) + XCTAssertNotEqual(request.sendTime, "") + + switch MockedClientURLProtocol.mockedOperation { + case .badRequest: + respondWithError(statusCode: 400, code: 0, message: "explanation about malformed request") + case .malformedResponse: + malformedResponse() + case .firstEventFails: + respondWithSuccess(response: PublishEventResponse(errors: [ + EventError.init(index: 0, reason: .eventDefinitionNotFound, message: "") + ])) + case .success: + respondWithSuccess(response: PublishEventResponse(errors: [])) + } + } + + private func malformedResponse() { + let response = URLResponse() // Malformed/Incomplete + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) + } + + private func respondWithError(statusCode: Int, code: Int, message: String) { + let error = HttpError(code: code, message: message, details: []) + let errorData = try? JSONEncoder().encode(error) + + let response = HTTPURLResponse( + // swiftlint:disable:next force_unwrapping + url: request.url!, statusCode: statusCode, httpVersion: "", headerFields: [:])! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let errorData = errorData { + client?.urlProtocol(self, didLoad: errorData) + } + client?.urlProtocolDidFinishLoading(self) + } + + private func respondWithSuccess(response: Codable) { + // swiftlint:disable:next force_unwrapping + let httpResponse = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "", headerFields: [:])! + + client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + + if let data = try? JSONEncoder().encode(response) { + client?.urlProtocol(self, didLoad: data) + } + + client?.urlProtocolDidFinishLoading(self) + } +} diff --git a/Tests/ConfidenceTests/RemoteConfidenceClientTests.swift b/Tests/ConfidenceTests/RemoteConfidenceClientTests.swift new file mode 100644 index 00000000..9e7a89d3 --- /dev/null +++ b/Tests/ConfidenceTests/RemoteConfidenceClientTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Common +import XCTest + +@testable import Confidence + +// swiftlint:enable:next force_cast +class RemoteConfidenceClientTest: XCTestCase { + override func setUp() { + MockedClientURLProtocol.reset() + super.setUp() + } + + func testUploadDoesntThrow() async throws { + let client = RemoteConfidenceClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + + let processed = try await client.upload(events: [ + NetworkEvent( + eventDefinition: "testEvent", + payload: NetworkStruct.init(fields: [:]), + eventTime: Date.backport.nowISOString + ) + ]) + XCTAssertTrue(processed) + } + + func testUploadEmptyeventsDoesntThrow() async throws { + let client = RemoteConfidenceClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + + let processed = try await client.upload(events: []) + XCTAssertTrue(processed) + } + + func testUploadFirstEventFailsDoesntThrow() async throws { + MockedClientURLProtocol.mockedOperation = .firstEventFails + let client = RemoteConfidenceClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + + let processed = try await client.upload(events: [ + NetworkEvent( + eventDefinition: "testEvent", + payload: NetworkStruct.init(fields: [:]), + eventTime: Date.backport.nowISOString + ) + ]) + XCTAssertTrue(processed) + } + + func testBadRequestThrows() async throws { + MockedClientURLProtocol.mockedOperation = .badRequest + let client = RemoteConfidenceClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + + var caughtError: ConfidenceError? + do { + _ = try await client.upload(events: [ + NetworkEvent( + eventDefinition: "testEvent", + payload: NetworkStruct.init(fields: [:]), + eventTime: Date.backport.nowISOString + ) + ]) + } catch { + caughtError = error as! ConfidenceError? + } + let expectedError = ConfidenceError.badRequest(message: "explanation about malformed request") + XCTAssertEqual(caughtError, expectedError) + } + + func testNMalformedResponseThrows() async throws { + MockedClientURLProtocol.mockedOperation = .malformedResponse + let client = RemoteConfidenceClient( + options: ConfidenceClientOptions( + credentials: ConfidenceClientCredentials.clientSecret(secret: "")), + session: MockedClientURLProtocol.mockedSession(), + metadata: ConfidenceMetadata(name: "", version: "")) + + var caughtError: ConfidenceError? + do { + _ = try await client.upload(events: [ + NetworkEvent( + eventDefinition: "testEvent", + payload: NetworkStruct.init(fields: [:]), + eventTime: Date.backport.nowISOString + ) + ]) + } catch { + caughtError = error as! ConfidenceError? + } + let expectedError = ConfidenceError.internalError(message: "invalidResponse") + XCTAssertEqual(caughtError, expectedError) + } +}